Compare commits

..

13 Commits

Author SHA1 Message Date
sagit 53e1efcdc1 style: improve glassmorphism UI for background adaptability (#420)
Increase backdrop blur and lower white/black background opacity on inner
elements (tables, charts, tabs). Apply higher blur to admin layout
sidebar and H5 layout navigation bar. Remove solid backgrounds inside
nested cards to inherit background themes.
2026-04-06 10:23:16 +08:00
sagitchu 71d5c36ec3 style: improve glassmorphism UI for background adaptability
- Increase backdrop blur and lower white/black background opacity on inner elements (tables, charts, tabs)
- Apply higher blur to admin layout sidebar and H5 layout navigation bar
- Remove solid backgrounds inside nested cards to inherit background themes
2026-04-06 10:21:06 +08:00
sagit be4b1f5f25 fix(ui): improve background handling and PWA update prompts (#419)
## Changes
- Add adaptive solid color background support for dark/light themes.
- Replace mesh gradient with semi-transparent blurred background images.
- Fix background scrolling issue on mobile browsers.
- Remove borders from specific cards for consistent UI styling.
- Prevent caching of PWA service worker files in Nginx.
- Introduce interactive toast prompt for PWA updates instead of silent
refresh.
2026-04-05 20:55:15 +08:00
sagitchu cd4238274a fix(ui): improve background handling and PWA update prompts
- Add adaptive solid color background support for dark/light themes.
- Replace mesh gradient with semi-transparent blurred background images.
- Fix background scrolling issue on mobile browsers.
- Remove borders from specific cards for consistent UI styling.
- Prevent caching of PWA service worker files in Nginx.
- Introduce interactive toast prompt for PWA updates instead of silent refresh.
2026-04-05 20:53:25 +08:00
sagit 25dc03c894 style: optimize card and table backgrounds for better custom background visibility (#418)
Changed multiple bg-white/40 and bg-white/60 classes to more transparent
variants like bg-white/10 and bg-white/20, or bg-transparent to allow
the custom background image to show through.
2026-04-05 18:47:13 +08:00
sagitchu 4cf9c9174a style: optimize card and table backgrounds for better custom background visibility 2026-04-05 18:45:17 +08:00
sagit a45bd43e73 feat: add global background image setting to config page (#417)
Add app_bg_image setting to /config page to allow global background
customization.
2026-04-05 18:36:31 +08:00
sagitchu 364bace87f fix: remove unused imports and leftover jsx in settings 2026-04-05 18:35:07 +08:00
sagitchu 2caafab937 feat: add global background image setting to config page 2026-04-05 18:31:59 +08:00
sagit 901b903a70 fix: frontend browser caching issue (#416)
Disable caching for index.html and reduce static assets cache to 30d.
2026-04-05 17:51:56 +08:00
sagitchu 930cc86614 fix: frontend browser caching issue
Disable caching for index.html and reduce static assets cache to 30d.
2026-04-05 17:50:12 +08:00
sagit 101fd5805a feat(ui): add persistent custom background image upload feature (#415) 2026-04-05 17:44:19 +08:00
sagit 938e9ff4fd feat(ui): redesign with transparent liquid glass (#414)
* docs: add design spec for iOS 26 Liquid Glass UI redesign

* docs: add implementation plan for iOS 26 Liquid Glass UI redesign

* feat(ui): add liquid glass CSS variables and update root layouts

* feat(ui): update Card and Modal components to iOS 26 Liquid Glass style

* feat(ui): redesign dashboard page to Liquid Glass style

* feat(ui): redesign node management page cards and charts

* feat(ui): redesign monitor page to Liquid Glass style

* feat(ui): redesign group and panel-sharing cards to Liquid Glass

* feat(ui): redesign settings and user components to Liquid Glass

* feat(ui): redesign profile and auth pages to Liquid Glass

* fix(ui): revert to LineChart to fix compilation issues

* refactor(ui): extract dashboard user header and cleanup admin layout

* feat(ui): refine monitor page hero metrics and fix build

* fix(ui): real data for monitor hero metrics and card padding adjustments

* fix(ui): apply global card padding and fix index layouts

* feat(ui): implement liquid-glass-react for all Card components

* fix(ui): resolve liquid-glass-react rendering issues causing invisible text and broken layout

* fix(ui): native CSS liquid-glass effect replacing NPM package

* fix(ui): cleanup duplicate closing tags and complete liquid glass tables

* fix(ui): apply perfect rounded corners to list views and table headers in non-compact mode

* fix(ui): apply liquid glass styling to grouped rules view in forward page

* fix(ui): re-implement liquid-glass natively to prevent text distortion and remove package dependency

* fix(ui): remove mouse distortion and preserve proper liquid glass texture

* fix(ui): remove svg filter causing text distortion and replace with pure css liquid glass

* fix(ui): apply properly scoped border radius to table headers

* fix(ui): remove hardcoded bg-default-50/60 from TableHeader

* fix(ui): apply global liquid glass styling and transparent overlays to all modals and dialogs

* fix(ui): remove absolute/relative class conflicts hiding Dialog modales and implement pure transparent layout

* feat(ui): apply global liquid glass styling to cards, buttons, and diagnosis modals
2026-04-05 13:22:12 +08:00
101 changed files with 2741 additions and 14208 deletions
+2 -5
View File
@@ -21,14 +21,11 @@ jobs:
with: with:
node-version: '20.19.0' node-version: '20.19.0'
- name: Install pnpm
run: npm install -g pnpm
- name: Install dependencies - name: Install dependencies
run: pnpm install --frozen-lockfile run: npm install --legacy-peer-deps
- name: Build - name: Build
run: pnpm run build run: npm run build
backend: backend:
name: Build Go Backend name: Build Go Backend
+111 -50
View File
@@ -1,62 +1,123 @@
# AGENTS # PROJECT KNOWLEDGE BASE
FLVX — traffic forwarding panel: Go admin API + Vite/React UI + Go agent. **Generated:** Tue Mar 24 2026
**Commit:** 8ebde9d
**Branch:** main
**Tag:** 2.1.9-rc10
## Structure ## 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.
| Dir | Role | Entry | ## STRUCTURE
|-----|------|-------| ```
| `go-backend/` | Admin API (GORM + SQLite/PG, net/http) | `cmd/paneld/main.go` | ./
| `go-gost/` | Forwarding agent (forked GOST) | `main.go` | ├── go-gost/ # Go forwarding agent (forked gost + local x/)
| `go-gost/x/` | Protocol handlers/dialers/listeners (own module) | — | │ └── x/ # Local fork of github.com/go-gost/x (replace => ./x)
| `vite-frontend/` | React dashboard (shadcn bridge + Tailwind v4) | `src/App.tsx` | ├── go-backend/ # Go Admin API (GORM + SQLite/PostgreSQL, net/http)
│ └── tests/contract/ # Integration/contract tests
`go-gost/go.mod` uses `replace github.com/go-gost/x => ./x`. ├── vite-frontend/ # React/Vite dashboard (shadcn bridge + Tailwind v4)
│ └── src/shadcn-bridge/heroui/ # HeroUI-compatible facade
## Commands ├── docker-compose-v4.yml # Panel deploy (IPv4-only bridge)
├── docker-compose-v6.yml # Panel deploy (IPv6-enabled bridge)
```bash ├── panel_install.sh # Panel installer/upgrader (downloads compose)
# Backend ├── install.sh # Node installer/upgrader (downloads gost binary)
(cd go-backend && go run ./cmd/paneld) # SERVER_ADDR defaults to :6365 └── .github/workflows/ # CI: build/test + Docker push + release artifacts
(cd go-backend && make build)
(cd go-backend && go test ./...)
# Frontend
(cd vite-frontend && pnpm install)
(cd vite-frontend && pnpm run dev) # host 0.0.0.0:3000
(cd vite-frontend && pnpm run build) # tsc && vite build
(cd vite-frontend && pnpm run lint) # eslint --fix (no typecheck command)
# Agent
(cd go-gost && go run .)
``` ```
## Conventions ## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Deploy (Docker)** | `docker-compose-v4.yml` | Env: `JWT_SECRET`, `BACKEND_PORT`, `FRONTEND_PORT` |
| **Deploy (IPv6)** | `docker-compose-v6.yml` | Same as v4 + IPv6-enabled bridge |
| **Panel install** | `panel_install.sh` | Picks v4/v6, generates `JWT_SECRET`, downloads compose |
| **Node install** | `install.sh` | Installs `/etc/flux_agent/flux_agent` + writes `config.json`/`gost.json` + systemd `flux_agent.service` |
| **Admin API** | `go-backend/` | Go Admin API (SQLite/PostgreSQL) |
| **Web UI** | `vite-frontend/` | React/Vite dashboard (shadcn bridge + Tailwind v4) |
| **UI Compatibility** | `vite-frontend/src/shadcn-bridge/heroui/` | HeroUI-compatible API wrappers backed by shadcn/radix |
| **Theme Tokens** | `vite-frontend/src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic color mapping |
| **Go Agent** | `go-gost/` | Forwarding agent (forked gost + local x/) |
| **Go Core** | `go-gost/x/` | Handlers/listeners/dialers + management API |
| **Repository Layer** | `go-backend/internal/store/repo/` | GORM data access (repository.go 83k LOC) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
| **CI Workflows** | `.github/workflows/` | ci-build.yml, docker-build.yml, deploy-docs.yml |
- **Auth**: raw JWT in `Authorization` header — **no `Bearer` prefix** (both frontend and backend). ## CODE MAP
- **API envelope**: all responses `{code, msg, data, ts}` (code 0 = success). | Symbol | Type | Location | Role |
- **Frontend UI**: import from `src/shadcn-bridge/heroui/*`, never `@heroui/*` or `@nextui-org/*`. |--------|------|----------|------|
- **Tailwind theme**: `globals.css` must import `tailwind-theme.pcss` or semantic classes break. | `flvx` | Project | `.` | Root directory |
- **Backend DB**: handlers use Repository methods, never `repo.DB()` directly. | `main` | Func | `go-backend/cmd/paneld/main.go` | Backend Entry |
- **GORM models**: always define `TableName()` (GORM pluralizes by default). | `App` | Component | `vite-frontend/src/App.tsx` | Frontend Entry |
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible). | `main` | Func | `go-gost/main.go` | Agent Entry |
- **Go versions**: `go.mod` says 1.25.0 for all three modules; CI builds with 1.23. | `Repository` | Struct | `go-backend/internal/store/repo/repository.go` | Data Access Layer |
| `Handler` | Struct | `go-backend/internal/http/handler/handler.go` | HTTP Handlers |
| `websocket_reporter` | Func | `go-gost/x/socket/websocket_reporter.go` | Panel Telemetry |
## Anti-patterns ## CONVENTIONS
- **Skills & MCP**: Always prefer using available skills (via `skill` tool) and MCP tools when applicable. Check for relevant skills before implementing from scratch.
- **Auth**: `Authorization` header carries the raw JWT token (no `Bearer` prefix) between `vite-frontend/` and `go-backend/`.
- **Module Fork**: `go-gost/` uses `replace github.com/go-gost/x => ./x` and `go-gost/x/` is also its own Go module.
- **Encryption**: Agent-to-panel communication uses AES encryption with node `secret` as PSK.
- **API Envelope**: All REST responses follow `{code, msg, data, ts}` structure (code 0 = success).
- **Frontend UI Layer**: Import UI primitives from `src/shadcn-bridge/heroui/*` (legacy-compatible facade), not direct `@heroui/*` packages.
- **Tailwind v4 Semantic Colors**: `src/styles/globals.css` must import `src/styles/tailwind-theme.pcss`; removing it breaks semantic classes like `bg-primary`, `text-foreground`, and `border-input`.
- **Go Versions**: `go-backend` uses Go 1.24, `go-gost` uses Go 1.23, `go-gost/x` uses Go 1.22.
- Don't edit `install.sh` or `panel_install.sh` locally (CI overwrites on release). ## ANTI-PATTERNS (THIS PROJECT)
- Don't edit `go-gost/x/internal/util/grpc/proto/*.pb.go` (generated). - **DO NOT EDIT** generated protobuf output: `go-gost/x/internal/util/grpc/proto/*.pb.go`, `go-gost/x/internal/util/grpc/proto/*_grpc.pb.go`.
- Don't add frontend tests (no Vitest/Jest configured). - **DO NOT ADD** `Bearer` prefix to Authorization header - expects raw JWT token.
- Don't reintroduce `@heroui/*` or `@nextui-org/*` packages. - **DO NOT MODIFY** `install.sh` or `panel_install.sh` locally - CI overwrites these on release.
- **DO NOT** let backend handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT ADD** frontend tests - project has no test infrastructure (Vitest/Jest not configured).
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies; migration is now shadcn bridge-based.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## Testing ## COMMANDS
```bash
# Panel (Docker)
docker compose -f docker-compose-v4.yml up -d
docker compose -f docker-compose-v6.yml up -d
- Backend: `(cd go-backend && go test ./...)` — includes contract tests in `tests/contract/`. # Release-based install scripts
- Frontend: no test infrastructure. ./panel_install.sh
- CI runs one PostgreSQL contract test: env var `FLVX_POSTGRES_TEST_DSN`. ./install.sh
## Build quirks # Local dev (per subproject)
(cd go-backend && make build)
(cd vite-frontend && npm run dev)
(cd go-gost && go run .)
- `vite-frontend` uses `rolldown-vite` (Rust bundler), not standard Vite. # Testing
- `vite.config.ts`: `minify: false`, `treeshake: false` (debugging mode). (cd go-backend && go test ./...)
- CI builds `go-gost` with `CGO_ENABLED=0` then compresses with UPX `--best --lzma`. (cd go-backend && go test ./tests/contract/...)
```
## UNIQUE STYLES
- **Flat Monorepo**: Language-prefixed dirs (`go-backend`, `go-gost`, `vite-frontend`) instead of `apps/`/`libs/`.
- **Asymmetric Go Layout**: `go-backend` follows `cmd/<app>/main.go` while `go-gost` uses `root/main.go`.
- **Frontend Hybrid Mode**: `App.tsx` detects "H5 mode" (mobile WebView) vs desktop, dictating layout strategy.
- **Experimental Bundler**: `vite-frontend` uses `rolldown-vite` (Rust-based) instead of standard Vite.
- **Non-minified Builds**: `vite.config.ts` sets `minify: false`, `treeshake: false` for debugging.
## NOTES
- LSP servers are not installed in this environment (gopls/typescript-language-server); rely on grep-based navigation.
- `vite-frontend/vite.config.ts` sets `minify: false` and disables treeshake; expect larger bundles.
- `vite-frontend` uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Install scripts (`install.sh`, `panel_install.sh`) self-delete after execution - common pattern in one-liner installs.
- CI uses UPX compression (`--best --lzma`) on Go binaries before release.
- CI dynamically injects `PINNED_VERSION` into install scripts and docker-compose files during releases.
- `panel_install.sh` auto-detects IPv6 and modifies `/etc/docker/daemon.json` to enable IPv6 bridge.
- Download proxy `https://gcode.hostcentral.cc/` used for GitHub downloads in China/restricted environments.
- Backend has contract tests in `go-backend/tests/contract/` - frontend has no test infrastructure.
- `analysis/3x-ui/` contains a separate git repo for reference/comparison - not part of FLVX core.
- CI workflows: `ci-build.yml` (build check), `docker-build.yml` (multi-arch images + release), `deploy-docs.yml` (MkDocs).
- PostgreSQL migration supported via `panel_install.sh` menu option using pgloader.
- Repository layer is large: `repository.go` (83k LOC), `repository_mutations.go` (43k LOC).
- Button visual parity relies on `vite-frontend/src/shadcn-bridge/heroui/button.tsx` color mapping + `vite-frontend/src/styles/tailwind-theme.pcss` token export.
## PLAN DOCUMENT RULE
- Every new implementation plan must have a dedicated Markdown plan document.
- Store plan documents under `plans/`.
- Use an incrementing numeric prefix and a short plan-summary name: `NNN-<plan-summary>.md` (for example, `001-auth-refactor.md`, `002-federation-api-cleanup.md`).
- The numeric prefix must increase by 1 for each new plan.
- In each plan document, keep a task checklist and mark each task as completed immediately after finishing it.
@@ -1,482 +0,0 @@
# 最大连接数限制实现计划
> **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 (`[x]`) syntax for tracking.
**Goal:** 在 FLVX 中实现基于用户的全局最大连接数限制和基于单条规则的独立最大连接数限制功能。前端输入框为 0 或空时表示不限制。
**Architecture:** 采用“覆盖逻辑”(方案二)。
1. 数据库层面:在 `user` 和 `forward` 表中各增加一个整型字段 `max_conn`,默认值为 0(表示不限制)。
2. 后端接口层面:提供 API 更新该字段,在组装下发给 GOST 的配置时,判断规则的 `max_conn` 是否大于 0:
- 如果规则 `max_conn > 0`,则为此规则动态生成一个唯一的连接限制器配置,并在下发服务的 `climiter` 字段中引用该限制器。
- 如果规则 `max_conn == 0`,则检查该规则所属用户的 `max_conn`。
- 如果用户 `max_conn > 0`,则引用以用户维度的连接限制器配置(如 `user_conn_limit_<user_id>`)。
- 否则不下发 `climiter`。
3. 后端服务控制平面:需要在下发服务前,将需要的连接限制器(Rule 或 User 维度)推送到节点上。
- **重要发现:** 当前 `go-gost` 的 WebSocket Reporter (`go-gost/x/socket/websocket_reporter.go`) 仅支持 `TrafficLimiter` 的动态增删(如 `AddLimiters` 等),**不支持** `ConnLimiter`(即 `CLimiters`)。
- **计划修改:** 我们需要先在 `go-gost` 侧(`go-gost/x/socket`)添加针对 `CLimiters` 的 WebSocket 指令(`AddCLimiters`, `UpdateCLimiters`, `DeleteCLimiters`)以及对应的处理函数(参考 `AddLimiters` 等的实现,调用现有的针对 `ConnLimiterRegistry` 的相关接口和配置存储逻辑,具体需要实现类似 `createLimiter` 到 `createConnLimiter` 的逻辑)。
- 完成底层修改后,`go-backend` 再通过这些新增加的 WebSocket 指令,在 `ensureLimiterOnNode` 时下发最大连接数限制规则。
4. 前端层面:在用户管理和规则管理页面增加输入框组件。
**Tech Stack:** Go, GORM, SQLite/PostgreSQL, React, Vite, TypeScript, TailwindCSS.
---
### Task 1: 扩展 go-gost WebSocket 接口以支持 CLimiters
**Files:**
- Modify: `go-gost/x/socket/limiter.go`
- Modify: `go-gost/x/socket/websocket_reporter.go`
[x] **Step 1: 实现 `createConnLimiter` 等功能**
在 `go-gost/x/socket/limiter.go` 中参考现有 `createLimiter` 添加对 `CLimiters` 的支持:
```go
func createConnLimiter(req createLimiterRequest) error {
name := strings.TrimSpace(req.Data.Name)
if name == "" {
return errors.New("limiter name is required")
}
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
return errors.New("conn limiter " + name + " already exists")
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func updateConnLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
for i := range c.CLimiters {
if c.CLimiters[i].Name == name {
c.CLimiters[i] = &req.Data
return nil
}
}
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func deleteConnLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
if c := config.Global(); c != nil {
limiteres := c.CLimiters
c.CLimiters = nil
for _, s := range limiteres {
if s.Name == name {
continue
}
c.CLimiters = append(c.CLimiters, s)
}
}
return nil
}
```
[x] **Step 2: 在 `WebSocketReporter` 注册命令**
在 `go-gost/x/socket/websocket_reporter.go` 的 `ProcessCommand` 中添加 case:
```go
case "AddCLimiters":
err = w.handleAddCLimiter(cmd.Data)
response.Type = "AddCLimitersResponse"
needSaveConfig = true
case "UpdateCLimiters":
err = w.handleUpdateCLimiter(cmd.Data)
response.Type = "UpdateCLimitersResponse"
needSaveConfig = true
case "DeleteCLimiters":
err = w.handleDeleteCLimiter(cmd.Data)
response.Type = "DeleteCLimitersResponse"
needSaveConfig = true
```
[x] **Step 3: 实现 Handler 方法**
在 `go-gost/x/socket/websocket_reporter.go` 中添加:
```go
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析限流器配置失败: %v", err)
}
req := createLimiterRequest{Data: limiterConfig}
return createConnLimiter(req)
}
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var updateReq struct {
Limiter string `json:"limiter"`
Data config.LimiterConfig `json:"data"`
}
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析更新请求失败: %v", err)
}
updateReq.Limiter = limiterConfig.Name
updateReq.Data = limiterConfig
}
req := updateLimiterRequest{
Limiter: updateReq.Limiter,
Data: updateReq.Data,
}
return updateConnLimiter(req)
}
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var deleteReq deleteLimiterRequest
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
var limiterName string
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
return fmt.Errorf("解析删除请求失败: %v", err)
}
deleteReq.Limiter = limiterName
}
return deleteConnLimiter(deleteReq)
}
```
[x] **Step 4: Commit**
```bash
cd go-gost
git add x/socket/limiter.go x/socket/websocket_reporter.go
git commit -m "feat: add CLimiters support for websocket reporter"
cd ..
```
---
### Task 2: 数据库迁移与模型更新
**Files:**
- Modify: `go-backend/internal/store/model/model.go`
- Modify: `go-backend/internal/store/repo/repository.go`
[x] **Step 1: 更新数据库模型**
在 `go-backend/internal/store/model/model.go` 的 `User` 和 `Forward` 结构体中添加 `MaxConn` 字段。
```go
// 在 User 结构体中
type User struct {
// ...
MaxConn int `gorm:"column:max_conn;not null;default:0"`
// ...
}
// 在 Forward 结构体中
type Forward struct {
// ...
MaxConn int `gorm:"column:max_conn;not null;default:0"`
// ...
}
```
[x] **Step 2: 编写数据库迁移**
在 `go-backend/internal/store/repo/repository.go` 的 `AutoMigrate` 逻辑前(如果有自定义迁移)或利用 gorm 自动迁移机制,由于这是 autoMigrate,添加字段只要 `db.AutoMigrate(&model.User{}, &model.Forward{})` 被调用就能自动加上。确认已执行迁移。由于 `FLVX` 通常会自动执行迁移,只需修改模型即可。我们需要处理默认值,由于使用了 `default:0`,GORM 会处理新增字段的默认值,但为了安全起见,如果在旧环境中,可能直接 alter table。
```go
// 无需手动编写 SQL,依赖现有的 gorm AutoMigrate 即可。
```
[x] **Step 3: 运行并验证迁移通过**
Run: `make build` (在 go-backend 中),或者运行一个相关的存储单元测试。
[x] **Step 4: Commit**
```bash
cd go-backend
git add internal/store/model/model.go
git commit -m "feat: add max_conn field to user and forward models"
cd ..
```
---
### Task 3: 后端控制平面 - 连接数限制器的组装与下发
**Files:**
- Modify: `go-backend/internal/http/handler/control_plane.go`
- Modify: `go-backend/internal/store/repo/repository_control.go`
[x] **Step 1: 更新存储层以获取 User 的 MaxConn**
在 `go-backend/internal/store/repo/repository_control.go` 中:
需要一个方法获取 User,或者如果已经有,确保可以拿到 `MaxConn`。
[x] **Step 2: 编写下发 CLimiter 到节点的辅助函数**
在 `go-backend/internal/http/handler/control_plane.go`,参考 `ensureLimiterOnNode` 和 `upsertLimiterOnNode`:
```go
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
limitStr := fmt.Sprintf("$ %d", maxConn)
payload := map[string]interface{}{
"name": limiterName,
"limits": []string{limitStr},
}
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
if !isAlreadyExistsMessage(err.Error()) {
return fmt.Errorf("连接限制器下发失败: %w", err)
}
updatePayload := map[string]interface{}{
"limiter": limiterName,
"data": payload,
}
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
}
}
return nil
}
```
[x] **Step 3: 更新组装配置逻辑以绑定 `climiter`**
在 `control_plane.go` 的 `syncForwardServicesWithWarnings` 及其辅助函数 `buildForwardServiceConfigs` 附近:
修改 `buildForwardServiceConfigs` 的签名,传入 `maxConn int` 和对应的 `cLimiterName string`。
```go
func buildForwardServiceConfigs(baseName string, forward *model.Forward, tunnel *model.Tunnel, node *model.Node, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {
// ... 现有逻辑
// 在服务配置生成的部分增加:
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
// ...
}
```
[x] **Step 4: 在转发服务同步主流程中决定并下发 `climiter`**
在 `syncForwardServicesWithWarnings` (可能在多个重载/处理入口处,如 `ensureForwardServices`),查出转发所属 user 的 `MaxConn`,以及转发本身的 `MaxConn`。
```go
// 获取 User
user, err := h.repo.GetUser(forward.UserID)
if err != nil {
return nil, err
}
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
if cLimiterName != "" {
for _, fp := range ports {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
}
// 传递给 buildForwardServiceConfigs
// ...
```
*(注意:需要确保更新涉及 `buildForwardServiceConfigs` 的所有调用点)*
[x] **Step 5: Commit**
```bash
cd go-backend
git add internal/http/handler/control_plane.go internal/store/repo/repository_control.go
git commit -m "feat: implement max conn limiter dispatching"
cd ..
```
---
### Task 4: 后端接口 - 用户和规则的 CRUD 支持
**Files:**
- Modify: `go-backend/internal/http/handler/admin_user.go`
- Modify: `go-backend/internal/http/handler/forward.go`
[x] **Step 1: 用户接口更新**
在 `go-backend/internal/http/handler/admin_user.go`,修改用户创建和更新请求的结构体(如果有),接收 `MaxConn`,并在保存到数据库时赋值。
```go
type CreateUserReq struct {
// ...
MaxConn *int `json:"maxConn"`
}
// 接收后:
if req.MaxConn != nil {
user.MaxConn = *req.MaxConn
}
```
在获取用户列表时,确保 `MaxConn` 返回给前端。
[x] **Step 2: 规则接口更新**
在 `go-backend/internal/http/handler/forward.go` 中,更新 `CreateForwardReq` 和 `UpdateForwardReq` 结构体,增加 `MaxConn`,并在创建/更新 Forward 时保存到数据库。
如果转发规则的 `MaxConn` 或相关信息改变,触发节点上的规则重载(重新下发服务)。这一步由于更改了数据库,复用现有的 `syncForwardServices` 就会带上最新的配置。
[x] **Step 3: 测试接口**
Run: 可以启动后使用 curl 测试。
[x] **Step 4: Commit**
```bash
cd go-backend
git add internal/http/handler/admin_user.go internal/http/handler/forward.go
git commit -m "feat: add maxConn to user and forward CRUD API"
cd ..
```
---
### Task 5: 前端 - 用户管理页面集成
**Files:**
- Modify: `vite-frontend/src/api/types.ts`
- Modify: `vite-frontend/src/api/index.ts`
- Modify: `vite-frontend/src/pages/users.tsx` (或者对应的用户管理页面文件)
[x] **Step 1: 类型更新**
在 `vite-frontend/src/api/types.ts` 中:
为 `UserApiItem` 和相关的 mutation payload 增加 `maxConn?: number` 属性。
[x] **Step 2: UI 修改**
在用户创建/编辑弹窗中,增加“最大连接数”输入框:
(假设使用 `@nextui-org/react` 的 `Input`)
```tsx
<Input
type="number"
label="最大并发连接数"
placeholder="0 或空表示不限制"
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
onValueChange={(val) => {
const num = parseInt(val, 10);
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
}}
/>
```
并在用户的表格列中展示 `最大连接数`(值为 0 显示“不限制”)。
[x] **Step 3: 运行 Vite 进行验证**
[x] **Step 4: Commit**
```bash
cd vite-frontend
git add src/api/types.ts src/api/index.ts src/pages/users.tsx
git commit -m "feat: add max conn UI to user management"
cd ..
```
---
### Task 6: 前端 - 转发规则页面集成
**Files:**
- Modify: `vite-frontend/src/pages/forward.tsx`
[x] **Step 1: 类型更新**
在 `api/types.ts` 中 `ForwardMutationPayload` 和 `ForwardApiItem` 中增加 `maxConn?: number`。
[x] **Step 2: UI 修改**
在 `vite-frontend/src/pages/forward.tsx` 的创建/编辑规则弹窗(在 "规则限速" 附近)增加“最大连接数”输入框:
```tsx
<Input
type="number"
label="最大并发连接数"
placeholder="0 或空表示不限制"
value={formData.maxConn === 0 ? "" : String(formData.maxConn || "")}
onValueChange={(val) => {
const num = parseInt(val, 10);
setFormData({ ...formData, maxConn: isNaN(num) ? 0 : num });
}}
description="此设置优先于用户的全局连接数限制。0 表示不限制(或使用用户的全局限制)。"
/>
```
如果是在列表/卡片中展示,可以增加一个小标签或者 Tooltip 显示其最大连接数设置。
[x] **Step 3: 验证**
在前端验证该功能能正确读写规则的连接限制字段。
[x] **Step 4: Commit**
```bash
cd vite-frontend
git add src/pages/forward.tsx src/api/types.ts
git commit -m "feat: add max conn UI to forward rules"
cd ..
```
+68 -34
View File
@@ -1,38 +1,72 @@
# go-backend # GO BACKEND KNOWLEDGE BASE
Admin API for FLVX. Go + net/http + GORM (SQLite/PostgreSQL). **Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## Structure ## 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).
| Dir | Role | ## STRUCTURE
|-----|------| ```
| `cmd/paneld/main.go` | Entry point, HTTP server + WebSocket | go-backend/
| `internal/http/router.go` | Route registration (`http.ServeMux`) + middleware chain | ├── cmd/paneld/main.go # Entry point; starts HTTP server + WebSocket
| `internal/http/handler/` | API handlers | ├── internal/
| `internal/http/middleware/` | JWT, CORS, logging, recover | │ ├── http/ # HTTP layer
| `internal/http/response/` | JSON envelope helpers | │ │ ├── router.go # Routes (NewServeMux) + Middleware chain
| `internal/store/model/model.go` | All GORM models (single file) | │ │ ├── handler/ # API Handlers (User, Tunnel, Node, etc.)
| `internal/store/repo/` | Repository layer (never access DB directly) | │ │ ├── middleware/ # JWT, CORS, Logging, Recover
| `internal/auth/` | Auth logic | │ │ └── response/ # JSON response helpers
| `tests/contract/` | Integration tests | │ ├── store/
│ │ ├── model/model.go # GORM model structs (single source of truth)
## Conventions │ │ └── repo/ # Data Access Layer (Repository pattern, GORM)
│ │ ├── repository.go # Core queries, Open/OpenPostgres, AutoMigrate (83k LOC)
- **Auth**: raw JWT in `Authorization` header — no `Bearer` prefix. │ │ ├── repository_mutations.go # Mutation helpers (user/node/tunnel/forward CRUD, 43k LOC)
- **API envelope**: `{code, msg, data, ts}`, code 0 = success. │ │ ├── repository_federation.go # Federation-specific queries
- **Repository pattern**: handlers call repo methods, never `repo.DB()` directly. │ │ ├── repository_flow.go # Flow/forward status queries
- **GORM**: `TableName()` on every model (GORM pluralizes by default). │ │ ├── repository_control.go # Control plane queries
- **GORM tags**: no `type:jsonb` or `type:serial` (SQLite incompatible). │ │ └── repository_groups.go # Group management queries
- **SQLite**: `MaxOpenConns(1)`, WAL mode, `busy_timeout=5000`. │ └── auth/ # Auth logic
- **Schema**: created via `autoMigrateAll()` at startup, no hand-written DDL. ├── tests/contract/ # Integration/contract tests (14 tests)
- **PostgreSQL**: set `DB_TYPE=postgres` and `DATABASE_URL` env vars. ├── Dockerfile # Multi-stage build (golang:1.24-bookworm → debian:bookworm-slim)
- **Config**: all from environment variables. └── Makefile # Build commands
```
## Commands
## WHERE TO LOOK
```bash | Task | Location | Notes |
go run ./cmd/paneld # SERVER_ADDR defaults to :6365 |------|----------|-------|
make build | **API Routes** | `go-backend/internal/http/router.go` | Registers handlers to `http.ServeMux` |
go test ./... # includes contract tests | **DB Models** | `go-backend/internal/store/model/model.go` | GORM structs with `TableName()` methods |
go test ./tests/contract/... # contract tests only | **Repository** | `go-backend/internal/store/repo/` | GORM-based queries, all DB ops encapsulated |
| **Auth Middleware** | `go-backend/internal/http/middleware/jwt.go` | Extracts `Authorization` header |
| **WebSocket** | `go-backend/internal/ws/` | Real-time updates (traffic, status) |
| **Contract Tests** | `go-backend/tests/contract/` | Integration tests for auth, federation, tunnels |
## CONVENTIONS
- **GORM ORM**: Uses GORM with `glebarez/sqlite` (CGO-free) and `gorm.io/driver/postgres`.
- **AutoMigrate**: Schema created at startup via `autoMigrateAll()` — no hand-written DDL.
- **TableName()**: All models define explicit `TableName()` returning singular snake_case names.
- **Repository Pattern**: Handlers never access `*gorm.DB` directly — all queries go through `repo.Repository` methods.
- **Standard Lib**: Uses `net/http` for routing (Go 1.22+ patterns).
- **Auth**: Expects raw JWT in `Authorization` header (no `Bearer` prefix).
- **API Envelope**: All responses use `response.R{code, msg, data, ts}` structure.
- **Config**: Loaded from environment variables (see `cmd/paneld/main.go`).
- **SQLite Constraints**: `MaxOpenConns(1)`, WAL mode, busy_timeout=5000.
- **PostgreSQL**: Supported via `DB_TYPE=postgres` and `DATABASE_URL` env vars.
## ANTI-PATTERNS
- **DO NOT** let handlers call `repo.DB()` directly — add a Repository method instead.
- **DO NOT CHANGE** handler signatures without updating `router.go`.
- **DO NOT** use `type:jsonb` or `type:serial` in GORM tags (SQLite incompatible).
- **DO NOT** omit `TableName()` on new models — GORM pluralizes by default.
## COMMANDS
```bash
cd go-backend
go run ./cmd/paneld # Default: SERVER_ADDR=:6365
go test ./... # Unit tests
go test ./tests/contract/... # Contract tests
make build
``` ```
+1 -1
View File
@@ -1,4 +1,4 @@
FROM golang:1.25-bookworm AS builder FROM golang:1.24-bookworm AS builder
WORKDIR /src WORKDIR /src
COPY go.mod go.sum ./ COPY go.mod go.sum ./
+7 -5
View File
@@ -1,6 +1,8 @@
module go-backend module go-backend
go 1.25.0 go 1.24.0
toolchain go1.24.4
require ( require (
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
@@ -22,11 +24,11 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.31.0 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.17.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.29.0 // indirect
modernc.org/libc v1.65.7 // indirect modernc.org/libc v1.65.7 // indirect
modernc.org/mathutil v1.7.1 // indirect modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect modernc.org/memory v1.11.0 // indirect
+12 -12
View File
@@ -38,21 +38,21 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
@@ -71,11 +71,10 @@ type RuntimeReleaseRoleRequest struct {
} }
type RuntimeDiagnoseRequest struct { type RuntimeDiagnoseRequest struct {
IP string `json:"ip"` IP string `json:"ip"`
Port int `json:"port"` Port int `json:"port"`
Count int `json:"count"` Count int `json:"count"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
Protocol string `json:"protocol"`
} }
type RuntimeNodeCommandRequest struct { type RuntimeNodeCommandRequest struct {
+31 -156
View File
@@ -42,7 +42,6 @@ type diagnosisWorkItem struct {
toNode chainNodeRecord toNode chainNodeRecord
hasChainHop bool hasChainHop bool
ipPreference string ipPreference string
protocol string
} }
type diagnosisExecOptions struct { type diagnosisExecOptions struct {
@@ -186,6 +185,14 @@ func (h *Handler) listForwardPorts(forwardID int64) ([]forwardPortRecord, error)
return h.repo.ListForwardPorts(forwardID) return h.repo.ListForwardPorts(forwardID)
} }
func (h *Handler) isTunnelSelectedTLSProtocol(tunnelID int64) (bool, error) {
protocol, err := h.repo.GetTunnelOutProtocol(tunnelID)
if err != nil {
return false, err
}
return isTLSTunnelProtocol(protocol), nil
}
func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) { func (h *Handler) getNodeRecord(nodeID int64) (*nodeRecord, error) {
n, err := h.repo.GetNodeRecord(nodeID) n, err := h.repo.GetNodeRecord(nodeID)
if err != nil { if err != nil {
@@ -265,23 +272,11 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
} }
serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID) serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
tunnelTLSProtocol, err := h.isTunnelSelectedTLSProtocol(forward.TunnelID)
user, err := h.repo.GetUserByID(forward.UserID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
for _, fp := range ports { for _, fp := range ports {
if limiterID != nil && speed != nil { if limiterID != nil && speed != nil {
if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil { if err := h.ensureLimiterOnNode(fp.NodeID, *limiterID, *speed); err != nil {
@@ -299,17 +294,11 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
} }
} }
if cLimiterName != "" {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
node, err := h.getNodeRecord(fp.NodeID) node, err := h.getNodeRecord(fp.NodeID)
if err != nil { if err != nil {
return nil, err return nil, err
} }
services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, cLimiterName) services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, tunnelTLSProtocol)
_, err = h.sendNodeCommand(node.ID, method, services, true, false) _, err = h.sendNodeCommand(node.ID, method, services, true, false)
if err != nil && allowFallbackAdd && method == "UpdateService" { if err != nil && allowFallbackAdd && method == "UpdateService" {
if isNotFoundError(err) { if isNotFoundError(err) {
@@ -324,7 +313,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
} }
if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) { if err != nil && strings.EqualFold(strings.TrimSpace(method), "UpdateService") && isCannotAssignRequestedAddressError(err) {
var warning string var warning string
warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, cLimiterName) warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, tunnelTLSProtocol)
if err == nil && warning != "" { if err == nil && warning != "" {
warnings = append(warnings, warning) warnings = append(warnings, warning)
} }
@@ -350,7 +339,7 @@ func (h *Handler) syncForwardServicesWithWarnings(forward *forwardRecord, method
return warnings, nil return warnings, nil
} }
func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, cLimiterName string) (string, error) { func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, tunnelTLSProtocol bool) (string, error) {
if h == nil || forward == nil || tunnel == nil || node == nil { if h == nil || forward == nil || tunnel == nil || node == nil {
return "", errors.New("invalid bind fallback context") return "", errors.New("invalid bind fallback context")
} }
@@ -367,7 +356,7 @@ func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunne
} }
time.Sleep(150 * time.Millisecond) time.Sleep(150 * time.Millisecond)
defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, cLimiterName) defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, tunnelTLSProtocol)
if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil { if _, err := h.sendNodeCommand(node.ID, "AddService", defaultServices, true, false); err != nil {
return "", err return "", err
} }
@@ -496,9 +485,6 @@ func (h *Handler) controlForwardServices(forward *forwardRecord, commandType str
nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType) nodeHandled, lastNotFoundErr, err := h.controlForwardServicesOnNode(fp.NodeID, bases, commandType)
if err != nil { if err != nil {
if isNodeOfflineOrTimeoutError(err) {
continue
}
return err return err
} }
@@ -718,7 +704,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
} }
ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID) ipPreference := h.repo.GetTunnelIPPreference(forward.TunnelID)
protocol := strings.ToLower(strings.TrimSpace(tunnel.Protocol))
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows) inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets)) workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2+len(targets))
@@ -733,7 +718,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
targetIP: target.IP, targetIP: target.IP,
targetPort: target.Port, targetPort: target.Port,
description: description, description: description,
protocol: "tcp",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
}, },
@@ -751,7 +735,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(firstNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
"toChainType": 2, "toChainType": 2,
@@ -768,7 +751,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
"toChainType": 3, "toChainType": 3,
@@ -789,7 +771,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(nextNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 2, "fromChainType": 2,
"fromInx": currentNode.Inx, "fromInx": currentNode.Inx,
@@ -807,7 +788,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 2, "fromChainType": 2,
"fromInx": currentNode.Inx, "fromInx": currentNode.Inx,
@@ -827,7 +807,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
targetIP: target.IP, targetIP: target.IP,
targetPort: target.Port, targetPort: target.Port,
description: description, description: description,
protocol: "tcp",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 3, "fromChainType": 3,
}, },
@@ -843,7 +822,6 @@ func (h *Handler) prepareForwardDiagnosis(forward *forwardRecord) (string, []dia
targetIP: target.IP, targetIP: target.IP,
targetPort: target.Port, targetPort: target.Port,
description: description, description: description,
protocol: "tcp",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
}, },
@@ -898,7 +876,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
} }
ipPreference := h.repo.GetTunnelIPPreference(tunnelID) ipPreference := h.repo.GetTunnelIPPreference(tunnelID)
protocol := strings.ToLower(strings.TrimSpace(tunnel.Protocol))
inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows) inNodes, chainHops, outNodes := splitChainNodeGroups(chainRows)
workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2) workItems := make([]diagnosisWorkItem, 0, len(chainRows)*2)
@@ -911,7 +888,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
targetIP: "www.bing.com", targetIP: "www.bing.com",
targetPort: 443, targetPort: 443,
description: description, description: description,
protocol: "tcp",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
}, },
@@ -928,7 +904,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(firstNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
"toChainType": 2, "toChainType": 2,
@@ -945,7 +920,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
"toChainType": 3, "toChainType": 3,
@@ -966,7 +940,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(nextNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 2, "fromChainType": 2,
"fromInx": currentNode.Inx, "fromInx": currentNode.Inx,
@@ -984,7 +957,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
hasChainHop: true, hasChainHop: true,
ipPreference: ipPreference, ipPreference: ipPreference,
description: description, description: description,
protocol: defaultString(strings.ToLower(strings.TrimSpace(outNode.Protocol)), protocol),
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 2, "fromChainType": 2,
"fromInx": currentNode.Inx, "fromInx": currentNode.Inx,
@@ -1003,7 +975,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
targetIP: "www.bing.com", targetIP: "www.bing.com",
targetPort: 443, targetPort: 443,
description: description, description: description,
protocol: "tcp",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 3, "fromChainType": 3,
}, },
@@ -1017,7 +988,6 @@ func (h *Handler) prepareTunnelDiagnosis(tunnelID int64) (string, string, []diag
targetIP: "www.bing.com", targetIP: "www.bing.com",
targetPort: 443, targetPort: 443,
description: description, description: description,
protocol: "tcp",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
"fromChainType": 1, "fromChainType": 1,
}, },
@@ -1137,9 +1107,9 @@ func (h *Handler) executeDiagnosisWorkItem(workItem diagnosisWorkItem, options d
single := make([]map[string]interface{}, 0, 1) single := make([]map[string]interface{}, 0, 1)
nodeCache := map[int64]*nodeRecord{} nodeCache := map[int64]*nodeRecord{}
if workItem.hasChainHop { if workItem.hasChainHop {
h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, workItem.protocol, options) h.appendChainHopDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.toNode, workItem.description, workItem.metadata, workItem.ipPreference, options)
} else { } else {
h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, workItem.protocol, options) h.appendPathDiagnosis(&single, nodeCache, workItem.fromNodeID, workItem.targetIP, workItem.targetPort, workItem.description, workItem.metadata, options)
} }
if len(single) == 0 { if len(single) == 0 {
@@ -1265,14 +1235,14 @@ func (h *Handler) appendFailedDiagnosis(results *[]map[string]interface{}, nodeC
item["nodeName"] = node.Name item["nodeName"] = node.Name
} }
if strings.TrimSpace(message) == "" { if strings.TrimSpace(message) == "" {
message = "连接失败" message = "TCP连接失败"
} }
item["success"] = false item["success"] = false
item["message"] = message item["message"] = message
*results = append(*results, item) *results = append(*results, item)
} }
func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, protocol string, options diagnosisExecOptions) { func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, targetIP string, targetPort int, description string, metadata map[string]interface{}, options diagnosisExecOptions) {
item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata) item := newDiagnosisResultItem(fromNodeID, targetIP, targetPort, description, metadata)
fromNode, err := h.cachedNode(nodeCache, fromNodeID) fromNode, err := h.cachedNode(nodeCache, fromNodeID)
@@ -1289,9 +1259,9 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
pingErr error pingErr error
) )
if fromNode.IsRemote == 1 { if fromNode.IsRemote == 1 {
pingData, pingErr = h.pingViaRemoteNode(fromNode, targetIP, targetPort, protocol, options) pingData, pingErr = h.tcpPingViaRemoteNode(fromNode, targetIP, targetPort, options)
} else { } else {
pingData, pingErr = h.pingViaNode(fromNodeID, targetIP, targetPort, protocol, options) pingData, pingErr = h.tcpPingViaNode(fromNodeID, targetIP, targetPort, options)
} }
if pingErr != nil { if pingErr != nil {
item["success"] = false item["success"] = false
@@ -1308,21 +1278,21 @@ func (h *Handler) appendPathDiagnosis(results *[]map[string]interface{}, nodeCac
message := strings.TrimSpace(asString(pingData["message"])) message := strings.TrimSpace(asString(pingData["message"]))
if success { if success {
if message == "" { if message == "" {
message = "连接成功" message = "TCP连接成功"
} }
} else { } else {
if message == "" { if message == "" {
message = strings.TrimSpace(asString(pingData["errorMessage"])) message = strings.TrimSpace(asString(pingData["errorMessage"]))
} }
if message == "" { if message == "" {
message = "连接失败" message = "TCP连接失败"
} }
} }
item["message"] = message item["message"] = message
*results = append(*results, item) *results = append(*results, item)
} }
func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, protocol string, options diagnosisExecOptions) { func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nodeCache map[int64]*nodeRecord, fromNodeID int64, toNode chainNodeRecord, description string, metadata map[string]interface{}, ipPreference string, options diagnosisExecOptions) {
fromNode, _ := h.cachedNode(nodeCache, fromNodeID) fromNode, _ := h.cachedNode(nodeCache, fromNodeID)
targetNode, err := h.cachedNode(nodeCache, toNode.NodeID) targetNode, err := h.cachedNode(nodeCache, toNode.NodeID)
if err != nil { if err != nil {
@@ -1334,7 +1304,7 @@ func (h *Handler) appendChainHopDiagnosis(results *[]map[string]interface{}, nod
h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error()) h.appendFailedDiagnosis(results, nodeCache, fromNodeID, strings.Trim(strings.TrimSpace(targetNode.ServerIP), "[]"), toNode.Port, description, metadata, err.Error())
return return
} }
h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, protocol, options) h.appendPathDiagnosis(results, nodeCache, fromNodeID, targetIP, targetPort, description, metadata, options)
} }
func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) { func resolveChainProbeTarget(fromNode, targetNode *nodeRecord, preferredPort int, ipPreference string, connectIp string) (string, int, error) {
@@ -1427,81 +1397,13 @@ func (h *Handler) tcpPingViaRemoteNode(node *nodeRecord, ip string, port int, op
fc := client.NewFederationClientWithTimeout(options.commandTimeout) fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{ return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip), IP: strings.TrimSpace(ip),
Port: port, Port: port,
Count: 4, Count: 4,
Timeout: options.pingTimeoutMS, Timeout: options.pingTimeoutMS,
Protocol: "tcp",
}) })
} }
func (h *Handler) udpPingViaRemoteNode(node *nodeRecord, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if node == nil {
return nil, errors.New("节点不存在")
}
remoteURL := strings.TrimSpace(node.RemoteURL)
remoteToken := strings.TrimSpace(node.RemoteToken)
if remoteURL == "" || remoteToken == "" {
return nil, errors.New("远程节点缺少共享配置")
}
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
fc := client.NewFederationClientWithTimeout(options.commandTimeout)
return fc.Diagnose(remoteURL, remoteToken, h.federationLocalDomain(), client.RuntimeDiagnoseRequest{
IP: strings.TrimSpace(ip),
Port: port,
Count: 4,
Timeout: options.pingTimeoutMS,
Protocol: "udp",
})
}
func isUDPBasedProtocol(protocol string) bool {
p := strings.ToLower(strings.TrimSpace(protocol))
return p == "kcp" || p == "udp" || p == "quic"
}
func (h *Handler) pingViaNode(nodeID int64, ip string, port int, protocol string, options diagnosisExecOptions) (map[string]interface{}, error) {
if isUDPBasedProtocol(protocol) {
return h.udpPingViaNode(nodeID, ip, port, options)
}
return h.tcpPingViaNode(nodeID, ip, port, options)
}
func (h *Handler) pingViaRemoteNode(node *nodeRecord, ip string, port int, protocol string, options diagnosisExecOptions) (map[string]interface{}, error) {
if isUDPBasedProtocol(protocol) {
return h.udpPingViaRemoteNode(node, ip, port, options)
}
return h.tcpPingViaRemoteNode(node, ip, port, options)
}
func (h *Handler) udpPingViaNode(nodeID int64, ip string, port int, options diagnosisExecOptions) (map[string]interface{}, error) {
if options.commandTimeout <= 0 {
options.commandTimeout = diagnosisCommandTimeout
}
if options.pingTimeoutMS <= 0 {
options.pingTimeoutMS = int(diagnosisCommandTimeout / time.Millisecond)
}
res, err := h.sendNodeCommandWithTimeout(nodeID, "UdpPing", map[string]interface{}{
"ip": ip,
"port": port,
"count": 4,
"timeout": options.pingTimeoutMS,
}, options.commandTimeout, false, false)
if err != nil {
return nil, err
}
if res.Data == nil {
return nil, errors.New("节点未返回诊断数据")
}
return res.Data, nil
}
func splitRemoteTargets(remoteAddr string) []string { func splitRemoteTargets(remoteAddr string) []string {
parts := strings.Split(remoteAddr, ",") parts := strings.Split(remoteAddr, ",")
out := make([]string, 0, len(parts)) out := make([]string, 0, len(parts))
@@ -1659,7 +1561,7 @@ func compactErrorMessage(msg string) string {
return strings.Join(strings.Fields(strings.ToLower(msg)), "") return strings.Join(strings.Fields(strings.ToLower(msg)), "")
} }
func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} { func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, tunnelTLSProtocol bool) []map[string]interface{} {
protocols := []string{"tcp", "udp"} protocols := []string{"tcp", "udp"}
services := make([]map[string]interface{}, 0, 2) services := make([]map[string]interface{}, 0, 2)
targets := splitRemoteTargets(forward.RemoteAddr) targets := splitRemoteTargets(forward.RemoteAddr)
@@ -1702,13 +1604,10 @@ func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel
}, },
}, },
} }
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
if protocol == "udp" { if protocol == "udp" {
listenerMetadata := map[string]interface{}{ listenerMetadata := map[string]interface{}{"keepAlive": true}
"keepAlive": true, if tunnelTLSProtocol {
"ttl": "30s", listenerMetadata["ttl"] = "10s"
} }
service["listener"].(map[string]interface{})["metadata"] = listenerMetadata service["listener"].(map[string]interface{})["metadata"] = listenerMetadata
} }
@@ -1820,30 +1719,6 @@ func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int)
return nil return nil
} }
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
limitStr := fmt.Sprintf("$ %d", maxConn)
payload := map[string]interface{}{
"name": limiterName,
"limits": []string{limitStr},
}
if _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
if !isAlreadyExistsMessage(err.Error()) {
return fmt.Errorf("连接限制器下发失败: %w", err)
}
updatePayload := map[string]interface{}{
"limiter": limiterName,
"data": payload,
}
if _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
return fmt.Errorf("连接限制器更新失败: %w", updateErr)
}
}
return nil
}
func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) { func buildLimiterAddPayload(limiterID int64, speed int) (string, map[string]interface{}) {
rate := float64(speed) / 8.0 rate := float64(speed) / 8.0
limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate) limitStr := fmt.Sprintf("$ %.1fMB %.1fMB", rate, rate)
@@ -378,7 +378,7 @@ func TestRetryTunnelServiceAddWithCleanupReturnsCleanupError(t *testing.T) {
func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) { func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, "") services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22000, "10.9.8.7", nil, false)
if len(services) != 2 { if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services)) t.Fatalf("expected 2 services, got %d", len(services))
} }
@@ -393,7 +393,7 @@ func TestBuildForwardServiceConfigs_UsesBindIPForListen(t *testing.T) {
func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) { func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"} node := &nodeRecord{TCPListenAddr: "0.0.0.0", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, "") services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 22001, "", nil, false)
if len(services) != 2 { if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services)) t.Fatalf("expected 2 services, got %d", len(services))
} }
@@ -409,7 +409,7 @@ func TestBuildForwardServiceConfigs_DefaultListenAddrWhenBindIPEmpty(t *testing.
func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) { func TestBuildForwardServiceConfigs_BindIPAlreadyContainsPort(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, "") services := buildForwardServiceConfigs("1_2_0", forward, nil, node, 55555, "3.3.3.3:12345", nil, false)
if len(services) != 2 { if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services)) t.Fatalf("expected 2 services, got %d", len(services))
} }
@@ -464,7 +464,7 @@ func TestBuildForwardServiceConfigs_IPv6BindIP(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7} forward := &forwardRecord{RemoteAddr: "1.2.3.4:80", Strategy: "fifo", TunnelID: 7}
node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"} node := &nodeRecord{TCPListenAddr: "[::]", UDPListenAddr: "[::]"}
services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, "") services := buildForwardServiceConfigs("1_2_0", forward, nil, node, tt.port, tt.bindIP, nil, false)
if len(services) != 2 { if len(services) != 2 {
t.Fatalf("expected 2 services, got %d", len(services)) t.Fatalf("expected 2 services, got %d", len(services))
} }
+17 -47
View File
@@ -5,7 +5,6 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/url"
"sort" "sort"
"strconv" "strconv"
"strings" "strings"
@@ -86,11 +85,10 @@ type federationRuntimeReleaseRoleRequest struct {
} }
type federationRuntimeDiagnoseRequest struct { type federationRuntimeDiagnoseRequest struct {
IP string `json:"ip"` IP string `json:"ip"`
Port int `json:"port"` Port int `json:"port"`
Count int `json:"count"` Count int `json:"count"`
Timeout int `json:"timeout"` Timeout int `json:"timeout"`
Protocol string `json:"protocol"`
} }
type federationRuntimeCommandRequest struct { type federationRuntimeCommandRequest struct {
@@ -145,24 +143,17 @@ type remoteUsageNodeItem struct {
func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} { func buildFederationServiceConfig(serviceName, addr, protocol, role, chainName string, targetCount int, interfaceName string) map[string]interface{} {
service := map[string]interface{}{ service := map[string]interface{}{
"name": serviceName, "name": serviceName,
"addr": addr, "addr": addr,
"handler": map[string]interface{}{ "handler": map[string]interface{}{
"type": "relay", "type": "relay",
}, },
"listener": buildTunnelListenerConfig(protocol), "listener": map[string]interface{}{
"type": protocol,
},
} }
if isTCPTunnelProtocol(protocol) { if isTLSTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{ service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{"nodelay": true}
"nodelay": true,
"mux.keepaliveInterval": "15s",
"mux.keepaliveTimeout": "45s",
}
}
if isKCPTunnelProtocol(protocol) {
service["handler"].(map[string]interface{})["metadata"] = map[string]interface{}{
"connectTimeout": "30s",
}
} }
if role == "middle" { if role == "middle" {
service["handler"].(map[string]interface{})["chain"] = chainName service["handler"].(map[string]interface{})["chain"] = chainName
@@ -644,15 +635,6 @@ func (h *Handler) nodeImport(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required")) response.WriteJSON(w, response.ErrDefault("Remote URL and Token are required"))
return return
} }
rURL, err := url.Parse(req.RemoteURL)
if err != nil || (rURL.Scheme != "http" && rURL.Scheme != "https") {
response.WriteJSON(w, response.ErrDefault("Invalid Remote URL format"))
return
}
if err := IsSafeRemoteAddr(rURL.Host); err != nil {
response.WriteJSON(w, response.Err(403, "禁止将远程节点地址设置为内部网络"))
return
}
domainCfg, _ := h.repo.GetConfigByName("panel_domain") domainCfg, _ := h.repo.GetConfigByName("panel_domain")
localDomain := "" localDomain := ""
@@ -1103,23 +1085,16 @@ func (h *Handler) federationRuntimeApplyRole(w http.ResponseWriter, r *http.Requ
connector := map[string]interface{}{ connector := map[string]interface{}{
"type": "relay", "type": "relay",
} }
if isTCPTunnelProtocol(targetProtocol) { if isTLSTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{ connector["metadata"] = map[string]interface{}{"nodelay": true}
"nodelay": true,
"mux.keepaliveInterval": "15s",
"mux.keepaliveTimeout": "45s",
}
}
if isKCPTunnelProtocol(targetProtocol) {
connector["metadata"] = map[string]interface{}{
"connectTimeout": "30s",
}
} }
nodeItems = append(nodeItems, map[string]interface{}{ nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", i+1), "name": fmt.Sprintf("node_%d", i+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)), "addr": processServerAddress(fmt.Sprintf("%s:%d", host, target.Port)),
"connector": connector, "connector": connector,
"dialer": buildTunnelDialerConfig(targetProtocol), "dialer": map[string]interface{}{
"type": targetProtocol,
},
}) })
} }
@@ -1282,12 +1257,7 @@ func (h *Handler) federationRuntimeDiagnose(w http.ResponseWriter, r *http.Reque
commandTimeout = diagnosisCommandTimeout commandTimeout = diagnosisCommandTimeout
} }
commandType := "TcpPing" res, err := h.sendNodeCommandWithTimeout(share.NodeID, "TcpPing", map[string]interface{}{
if isUDPBasedProtocol(req.Protocol) {
commandType = "UdpPing"
}
res, err := h.sendNodeCommandWithTimeout(share.NodeID, commandType, map[string]interface{}{
"ip": req.IP, "ip": req.IP,
"port": req.Port, "port": req.Port,
"count": req.Count, "count": req.Count,
@@ -43,19 +43,16 @@ func (h *Handler) processFlowItem(nodeID int64, item flowItem) {
forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName) forwardID, userID, userTunnelID, ok := parseFlowServiceIDs(serviceName)
if ok { if ok {
if h.forwardExists(forwardID) { inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U)
inFlow, outFlow := h.scaleFlowByTunnel(forwardID, item.D, item.U) _ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow)
_ = h.repo.AddFlow(forwardID, userID, userTunnelID, inFlow, outFlow) if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil {
if quota, quotaErr := h.repo.AddUserQuotaUsage(userID, inFlow+outFlow, time.Now()); quotaErr == nil { h.enforceUserQuotaIfNeeded(userID, quota)
h.enforceUserQuotaIfNeeded(userID, quota)
}
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
} else if nodeID > 0 {
h.sendDeleteOrphanedForwardService(nodeID, serviceName)
} }
h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item) h.processPeerShareFlowFromForward(forwardID, nodeID, serviceName, item)
if userTunnelID > 0 {
h.enforceFlowPolicies(userID, userTunnelID)
}
return return
} }
@@ -556,14 +553,6 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
} }
parts := strings.Split(name, "_") parts := strings.Split(name, "_")
if len(parts) == 2 && parts[0] == "tunnel" {
tunnelID, err := strconv.ParseInt(parts[1], 10, 64)
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
}
continue
}
if len(parts) >= 3 { if len(parts) >= 3 {
forwardID, err := strconv.ParseInt(parts[0], 10, 64) forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime { if err == nil && forwardID > 0 && hasUnboundForwardPeerRuntime {
@@ -577,7 +566,7 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
suffix := parts[len(parts)-1] suffix := parts[len(parts)-1]
switch suffix { switch suffix {
case "tls", "kcp", "wss", "mtls", "mwss", "mtcp": case "tls":
tunnelID, err := strconv.ParseInt(parts[0], 10, 64) tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) { if err != nil || tunnelID <= 0 || h.tunnelExists(tunnelID) {
continue continue
@@ -585,10 +574,6 @@ func (h *Handler) cleanOrphanedServices(nodeID int64, services []namedConfigItem
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true) _, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
case "tcp": case "tcp":
if len(parts) < 4 { if len(parts) < 4 {
tunnelID, err := strconv.ParseInt(parts[0], 10, 64)
if err == nil && tunnelID > 0 && !h.tunnelExists(tunnelID) {
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{name}}, false, true)
}
continue continue
} }
forwardID, err := strconv.ParseInt(parts[0], 10, 64) forwardID, err := strconv.ParseInt(parts[0], 10, 64)
@@ -643,21 +628,6 @@ func (h *Handler) forwardExists(forwardID int64) bool {
return ok return ok
} }
func (h *Handler) sendDeleteOrphanedForwardService(nodeID int64, serviceName string) {
parts := strings.Split(serviceName, "_")
if len(parts) < 3 {
return
}
forwardID, err := strconv.ParseInt(parts[0], 10, 64)
if err != nil || forwardID <= 0 {
return
}
base := parts[0] + "_" + parts[1] + "_" + parts[2]
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{
"services": []string{base + "_tcp", base + "_udp"},
}, false, true)
}
func (h *Handler) speedLimiterExists(name string) bool { func (h *Handler) speedLimiterExists(name string) bool {
if name == "" { if name == "" {
return false return false
+2 -15
View File
@@ -27,7 +27,6 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
) )
type Handler struct { type Handler struct {
repo *repo.Repository repo *repo.Repository
jwtSecret string jwtSecret string
@@ -357,12 +356,6 @@ func (h *Handler) getConfigByName(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("配置名称不能为空")) response.WriteJSON(w, response.ErrDefault("配置名称不能为空"))
return return
} }
configName := strings.ToLower(strings.TrimSpace(req.Name))
switch configName {
case "license_key", "cloudflare_secret_key", "jwt_secret":
response.WriteJSON(w, response.Err(403, "禁止访问敏感配置"))
return
}
cfg, err := h.repo.GetConfigByName(req.Name) cfg, err := h.repo.GetConfigByName(req.Name)
if err != nil { if err != nil {
@@ -388,12 +381,6 @@ func (h *Handler) getConfigs(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
ctxClaims := r.Context().Value(middleware.ClaimsContextKey)
if claims, ok := ctxClaims.(auth.Claims); !ok || claims.RoleID != 0 {
delete(cfgMap, "license_key")
delete(cfgMap, "cloudflare_secret_key")
delete(cfgMap, "jwt_secret")
}
response.WriteJSON(w, response.OK(cfgMap)) response.WriteJSON(w, response.OK(cfgMap))
} }
@@ -865,7 +852,7 @@ func (h *Handler) licenseActivate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error())) response.WriteJSON(w, response.ErrDefault("设备绑定失败: "+err.Error()))
return return
} }
// Validation might still fail with scope if we don't query via machine id, but since activate machine succeeded // 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 // we can consider the license valid for our simple usecase
} else { } else {
@@ -976,7 +963,7 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
return return
} }
if value == "" && name != "app_logo" && name != "app_favicon" { if value == "" && name != "app_logo" && name != "app_favicon" && name != "app_bg_image" {
response.WriteJSON(w, response.ErrDefault("配置值不能为空")) response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
return return
} }
+1 -1
View File
@@ -121,7 +121,7 @@ func (h *Handler) runHealthChecks(ctx context.Context) {
func (h *Handler) runTunnelQualityProber(ctx context.Context) { func (h *Handler) runTunnelQualityProber(ctx context.Context) {
defer h.jobsWG.Done() defer h.jobsWG.Done()
if h == nil || h.qualityProber == nil { if h == nil || h.qualityProber == nil || !h.isTunnelQualityMonitoringEnabled() {
return return
} }
+29 -183
View File
@@ -68,9 +68,8 @@ func (h *Handler) userCreate(w http.ResponseWriter, r *http.Request) {
} }
roleID := 1 roleID := 1
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
maxConn := asInt(req["maxConn"], 0)
userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, maxConn, now) userID, err := h.repo.CreateUser(username, security.MD5(pwd), roleID, expTime, flow, flowResetTime, num, status, now)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
@@ -157,16 +156,15 @@ func (h *Handler) userUpdate(w http.ResponseWriter, r *http.Request) {
_, hasDailyQuota := req["dailyQuotaGB"] _, hasDailyQuota := req["dailyQuotaGB"]
_, hasMonthlyQuota := req["monthlyQuotaGB"] _, hasMonthlyQuota := req["monthlyQuotaGB"]
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
maxConn := asInt(req["maxConn"], 0)
pwd := asString(req["pwd"]) pwd := asString(req["pwd"])
if strings.TrimSpace(pwd) == "" { if strings.TrimSpace(pwd) == "" {
if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, maxConn, now); err != nil { if err := h.repo.UpdateUserWithoutPassword(id, username, flow, num, expTime, flowResetTime, status, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
} else { } else {
if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, maxConn, now); err != nil { if err := h.repo.UpdateUserWithPassword(id, username, security.MD5(pwd), flow, num, expTime, flowResetTime, status, now); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
@@ -303,10 +301,6 @@ func (h *Handler) nodeCreate(w http.ResponseWriter, r *http.Request) {
response.WriteJSON(w, response.ErrDefault("节点名称和地址不能为空")) response.WriteJSON(w, response.ErrDefault("节点名称和地址不能为空"))
return return
} }
if err := IsValidNodeAddress(serverIP); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
inx := h.repo.NextIndex("node") inx := h.repo.NextIndex("node")
@@ -371,13 +365,6 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
newHTTP := asInt(req["http"], currentHTTP) newHTTP := asInt(req["http"], currentHTTP)
newTLS := asInt(req["tls"], currentTLS) newTLS := asInt(req["tls"], currentTLS)
newSocks := asInt(req["socks"], currentSocks) newSocks := asInt(req["socks"], currentSocks)
serverIP := asString(req["serverIp"])
if serverIP != "" {
if err := IsValidNodeAddress(serverIP); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error()))
return
}
}
if currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) { if currentStatus == 1 && (newHTTP != currentHTTP || newTLS != currentTLS || newSocks != currentSocks) {
if err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil { if err := h.applyNodeProtocolChange(id, newHTTP, newTLS, newSocks); err != nil {
response.WriteJSON(w, response.ErrDefault(err.Error())) response.WriteJSON(w, response.ErrDefault(err.Error()))
@@ -388,7 +375,7 @@ func (h *Handler) nodeUpdate(w http.ResponseWriter, r *http.Request) {
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
if err := h.repo.UpdateNode(id, if err := h.repo.UpdateNode(id,
asString(req["name"]), asString(req["name"]),
serverIP, asString(req["serverIp"]),
nullableText(asString(req["serverIpV4"])), nullableText(asString(req["serverIpV4"])),
nullableText(asString(req["serverIpV6"])), nullableText(asString(req["serverIpV6"])),
defaultString(asString(req["port"]), "1000-65535"), defaultString(asString(req["port"]), "1000-65535"),
@@ -659,15 +646,11 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
if trimmed := strings.TrimSpace(inIP); trimmed != "" { if trimmed := strings.TrimSpace(inIP); trimmed != "" {
tunnelInIP = sql.NullString{String: trimmed, Valid: true} tunnelInIP = sql.NullString{String: trimmed, Valid: true}
} }
tunnelProtocol := "tls"
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
tunnelProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
tunnel := model.Tunnel{ tunnel := model.Tunnel{
Name: name, Name: name,
TrafficRatio: trafficRatio, TrafficRatio: trafficRatio,
Type: typeVal, Type: typeVal,
Protocol: tunnelProtocol, Protocol: "tls",
Flow: flow, Flow: flow,
CreatedTime: now, CreatedTime: now,
UpdatedTime: now, UpdatedTime: now,
@@ -708,7 +691,7 @@ func (h *Handler) tunnelCreate(w http.ResponseWriter, r *http.Request) {
if typeVal == 2 { if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState) createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil { if applyErr != nil {
h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID, tunnelProtocol) h.rollbackTunnelRuntime(createdChains, createdServices, tunnelID)
h.releaseFederationRuntimeRefs(federationReleaseRefs) h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.deleteTunnelByID(tunnelID) _ = h.deleteTunnelByID(tunnelID)
response.WriteJSON(w, response.ErrDefault(applyErr.Error())) response.WriteJSON(w, response.ErrDefault(applyErr.Error()))
@@ -728,30 +711,17 @@ func (h *Handler) cleanupTunnelRuntime(tunnelID int64) {
return return
} }
protocol := strings.TrimSpace(tunnel.Protocol) serviceName := fmt.Sprintf("%d_tls", tunnelID)
if protocol == "" {
protocol = "tls"
}
chainName := fmt.Sprintf("chains_%d", tunnelID) chainName := fmt.Sprintf("chains_%d", tunnelID)
serviceNames := []string{
fmt.Sprintf("tunnel_%d", tunnelID),
fmt.Sprintf("%d_tls", tunnelID),
fmt.Sprintf("%d_kcp", tunnelID),
fmt.Sprintf("%d_wss", tunnelID),
fmt.Sprintf("%d_mtls", tunnelID),
fmt.Sprintf("%d_mwss", tunnelID),
fmt.Sprintf("%d_tcp", tunnelID),
fmt.Sprintf("%d_mtcp", tunnelID),
}
for _, row := range chainRows { for _, row := range chainRows {
if row.ChainType == 1 { if row.ChainType == 1 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true) _, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
} else if row.ChainType == 2 { } else if row.ChainType == 2 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true) _, _ = h.sendNodeCommand(row.NodeID, "DeleteChains", map[string]interface{}{"chain": chainName}, false, true)
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true) _, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
} else if row.ChainType == 3 { } else if row.ChainType == 3 {
_, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true) _, _ = h.sendNodeCommand(row.NodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
} }
} }
} }
@@ -835,12 +805,6 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
} }
defer func() { tx.Rollback() }() defer func() { tx.Rollback() }()
updateProtocol := "tls"
if len(runtimeState.OutNodes) > 0 && strings.TrimSpace(runtimeState.OutNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.OutNodes[0].Protocol)
} else if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
if err := h.repo.UpdateTunnelTx( if err := h.repo.UpdateTunnelTx(
tx, tx,
id, id,
@@ -851,7 +815,6 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
asInt(req["status"], 1), asInt(req["status"], 1),
inIp, inIp,
ipPreference, ipPreference,
updateProtocol,
now, now,
); err != nil { ); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
@@ -899,11 +862,7 @@ func (h *Handler) tunnelUpdate(w http.ResponseWriter, r *http.Request) {
if typeVal == 2 { if typeVal == 2 {
createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState) createdChains, createdServices, applyErr := h.applyTunnelRuntime(runtimeState)
if applyErr != nil { if applyErr != nil {
updateProtocol := "tls" h.rollbackTunnelRuntime(createdChains, createdServices, id)
if len(runtimeState.InNodes) > 0 && strings.TrimSpace(runtimeState.InNodes[0].Protocol) != "" {
updateProtocol = strings.TrimSpace(runtimeState.InNodes[0].Protocol)
}
h.rollbackTunnelRuntime(createdChains, createdServices, id, updateProtocol)
h.releaseFederationRuntimeRefs(federationReleaseRefs) h.releaseFederationRuntimeRefs(federationReleaseRefs)
_ = h.repo.DeleteFederationTunnelBindingsByTunnel(id) _ = h.repo.DeleteFederationTunnelBindingsByTunnel(id)
if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) { if len(federationReleaseRefs) == 0 && shouldDeferTunnelRuntimeApplyError(applyErr) {
@@ -1727,10 +1686,6 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
return return
} }
if roleID != 0 { if roleID != 0 {
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络"))
return
}
if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil { if speedIDVal, ok := req["speedId"]; ok && speedIDVal != nil {
response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则")) response.WriteJSON(w, response.Err(-1, "普通用户无法设置限速规则"))
return return
@@ -1779,8 +1734,7 @@ func (h *Handler) forwardCreate(w http.ResponseWriter, r *http.Request) {
if userName == "" { if userName == "" {
userName = "user" userName = "user"
} }
maxConn := asInt(req["maxConn"], 0) forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID))
forwardID, err := h.repo.CreateForwardTx(userID, userName, name, tunnelID, remoteAddr, defaultString(asString(req["strategy"]), "fifo"), now, inx, entryNodes, port, inIp, nullableInt(speedID), maxConn)
if err != nil { if err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
@@ -1851,12 +1805,6 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
if remoteAddr == "" { if remoteAddr == "" {
remoteAddr = forward.RemoteAddr remoteAddr = forward.RemoteAddr
} }
if actorRole != 0 {
if err := IsSafeRemoteAddr(remoteAddr); err != nil {
response.WriteJSON(w, response.Err(403, "禁止将目标地址设置为内部网络"))
return
}
}
strategy := strings.TrimSpace(asString(req["strategy"])) strategy := strings.TrimSpace(asString(req["strategy"]))
if strategy == "" { if strategy == "" {
strategy = forward.Strategy strategy = forward.Strategy
@@ -1931,9 +1879,7 @@ func (h *Handler) forwardUpdate(w http.ResponseWriter, r *http.Request) {
} }
} }
now := time.Now().UnixMilli() now := time.Now().UnixMilli()
maxConn := asInt(req["maxConn"], forward.MaxConn) if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID); err != nil {
if err := h.repo.UpdateForward(id, name, tunnelID, remoteAddr, strategy, now, newSpeedID, maxConn); err != nil {
response.WriteJSON(w, response.Err(-2, err.Error())) response.WriteJSON(w, response.Err(-2, err.Error()))
return return
} }
@@ -3395,11 +3341,6 @@ func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []m
return errors.New("invalid tunnel service context") return errors.New("invalid tunnel service context")
} }
serviceName := fmt.Sprintf("%d_tls", tunnelID) serviceName := fmt.Sprintf("%d_tls", tunnelID)
if len(serviceData) > 0 {
if name, ok := serviceData[0]["name"].(string); ok && strings.TrimSpace(name) != "" {
serviceName = strings.TrimSpace(name)
}
}
return retryTunnelServiceAddWithCleanup( return retryTunnelServiceAddWithCleanup(
func() error { func() error {
_, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false) _, err := h.sendNodeCommand(nodeID, "AddService", serviceData, true, false)
@@ -3413,31 +3354,19 @@ func (h *Handler) addTunnelServiceOnNode(nodeID, tunnelID int64, serviceData []m
) )
} }
func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64, protocol string) { func (h *Handler) rollbackTunnelRuntime(chainNodeIDs, serviceNodeIDs []int64, tunnelID int64) {
if h == nil || tunnelID <= 0 { if h == nil || tunnelID <= 0 {
return return
} }
if protocol == "" {
protocol = "tls"
}
seenServices := make(map[int64]struct{}) seenServices := make(map[int64]struct{})
serviceNames := []string{ serviceName := fmt.Sprintf("%d_tls", tunnelID)
fmt.Sprintf("tunnel_%d", tunnelID),
fmt.Sprintf("%d_tls", tunnelID),
fmt.Sprintf("%d_kcp", tunnelID),
fmt.Sprintf("%d_wss", tunnelID),
fmt.Sprintf("%d_mtls", tunnelID),
fmt.Sprintf("%d_mwss", tunnelID),
fmt.Sprintf("%d_tcp", tunnelID),
fmt.Sprintf("%d_mtcp", tunnelID),
}
for i := len(serviceNodeIDs) - 1; i >= 0; i-- { for i := len(serviceNodeIDs) - 1; i >= 0; i-- {
nodeID := serviceNodeIDs[i] nodeID := serviceNodeIDs[i]
if _, ok := seenServices[nodeID]; ok { if _, ok := seenServices[nodeID]; ok {
continue continue
} }
seenServices[nodeID] = struct{}{} seenServices[nodeID] = struct{}{}
_, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": serviceNames}, false, true) _, _ = h.sendNodeCommand(nodeID, "DeleteService", map[string]interface{}{"services": []string{serviceName}}, false, true)
} }
seenChains := make(map[int64]struct{}) seenChains := make(map[int64]struct{})
@@ -3502,29 +3431,16 @@ func buildTunnelChainConfig(tunnelID int64, fromNodeID int64, targets []tunnelRu
connector := map[string]interface{}{ connector := map[string]interface{}{
"type": "relay", "type": "relay",
} }
connectorMetadata := map[string]interface{}{} if isTLSTunnelProtocol(protocol) {
if isTCPTunnelProtocol(protocol) { connector["metadata"] = map[string]interface{}{"nodelay": true}
connectorMetadata["nodelay"] = true
connectorMetadata["mux.keepaliveInterval"] = "15s"
connectorMetadata["mux.keepaliveTimeout"] = "45s"
connectorMetadata["mux.maxFrameSize"] = 32768
connectorMetadata["mux.maxStreamBuffer"] = 2097152
}
if isKCPTunnelProtocol(protocol) {
connectorMetadata["connectTimeout"] = "30s"
connectorMetadata["mux.keepaliveInterval"] = "15s"
connectorMetadata["mux.keepaliveTimeout"] = "45s"
connectorMetadata["mux.maxFrameSize"] = 32768
connectorMetadata["mux.maxStreamBuffer"] = 2097152
}
if len(connectorMetadata) > 0 {
connector["metadata"] = connectorMetadata
} }
nodeItems = append(nodeItems, map[string]interface{}{ nodeItems = append(nodeItems, map[string]interface{}{
"name": fmt.Sprintf("node_%d", idx+1), "name": fmt.Sprintf("node_%d", idx+1),
"addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)), "addr": processServerAddress(fmt.Sprintf("%s:%d", host, port)),
"connector": connector, "connector": connector,
"dialer": buildTunnelDialerConfig(protocol), "dialer": map[string]interface{}{
"type": protocol,
},
}) })
} }
@@ -3556,32 +3472,19 @@ func buildTunnelChainServiceConfig(tunnelID int64, chainNode tunnelRuntimeNode,
handlerCfg := map[string]interface{}{ handlerCfg := map[string]interface{}{
"type": "relay", "type": "relay",
} }
handlerMetadata := map[string]interface{}{} if isTLSTunnelProtocol(protocol) {
if isTCPTunnelProtocol(protocol) { handlerCfg["metadata"] = map[string]interface{}{"nodelay": true}
handlerMetadata["nodelay"] = true
handlerMetadata["mux.keepaliveInterval"] = "15s"
handlerMetadata["mux.keepaliveTimeout"] = "45s"
handlerMetadata["mux.maxFrameSize"] = 32768
handlerMetadata["mux.maxStreamBuffer"] = 2097152
}
if isKCPTunnelProtocol(protocol) {
handlerMetadata["connectTimeout"] = "30s"
handlerMetadata["mux.keepaliveInterval"] = "15s"
handlerMetadata["mux.keepaliveTimeout"] = "45s"
handlerMetadata["mux.maxFrameSize"] = 32768
handlerMetadata["mux.maxStreamBuffer"] = 2097152
}
if len(handlerMetadata) > 0 {
handlerCfg["metadata"] = handlerMetadata
} }
if nextHopCandidateCount > 1 { if nextHopCandidateCount > 1 {
handlerCfg["retries"] = nextHopCandidateCount - 1 handlerCfg["retries"] = nextHopCandidateCount - 1
} }
service := map[string]interface{}{ service := map[string]interface{}{
"name": fmt.Sprintf("tunnel_%d", tunnelID), "name": fmt.Sprintf("%d_tls", tunnelID),
"addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)), "addr": processServerAddress(fmt.Sprintf("%s:%d", defaultString(strings.TrimSpace(chainNode.ConnectIP), node.TCPListenAddr), chainNode.Port)),
"handler": handlerCfg, "handler": handlerCfg,
"listener": buildTunnelListenerConfig(protocol), "listener": map[string]interface{}{
"type": protocol,
},
} }
if chainNode.ChainType == 2 { if chainNode.ChainType == 2 {
service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID) service["handler"].(map[string]interface{})["chain"] = fmt.Sprintf("chains_%d", tunnelID)
@@ -3664,67 +3567,10 @@ func nodeDisplayName(node *nodeRecord) string {
return fmt.Sprintf("node_%d", node.ID) return fmt.Sprintf("node_%d", node.ID)
} }
func isTCPTunnelProtocol(protocol string) bool {
p := strings.ToLower(strings.TrimSpace(defaultString(protocol, "tls")))
return p == "tls" || p == "mtls" || p == "mtcp"
}
func isKCPTunnelProtocol(protocol string) bool {
return strings.EqualFold(strings.TrimSpace(protocol), "kcp")
}
func isTLSTunnelProtocol(protocol string) bool { func isTLSTunnelProtocol(protocol string) bool {
return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls") return strings.EqualFold(strings.TrimSpace(defaultString(protocol, "tls")), "tls")
} }
func buildTunnelDialerConfig(protocol string) map[string]interface{} {
dialer := map[string]interface{}{
"type": protocol,
}
if isKCPTunnelProtocol(protocol) {
dialer["metadata"] = map[string]interface{}{
"kcp.keepalive": 10,
"kcp.tcp": false,
"kcp.mode": "fast3",
"kcp.sndwnd": 4096,
"kcp.rcvwnd": 4096,
"kcp.mtu": 1350,
"kcp.sockbuf": 4194304,
"kcp.smuxbuf": 4194304,
"kcp.streambuf": 2097152,
"kcp.datashard": 10,
"kcp.parityshard": 3,
"kcp.nocomp": true,
"kcp.nc": 1,
}
}
return dialer
}
func buildTunnelListenerConfig(protocol string) map[string]interface{} {
listener := map[string]interface{}{
"type": protocol,
}
if isKCPTunnelProtocol(protocol) {
listener["metadata"] = map[string]interface{}{
"kcp.keepalive": 10,
"kcp.tcp": false,
"kcp.mode": "fast3",
"kcp.sndwnd": 4096,
"kcp.rcvwnd": 4096,
"kcp.mtu": 1350,
"kcp.sockbuf": 4194304,
"kcp.smuxbuf": 4194304,
"kcp.streambuf": 2097152,
"kcp.datashard": 10,
"kcp.parityshard": 3,
"kcp.nocomp": true,
"kcp.nc": 1,
}
}
return listener
}
func nodeSupportsV4(node *nodeRecord) bool { func nodeSupportsV4(node *nodeRecord) bool {
if node == nil { if node == nil {
return false return false
@@ -4085,7 +3931,7 @@ func (h *Handler) rollbackForwardMutation(oldForward *forwardRecord, oldPorts []
h.repo.RollbackForwardFields( h.repo.RollbackForwardFields(
oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name, oldForward.ID, oldForward.UserID, oldForward.UserName, oldForward.Name,
oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status, oldForward.TunnelID, oldForward.RemoteAddr, oldForward.Strategy, oldForward.Status,
oldForward.SpeedID, oldForward.MaxConn, oldForward.SpeedID,
time.Now().UnixMilli(), time.Now().UnixMilli(),
) )
@@ -1,60 +0,0 @@
package handler
import (
"fmt"
"net"
"strings"
)
// DisableSafeRemoteAddrCheckForTesting allows bypassing the safety check during integration tests.
var DisableSafeRemoteAddrCheckForTesting = false
// IsSafeRemoteAddr checks if a given address is safe to connect to (prevents SSRF/Open Proxy).
// It resolves domains to IPs to prevent DNS rebinding attacks pointing to internal networks.
func IsSafeRemoteAddr(addr string) error {
if DisableSafeRemoteAddrCheckForTesting {
return nil
}
host, _, err := net.SplitHostPort(addr)
if err != nil {
if strings.Contains(err.Error(), "missing port in address") {
host = addr
} else {
return fmt.Errorf("invalid address format: %v", err)
}
}
ips, err := net.LookupIP(host)
if err != nil {
return fmt.Errorf("could not resolve address: %v", err)
}
for _, ip := range ips {
if ip.IsLoopback() || ip.IsPrivate() {
return fmt.Errorf("address resolves to internal IP: %s", ip.String())
}
}
return nil
}
// IsValidNodeAddress ensures the address is strictly a host or host:port.
// It explicitly denies schemes (http://, https://), paths (/...), and query params (?).
func IsValidNodeAddress(addr string) error {
addr = strings.TrimSpace(addr)
if strings.Contains(addr, "://") {
return fmt.Errorf("address must not contain scheme (e.g. http://)")
}
if strings.ContainsAny(addr, "/?") {
return fmt.Errorf("address must not contain path or query parameters")
}
_, _, err := net.SplitHostPort(addr)
if err != nil {
if !strings.Contains(err.Error(), "missing port in address") {
return fmt.Errorf("invalid address format")
}
}
return nil
}
+4 -2
View File
@@ -389,7 +389,9 @@ func (h *Handler) consumeNodePendingUpgradeRedeploy(nodeID int64) bool {
} }
func (h *Handler) onNodeOnline(nodeID int64) { func (h *Handler) onNodeOnline(nodeID int64) {
h.consumeNodePendingUpgradeRedeploy(nodeID) if !h.consumeNodePendingUpgradeRedeploy(nodeID) {
return
}
h.redeployNodeRuntimeAfterUpgrade(nodeID) h.redeployNodeRuntimeAfterUpgrade(nodeID)
} }
@@ -399,7 +401,7 @@ func (h *Handler) redeployNodeRuntimeAfterUpgrade(nodeID int64) {
fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err) fmt.Printf("post-upgrade redeploy: list tunnels for node %d failed: %v\n", nodeID, err)
return return
} }
forwardIDs, err := h.repo.ListForwardIDsByNode(nodeID) forwardIDs, err := h.repo.ListActiveForwardIDsByNode(nodeID)
if err != nil { if err != nil {
fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err) fmt.Printf("post-upgrade redeploy: list forwards for node %d failed: %v\n", nodeID, err)
return return
+1 -1
View File
@@ -109,7 +109,7 @@ func requiresAdmin(path string) bool {
return true return true
} }
if strings.HasPrefix(path, "/api/v1/federation/share/") || strings.HasPrefix(path, "/api/v1/federation/node/") { if strings.HasPrefix(path, "/api/v1/federation/share/") {
return true return true
} }
-4
View File
@@ -23,7 +23,6 @@ type User struct {
CreatedTime int64 `gorm:"column:created_time;not null"` CreatedTime int64 `gorm:"column:created_time;not null"`
UpdatedTime sql.NullInt64 `gorm:"column:updated_time"` UpdatedTime sql.NullInt64 `gorm:"column:updated_time"`
Status int `gorm:"not null"` Status int `gorm:"not null"`
MaxConn int `gorm:"column:max_conn;not null;default:0"`
} }
func (User) TableName() string { return "user" } func (User) TableName() string { return "user" }
@@ -44,7 +43,6 @@ type Forward struct {
Status int `gorm:"not null"` Status int `gorm:"not null"`
Inx int `gorm:"not null;default:0"` Inx int `gorm:"not null;default:0"`
SpeedID sql.NullInt64 `gorm:"column:speed_id"` SpeedID sql.NullInt64 `gorm:"column:speed_id"`
MaxConn int `gorm:"column:max_conn;not null;default:0"`
} }
func (Forward) TableName() string { return "forward" } func (Forward) TableName() string { return "forward" }
@@ -539,7 +537,6 @@ type ForwardRecord struct {
Strategy string Strategy string
Status int Status int
SpeedID sql.NullInt64 SpeedID sql.NullInt64
MaxConn int
} }
// TunnelRecord is a minimal tunnel view used by control plane. // TunnelRecord is a minimal tunnel view used by control plane.
@@ -549,7 +546,6 @@ type TunnelRecord struct {
Status int Status int
Flow int64 Flow int64
TrafficRatio float64 TrafficRatio float64
Protocol string
} }
type UserQuotaView struct { type UserQuotaView struct {
@@ -99,22 +99,6 @@ func (r *Repository) ListActiveForwardIDsByNode(nodeID int64) ([]int64, error) {
return ids, nil return ids, nil
} }
func (r *Repository) ListForwardIDsByNode(nodeID int64) ([]int64, error) {
if r == nil || r.db == nil {
return nil, errors.New("repository not initialized")
}
var ids []int64
err := r.db.Model(&model.ForwardPort{}).
Where("forward_port.node_id = ?", nodeID).
Select("DISTINCT forward_port.forward_id").
Order("forward_port.forward_id ASC").
Pluck("forward_port.forward_id", &ids).Error
if err != nil {
return nil, err
}
return ids, nil
}
func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) { func (r *Repository) ListForwardPorts(forwardID int64) ([]model.ForwardPortRecord, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return nil, errors.New("repository not initialized") return nil, errors.New("repository not initialized")
@@ -124,16 +124,15 @@ func (r *Repository) GetForwardRecord(forwardID int64) (*model.ForwardRecord, er
return nil, err return nil, err
} }
fr := model.ForwardRecord{ fr := model.ForwardRecord{
ID: f.ID, ID: f.ID,
UserID: f.UserID, UserID: f.UserID,
UserName: f.UserName, UserName: f.UserName,
Name: f.Name, Name: f.Name,
TunnelID: f.TunnelID, TunnelID: f.TunnelID,
RemoteAddr: f.RemoteAddr, RemoteAddr: f.RemoteAddr,
Strategy: f.Strategy, Strategy: f.Strategy,
Status: f.Status, Status: f.Status,
SpeedID: f.SpeedID, SpeedID: f.SpeedID,
MaxConn: f.MaxConn,
} }
if strings.TrimSpace(fr.Strategy) == "" { if strings.TrimSpace(fr.Strategy) == "" {
fr.Strategy = "fifo" fr.Strategy = "fifo"
@@ -159,7 +158,6 @@ func (r *Repository) GetTunnelRecord(tunnelID int64) (*model.TunnelRecord, error
Status: t.Status, Status: t.Status,
Flow: t.Flow, Flow: t.Flow,
TrafficRatio: t.TrafficRatio, TrafficRatio: t.TrafficRatio,
Protocol: t.Protocol,
} }
if tr.Flow <= 0 { if tr.Flow <= 0 {
tr.Flow = 1 tr.Flow = 1
@@ -37,7 +37,7 @@ func (r *Repository) UserExistsExcluding(username string, excludeID int64) (bool
return cnt > 0, err return cnt > 0, err
} }
func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status, maxConn int, now int64) (int64, error) { func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, flow, flowResetTime int64, num, status int, now int64) (int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return 0, errors.New("repository not initialized") return 0, errors.New("repository not initialized")
} }
@@ -51,7 +51,6 @@ func (r *Repository) CreateUser(username, pwdHash string, roleID int, expTime, f
OutFlow: 0, OutFlow: 0,
FlowResetTime: flowResetTime, FlowResetTime: flowResetTime,
Num: num, Num: num,
MaxConn: maxConn,
CreatedTime: now, CreatedTime: now,
UpdatedTime: sql.NullInt64{Int64: now, Valid: true}, UpdatedTime: sql.NullInt64{Int64: now, Valid: true},
Status: status, Status: status,
@@ -74,7 +73,7 @@ func (r *Repository) GetUserRoleID(userID int64) (int, error) {
return user.RoleID, nil return user.RoleID, nil
} }
func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error { func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string, flow int64, num int, expTime, flowResetTime int64, status int, now int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -88,12 +87,11 @@ func (r *Repository) UpdateUserWithPassword(id int64, username, pwdHash string,
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
"status": status, "status": status,
"max_conn": maxConn,
"updated_time": sql.NullInt64{Int64: now, Valid: true}, "updated_time": sql.NullInt64{Int64: now, Valid: true},
}).Error }).Error
} }
func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status, maxConn int, now int64) error { func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow int64, num int, expTime, flowResetTime int64, status int, now int64) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -106,7 +104,6 @@ func (r *Repository) UpdateUserWithoutPassword(id int64, username string, flow i
"exp_time": expTime, "exp_time": expTime,
"flow_reset_time": flowResetTime, "flow_reset_time": flowResetTime,
"status": status, "status": status,
"max_conn": maxConn,
"updated_time": sql.NullInt64{Int64: now, Valid: true}, "updated_time": sql.NullInt64{Int64: now, Valid: true},
}).Error }).Error
} }
@@ -397,7 +394,7 @@ func (r *Repository) UpdateTunnelOrder(tunnelID int64, inx int, now int64) {
Updates(map[string]interface{}{"inx": inx, "updated_time": now}).Error Updates(map[string]interface{}{"inx": inx, "updated_time": now}).Error
} }
func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, protocol string, now int64) error { func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, typeVal int, flow int64, trafficRatio float64, status int, inIP, ipPreference string, now int64) error {
if tx == nil { if tx == nil {
return errors.New("database unavailable") return errors.New("database unavailable")
} }
@@ -411,7 +408,6 @@ func (r *Repository) UpdateTunnelTx(tx *gorm.DB, tunnelID int64, name string, ty
"status": status, "status": status,
"in_ip": nullStringFromInterface(inIP), "in_ip": nullStringFromInterface(inIP),
"ip_preference": ipPreference, "ip_preference": ipPreference,
"protocol": protocol,
"updated_time": now, "updated_time": now,
}).Error }).Error
} }
@@ -695,7 +691,7 @@ func (r *Repository) GetMinForwardPort(forwardID int64) sql.NullInt64 {
return p return p
} }
func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}, maxConn int) error { func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remoteAddr, strategy string, now int64, speedID interface{}) error {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return errors.New("repository not initialized") return errors.New("repository not initialized")
} }
@@ -707,7 +703,6 @@ func (r *Repository) UpdateForward(id int64, name string, tunnelID int64, remote
"remote_addr": remoteAddr, "remote_addr": remoteAddr,
"strategy": strategy, "strategy": strategy,
"speed_id": nullInt64FromInterface(speedID), "speed_id": nullInt64FromInterface(speedID),
"max_conn": maxConn,
"updated_time": now, "updated_time": now,
}).Error }).Error
} }
@@ -782,7 +777,7 @@ func (r *Repository) UpdateForwardPortBindIP(forwardID, nodeID int64, port int,
Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error Update("in_ip", sql.NullString{String: inIP, Valid: strings.TrimSpace(inIP) != ""}).Error
} }
func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, maxConn int, now int64) { func (r *Repository) RollbackForwardFields(id, userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, status int, speedID interface{}, now int64) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return return
} }
@@ -797,7 +792,6 @@ func (r *Repository) RollbackForwardFields(id, userID int64, userName, name stri
"strategy": strategy, "strategy": strategy,
"status": status, "status": status,
"speed_id": nullInt64FromInterface(speedID), "speed_id": nullInt64FromInterface(speedID),
"max_conn": maxConn,
"updated_time": now, "updated_time": now,
}).Error }).Error
} }
@@ -1258,7 +1252,7 @@ func (r *Repository) EnsureUserTunnelGrant(userID, tunnelID int64) (int64, bool,
return ut.ID, true, nil return ut.ID, true, nil
} }
func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}, maxConn int) (int64, error) { func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnelID int64, remoteAddr, strategy string, now int64, inx int, entryNodeIDs []int64, port int, inIp string, speedID interface{}) (int64, error) {
if r == nil || r.db == nil { if r == nil || r.db == nil {
return 0, errors.New("repository not initialized") return 0, errors.New("repository not initialized")
} }
@@ -1277,7 +1271,6 @@ func (r *Repository) CreateForwardTx(userID int64, userName, name string, tunnel
UpdatedTime: now, UpdatedTime: now,
Status: 1, Status: 1,
Inx: inx, Inx: inx,
MaxConn: maxConn,
SpeedID: nullInt64FromInterface(speedID), SpeedID: nullInt64FromInterface(speedID),
} }
if err := tx.Create(&fwd).Error; err != nil { if err := tx.Create(&fwd).Error; err != nil {
-163
View File
@@ -1,163 +0,0 @@
import re
with open('internal/http/handler/control_plane.go', 'r') as f:
content = f.read()
# 1. Update ensureLimiterOnNode and add ensureConnLimiterOnNode
ensure_conn_limiter = """
func (h *Handler) ensureConnLimiterOnNode(nodeID int64, limiterName string, maxConn int) error {
\tlimitStr := fmt.Sprintf("$ %d", maxConn)
\t
\tpayload := map[string]interface{}{
\t\t"name": limiterName,
\t\t"limits": []string{limitStr},
\t}
\t
\tif _, err := h.sendNodeCommand(nodeID, "AddCLimiters", payload, false, false); err != nil {
\t\tif !isAlreadyExistsMessage(err.Error()) {
\t\t\treturn fmt.Errorf("连接限制器下发失败: %w", err)
\t\t}
\t\tupdatePayload := map[string]interface{}{
\t\t\t"limiter": limiterName,
\t\t\t"data": payload,
\t\t}
\t\tif _, updateErr := h.sendNodeCommand(nodeID, "UpdateCLimiters", updatePayload, false, false); updateErr != nil {
\t\t\treturn fmt.Errorf("连接限制器更新失败: %w", updateErr)
\t\t}
\t}
\treturn nil
}
"""
content = content.replace('func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf("限速规则下发失败: %w", err)\n\t}\n\n\treturn nil\n}',
'func (h *Handler) ensureLimiterOnNode(nodeID int64, limiterID int64, speed int) error {\n\tif err := h.upsertLimiterOnNode(nodeID, limiterID, speed); err != nil {\n\t\treturn fmt.Errorf("限速规则下发失败: %w", err)\n\t}\n\n\treturn nil\n}\n' + ensure_conn_limiter)
# 2. Update buildForwardServiceConfigs declaration
content = content.replace(
'func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64) []map[string]interface{} {',
'func buildForwardServiceConfigs(baseName string, forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, port int, bindIP string, limiterID *int64, cLimiterName string) []map[string]interface{} {'
)
# 3. Inject climiter into generated service
service_map_end = """ }
if protocol == "udp" {"""
service_map_end_new = """ }
if cLimiterName != "" {
service["climiter"] = cLimiterName
}
if protocol == "udp" {"""
content = content.replace(service_map_end, service_map_end_new)
# 4. Update syncForwardServicesWithWarnings
# Find user tunnel resolution
resolution = """ serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
for _, fp := range ports {"""
resolution_new = """ serviceBase := buildForwardServiceBaseWithResolvedUserTunnel(forward.ID, forward.UserID, userTunnelID)
user, err := h.repo.GetUserByID(forward.UserID)
if err != nil {
return nil, err
}
var cLimiterName string
var maxConnToSet int
if forward.MaxConn > 0 {
maxConnToSet = forward.MaxConn
cLimiterName = fmt.Sprintf("rule_conn_limit_%d", forward.ID)
} else if user != nil && user.MaxConn > 0 {
maxConnToSet = user.MaxConn
cLimiterName = fmt.Sprintf("user_conn_limit_%d", user.ID)
}
for _, fp := range ports {"""
content = content.replace(resolution, resolution_new)
# Inject ensureConnLimiterOnNode inside loop
loop_inner = """ 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
}
}
node, err := h.getNodeRecord(fp.NodeID)"""
loop_inner_new = """ 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
}
}
if cLimiterName != "" {
if err := h.ensureConnLimiterOnNode(fp.NodeID, cLimiterName, maxConnToSet); err != nil {
warnings = append(warnings, fmt.Sprintf("节点 %d 连接限制器下发失败: %v", fp.NodeID, err))
}
}
node, err := h.getNodeRecord(fp.NodeID)"""
content = content.replace(loop_inner, loop_inner_new)
# Update buildForwardServiceConfigs call in syncForwardServicesWithWarnings
content = content.replace(
'services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID)',
'services := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, strings.TrimSpace(fp.InIP), limiterID, cLimiterName)'
)
# Update fallbackForwardPortToDefaultBind call
content = content.replace(
'warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID)',
'warning, err = h.fallbackForwardPortToDefaultBind(forward, tunnel, node, fp, serviceBase, limiterID, cLimiterName)'
)
# 5. Update fallbackForwardPortToDefaultBind declaration and logic
content = content.replace(
'func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64) (string, error) {',
'func (h *Handler) fallbackForwardPortToDefaultBind(forward *forwardRecord, tunnel *tunnelRecord, node *nodeRecord, fp forwardPortRecord, serviceBase string, limiterID *int64, cLimiterName string) (string, error) {'
)
content = content.replace(
'defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID)',
'defaultServices := buildForwardServiceConfigs(serviceBase, forward, tunnel, node, fp.Port, "", limiterID, cLimiterName)'
)
with open('internal/http/handler/control_plane.go', 'w') as f:
f.write(content)
# Update control_plane_test.go
with open('internal/http/handler/control_plane_test.go', 'r') as f:
test_content = f.read()
test_content = re.sub(
r'buildForwardServiceConfigs\((.*?),(.*?),(.*?),(.*?),(.*?),(.*?),(.*?)\)',
r'buildForwardServiceConfigs(\1,\2,\3,\4,\5,\6,\7, "")',
test_content
)
with open('internal/http/handler/control_plane_test.go', 'w') as f:
f.write(test_content)
@@ -6,14 +6,9 @@ import (
"strings" "strings"
"testing" "testing"
"go-backend/internal/http/handler"
"go-backend/internal/store/repo" "go-backend/internal/store/repo"
) )
func init() {
handler.DisableSafeRemoteAddrCheckForTesting = true
}
func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 { func mustLastInsertID(t *testing.T, r *repo.Repository, label string) int64 {
t.Helper() t.Helper()
var id int64 var id int64
@@ -1,284 +0,0 @@
package contract_test
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"net/url"
"strings"
"sync"
"testing"
"time"
"github.com/gorilla/websocket"
"go-backend/internal/auth"
"go-backend/internal/http/response"
"go-backend/internal/security"
)
func TestMaxConnLimit(t *testing.T) {
secret := "contract-jwt-secret"
router, r := setupContractRouter(t, secret)
server := httptest.NewServer(router)
defer server.Close()
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
if err != nil {
t.Fatalf("generate admin token: %v", err)
}
now := time.Now().UnixMilli()
if err := r.DB().Exec(`
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "max-conn-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
t.Fatalf("insert tunnel: %v", err)
}
var tunnelID int64
if err := r.DB().Raw("SELECT id FROM tunnel WHERE name = ?", "max-conn-tunnel").Scan(&tunnelID).Error; err != nil {
t.Fatalf("get tunnel ID: %v", err)
}
if err := r.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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`, "max-conn-node", "max-conn-secret", "10.20.0.1", "10.20.0.1", "", "32000-32010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
t.Fatalf("insert node: %v", err)
}
var nodeID int64
if err := r.DB().Raw("SELECT id FROM node WHERE name = ?", "max-conn-node").Scan(&nodeID).Error; err != nil {
t.Fatalf("get node ID: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
VALUES(?, 1, ?, 32001, 'round', 1, 'tls')
`, tunnelID, nodeID).Error; err != nil {
t.Fatalf("insert chain_tunnel: %v", err)
}
if err := r.DB().Exec(`
INSERT INTO user_tunnel(user_id, tunnel_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
VALUES(1, ?, 10, 99999, 0, 0, 1, ?, 1)
`, tunnelID, now + 365*24*3600*1000).Error; err != nil { t.Fatalf("insert user_tunnel: %v", err)
}
var commandMu sync.Mutex
receivedCommands := make([]string, 0)
var addCLimitersData json.RawMessage
var updateCLimitersData json.RawMessage
stopNode := startMockSessionForMaxConn(t, server.URL, "max-conn-secret", func(cmdType string, data json.RawMessage) (bool, string) {
commandMu.Lock()
defer commandMu.Unlock()
receivedCommands = append(receivedCommands, cmdType)
if cmdType == "AddCLimiters" {
addCLimitersData = append([]byte(nil), data...)
return true, "already exists"
}
if cmdType == "UpdateCLimiters" {
updateCLimitersData = append([]byte(nil), data...)
}
return false, ""
})
defer stopNode()
waitNodeStatus(t, r, nodeID, 1)
payload := map[string]interface{}{
"name": "max-conn-forward",
"tunnelId": tunnelID,
"remoteAddr": "1.1.1.1:443",
"strategy": "fifo",
"maxConn": 42,
}
body, err := json.Marshal(payload)
if err != nil {
t.Fatalf("marshal payload: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/create", bytes.NewReader(body))
req.Header.Set("Authorization", adminToken)
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 create success, got code=%d msg=%s", out.Code, out.Msg)
}
var forwardID int64
if err := r.DB().Raw("SELECT id FROM forward WHERE name = ?", "max-conn-forward").Scan(&forwardID).Error; err != nil {
t.Fatalf("get forward ID: %v", err)
}
commandMu.Lock()
defer commandMu.Unlock()
hasAdd := false
hasUpdate := false
for _, cmd := range receivedCommands {
if cmd == "AddCLimiters" {
hasAdd = true
}
if cmd == "UpdateCLimiters" {
hasUpdate = true
}
}
if !hasAdd {
t.Fatalf("expected AddCLimiters to be sent, but it was not. Received: %v", receivedCommands)
}
if !hasUpdate {
t.Fatalf("expected UpdateCLimiters to be sent after AddCLimiters failed with already exists. Received: %v", receivedCommands)
}
expectedName := fmt.Sprintf("rule_conn_limit_%d", forwardID)
// verify payload for AddCLimiters
var addData map[string]interface{}
if err := json.Unmarshal(addCLimitersData, &addData); err != nil {
t.Fatalf("unmarshal AddCLimiters data: %v", err)
}
if addData["name"] != expectedName {
t.Fatalf("expected limiter name %s, got %v", expectedName, addData["name"])
}
if limits, ok := addData["limits"].([]interface{}); ok {
if len(limits) != 1 || limits[0] != "$ 42" {
t.Fatalf("expected limits to contain '$ 42', got %v", limits)
}
} else {
t.Fatalf("invalid limits type in AddCLimiters data: %v", addData)
}
// verify payload for UpdateCLimiters
var updateData map[string]interface{}
if err := json.Unmarshal(updateCLimitersData, &updateData); err != nil {
t.Fatalf("unmarshal UpdateCLimiters data: %v", err)
}
if updateData["limiter"] != expectedName {
t.Fatalf("expected update limiter name %s, got %v", expectedName, updateData["limiter"])
}
nestedData, ok := updateData["data"].(map[string]interface{})
if !ok {
t.Fatalf("expected nested 'data' in UpdateCLimiters, got %v", updateData)
}
if nestedData["name"] != expectedName {
t.Fatalf("expected nested name %s, got %v", expectedName, nestedData["name"])
}
if nestedLimits, ok := nestedData["limits"].([]interface{}); ok {
if len(nestedLimits) != 1 || nestedLimits[0] != "$ 42" {
t.Fatalf("expected nested limits to contain '$ 42', got %v", nestedLimits)
}
} else {
t.Fatalf("invalid limits type in UpdateCLimiters nested data: %v", nestedData)
}
}
func startMockSessionForMaxConn(t *testing.T, baseURL string, nodeSecret string, onCommand func(cmdType string, data json.RawMessage) (bool, string)) func() {
t.Helper()
u, err := url.Parse(baseURL)
if err != nil {
t.Fatalf("parse provider url: %v", err)
}
if strings.EqualFold(u.Scheme, "https") {
u.Scheme = "wss"
} else {
u.Scheme = "ws"
}
u.Path = "/system-info"
q := u.Query()
q.Set("type", "1")
q.Set("secret", nodeSecret)
q.Set("version", "v1")
q.Set("http", "1")
q.Set("tls", "1")
q.Set("socks", "1")
u.RawQuery = q.Encode()
conn, _, err := websocket.DefaultDialer.Dial(u.String(), nil)
if err != nil {
t.Fatalf("dial mock node websocket: %v", err)
}
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
for {
_, raw, readErr := conn.ReadMessage()
if readErr != nil {
return
}
plain := raw
var wrap struct {
Encrypted bool `json:"encrypted"`
Data string `json:"data"`
}
if err := json.Unmarshal(raw, &wrap); err == nil && wrap.Encrypted && strings.TrimSpace(wrap.Data) != "" {
crypto, cryptoErr := security.NewAESCrypto(nodeSecret)
if cryptoErr == nil {
if dec, decErr := crypto.Decrypt(wrap.Data); decErr == nil {
plain = []byte(dec)
}
}
}
var cmd struct {
Type string `json:"type"`
RequestID string `json:"requestId"`
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(plain, &cmd); err != nil {
continue
}
if strings.TrimSpace(cmd.RequestID) == "" {
continue
}
shouldFail := false
failMsg := ""
if onCommand != nil {
shouldFail, failMsg = onCommand(strings.TrimSpace(cmd.Type), cmd.Data)
}
respType := fmt.Sprintf("%sResponse", cmd.Type)
respPayload := map[string]interface{}{
"type": respType,
"success": !shouldFail,
"message": "OK",
"requestId": cmd.RequestID,
}
if shouldFail {
if strings.TrimSpace(failMsg) == "" {
failMsg = "mock command failed"
}
respPayload["message"] = failMsg
}
respBytes, err := json.Marshal(respPayload)
if err != nil {
continue
}
_ = conn.WriteMessage(websocket.TextMessage, respBytes)
}
}()
var stopOnce sync.Once
return func() {
stopOnce.Do(func() {
_ = conn.Close()
wg.Wait()
})
}
}
-5
View File
@@ -1,5 +0,0 @@
gost
gost_*
gost-*
*.sha256
*.exe
+34 -18
View File
@@ -1,30 +1,46 @@
# go-gost # GO-GOST SERVICE KNOWLEDGE BASE
Forwarding agent (forked GOST v3). Uses local `x/` module via `replace github.com/go-gost/x => ./x`. **Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## Structure ## OVERVIEW
Forwarding agent built on GOST v3 with a local fork of `github.com/go-gost/x` under `x/`.
**Stack:** Go 1.23, github.com/go-gost/core v0.3.1, local `go-gost/x` module.
| File | Role | ## STRUCTURE
|------|------| ```
| `main.go` | Entry point, reads `config.json`, starts reporter + service | go-gost/
| `config.go` | Panel integration config loader (addr, secret, ports) | ├── main.go # Entry; reads panel config.json; starts svc.Run(program)
| `program.go` | GOST runtime: parse config, run/reload services (SIGHUP) | ├── config.go # Panel config.json loader (addr/secret + ports)
| `x/` | Local fork of `github.com/go-gost/x` (own `go.mod`) | ├── program.go # GOST runtime: parse config, run/reload services
├── x/ # Local fork of github.com/go-gost/x (has its own go.mod)
└── go.mod # replace github.com/go-gost/x => ./x
```
## Conventions ## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Panel integration config** | `go-gost/config.go` | Expects `config.json` in cwd by default |
| **Service lifecycle/reload** | `go-gost/program.go` | Parses config; handles SIGHUP reload |
| **WebSocket reporting** | `go-gost/main.go` | Starts reporter + sets HTTP report URL |
| **Protocol behaviors** | `go-gost/x/` | Handlers/listeners/dialers live here |
| **Build** | `go-gost/Makefile` | Cross-compile targets for amd64/arm64 |
- Two config files: panel integration uses `config.json`; forwarding uses GOST config (`gost.{json,yaml}`). ## CONVENTIONS
- `x/` is the extension surface — add handlers/listeners/dialers there, not in vendored deps. - Two configs exist: panel integration uses `config.json`; forwarding services use GOST config (defaults to `gost.{json,yaml}` via viper search paths).
- Agent→panel: WebSocket (real-time commands) + HTTP (batch traffic reports). - `go-gost/x/` is the primary extension surface; avoid editing vendored deps.
- Agent communicates with panel via WebSocket (real-time commands) + HTTP (batch traffic reports).
- All panel communication uses AES encryption with node `secret` as PSK. - All panel communication uses AES encryption with node `secret` as PSK.
- CI builds with `CGO_ENABLED=0` for static binaries, then compresses with UPX.
## Anti-patterns ## ANTI-PATTERNS
- **DO NOT EDIT** generated protobuf in `x/internal/util/grpc/proto/`.
- Don't edit `x/internal/util/grpc/proto/*.pb.go` (generated protobuf).
## Commands
## COMMANDS
```bash ```bash
cd go-gost
go run . go run .
go test ./... go test ./...
go build . go build .
+28 -14
View File
@@ -1,6 +1,8 @@
module github.com/go-gost/gost module github.com/go-gost/gost
go 1.25.0 go 1.23.0
toolchain go1.23.4
require ( require (
github.com/go-gost/core v0.3.1 github.com/go-gost/core v0.3.1
@@ -14,6 +16,7 @@ require (
github.com/alessio/shellescape v1.4.1 // indirect github.com/alessio/shellescape v1.4.1 // indirect
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d // indirect
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/bwmarrin/snowflake v0.3.0 // indirect
github.com/bytedance/sonic v1.11.6 // indirect github.com/bytedance/sonic v1.11.6 // indirect
github.com/bytedance/sonic/loader v0.1.1 // indirect github.com/bytedance/sonic/loader v0.1.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
@@ -22,6 +25,8 @@ require (
github.com/coreos/go-iptables v0.7.0 // indirect github.com/coreos/go-iptables v0.7.0 // indirect
github.com/danieljoos/wincred v1.2.0 // indirect github.com/danieljoos/wincred v1.2.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/expr-lang/expr v1.16.3 // indirect
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf // indirect
github.com/fsnotify/fsnotify v1.7.0 // indirect github.com/fsnotify/fsnotify v1.7.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.3 // indirect github.com/gabriel-vasile/mimetype v1.4.3 // indirect
github.com/gin-contrib/cors v1.7.2 // indirect github.com/gin-contrib/cors v1.7.2 // indirect
@@ -42,13 +47,17 @@ require (
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 // indirect github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 // indirect
github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect github.com/google/pprof v0.0.0-20241210010833-40e02aabc2ad // indirect
github.com/google/uuid v1.6.0 // indirect github.com/google/uuid v1.6.0 // indirect
github.com/gorilla/websocket v1.5.3 // indirect github.com/gorilla/websocket v1.5.3 // indirect
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/hashicorp/hcl v1.0.0 // indirect github.com/hashicorp/hcl v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jonboulle/clockwork v0.2.2 // indirect github.com/jonboulle/clockwork v0.2.2 // indirect
github.com/josharian/native v1.0.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.7 // indirect github.com/klauspost/cpuid/v2 v2.2.7 // indirect
github.com/klauspost/reedsolomon v1.11.8 // indirect github.com/klauspost/reedsolomon v1.11.8 // indirect
@@ -56,6 +65,8 @@ require (
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
github.com/magiconair/properties v1.8.7 // indirect github.com/magiconair/properties v1.8.7 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mdlayher/netlink v1.6.0 // indirect
github.com/mdlayher/socket v0.1.1 // indirect
github.com/miekg/dns v1.1.61 // indirect github.com/miekg/dns v1.1.61 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/mitchellh/mapstructure v1.5.0 // indirect github.com/mitchellh/mapstructure v1.5.0 // indirect
@@ -76,7 +87,7 @@ require (
github.com/prometheus/common v0.48.0 // indirect github.com/prometheus/common v0.48.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/quic-go/qpack v0.5.1 // indirect github.com/quic-go/qpack v0.5.1 // indirect
github.com/quic-go/quic-go v0.49.1 // indirect github.com/quic-go/quic-go v0.48.2 // indirect
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 // indirect
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 // indirect
github.com/rs/xid v1.3.0 // indirect github.com/rs/xid v1.3.0 // indirect
@@ -91,6 +102,7 @@ require (
github.com/sourcegraph/conc v0.3.0 // indirect github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.11.0 // indirect github.com/spf13/afero v1.11.0 // indirect
github.com/spf13/cast v1.6.0 // indirect github.com/spf13/cast v1.6.0 // indirect
github.com/spf13/cobra v1.8.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect github.com/spf13/pflag v1.0.5 // indirect
github.com/spf13/viper v1.19.0 // indirect github.com/spf13/viper v1.19.0 // indirect
github.com/subosito/gotenv v1.6.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect
@@ -112,25 +124,27 @@ require (
github.com/zalando/go-keyring v0.2.4 // indirect github.com/zalando/go-keyring v0.2.4 // indirect
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
go.uber.org/zap v1.26.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/crypto v0.50.0 // indirect golang.org/x/crypto v0.38.0 // indirect
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect golang.org/x/exp v0.0.0-20241210194714-1829a127f884 // indirect
golang.org/x/mod v0.34.0 // indirect golang.org/x/mod v0.22.0 // indirect
golang.org/x/net v0.53.0 // indirect golang.org/x/net v0.33.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.43.0 // indirect golang.org/x/sys v0.33.0 // indirect
golang.org/x/term v0.42.0 // indirect golang.org/x/term v0.32.0 // indirect
golang.org/x/text v0.36.0 // indirect golang.org/x/text v0.25.0 // indirect
golang.org/x/time v0.12.0 // indirect golang.org/x/time v0.5.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.28.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
google.golang.org/grpc v1.80.0 // indirect google.golang.org/grpc v1.67.1 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.35.1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect gopkg.in/natefinch/lumberjack.v2 v2.2.1 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )
replace github.com/go-gost/x => ./x replace github.com/go-gost/x => ./x
+64 -48
View File
@@ -10,6 +10,8 @@ github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d h1:Byv0BzEl
github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw= github.com/asaskevich/govalidator v0.0.0-20210307081110-f21760c49a8d/go.mod h1:WaHUgvxTVq04UNunO+XhnAqY/wQc+bxr74GqbsZ/Jqw=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0=
github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE=
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0= github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4= github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM= github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
@@ -25,6 +27,7 @@ github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQ
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc= github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8= github.com/coreos/go-iptables v0.7.0 h1:XWM3V+MPRr5/q51NuWSgU0fqMad64Zyxs8ZUoMsamr8=
github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q= github.com/coreos/go-iptables v0.7.0/go.mod h1:Qe8Bv2Xik5FyTXwgIbLAnv2sWSBmvWdFETJConOQ//Q=
github.com/cpuguy83/go-md2man/v2 v2.0.3/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE= github.com/danieljoos/wincred v1.2.0 h1:ozqKHaLK0W/ii4KVbbvluM91W2H3Sh0BncbUNPS7jLE=
github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec= github.com/danieljoos/wincred v1.2.0/go.mod h1:FzQLLMKBFdvu+osBrnFODiv32YGwCfx0SkRa/eYHgec=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -37,6 +40,10 @@ github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymF
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4= github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98= github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c= github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/expr-lang/expr v1.16.3 h1:NLldf786GffptcXNxxJx5dQ+FzeWDKChBDqOOwyK8to=
github.com/expr-lang/expr v1.16.3/go.mod h1:uCkhfG+x7fcZ5A5sXHKuQ07jGZRl6J0FCAaf2k4PtVQ=
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf h1:NqGS3vTHzVENbIfd87cXZwdpO6MB2R1PjHMJLi4Z3ow=
github.com/florianl/go-nfqueue v1.3.2-0.20231218173729-f2bdeb033acf/go.mod h1:eSnAor2YCfMCVYrVNEhkLGN/r1L+J4uDjc0EUy0tfq4=
github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk= github.com/francoispqt/gojay v1.2.13 h1:d2m3sFjloqoIUQU3TsHBgj6qg/BVGlTBeHDUmyJnXKk=
github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY= github.com/francoispqt/gojay v1.2.13/go.mod h1:ehT5mTG4ua4581f1++1WLG0vPdaA9HaiDsoyrBGkyDY=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
@@ -63,10 +70,8 @@ github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c=
github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8= github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8=
github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKrzmkCoU= github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKrzmkCoU=
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs= github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -98,8 +103,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
@@ -109,8 +112,9 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 h1:NaJi58bCZZh0jjPw78EqDZekPEfhlzYE01C5R+zh1tE= github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866 h1:NaJi58bCZZh0jjPw78EqDZekPEfhlzYE01C5R+zh1tE=
github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866/go.mod h1:riddUzxTSBpJXk3qBHtYr4qOhFhT6k/1c0E3qkQjQpA= github.com/google/gopacket v1.1.20-0.20220810144506-32ee38206866/go.mod h1:riddUzxTSBpJXk3qBHtYr4qOhFhT6k/1c0E3qkQjQpA=
@@ -122,10 +126,16 @@ github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aN
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf h1:C1GPyPJrOlJlIrcaBBiBpDsqZena2Ks8spa5xZqr1XQ= github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf h1:C1GPyPJrOlJlIrcaBBiBpDsqZena2Ks8spa5xZqr1XQ=
github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf/go.mod h1:zXqxTI6jXDdKnlf8s+nT+3c8LrwUEy3yNpO4XJL90lA= github.com/gravitational/trace v1.1.16-0.20220114165159-14a9a7dd6aaf/go.mod h1:zXqxTI6jXDdKnlf8s+nT+3c8LrwUEy3yNpO4XJL90lA=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4= github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ= github.com/jonboulle/clockwork v0.2.2 h1:UOGuzwb1PwsrDAObMuhUnj0p5ULPj8V/xJ7Kx9qUBdQ=
github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8= github.com/jonboulle/clockwork v0.2.2/go.mod h1:Pkfl5aHPm1nk2H9h0bjmnJD/BcgbGXUBGnn1kMkgxc8=
github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk=
github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/judwhite/go-svc v1.2.1 h1:a7fsJzYUa33sfDJRF2N/WXhA+LonCEEY8BJb1tuS5tA= github.com/judwhite/go-svc v1.2.1 h1:a7fsJzYUa33sfDJRF2N/WXhA+LonCEEY8BJb1tuS5tA=
@@ -148,6 +158,10 @@ github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0V
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mdlayher/netlink v1.6.0 h1:rOHX5yl7qnlpiVkFWoqccueppMtXzeziFjWAjLg6sz0=
github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA=
github.com/mdlayher/socket v0.1.1 h1:q3uOGirUPfAV2MUoaC7BavjQ154J7+JOkTWyiV+intI=
github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs=
github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs=
github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
@@ -199,8 +213,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0= github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg=
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
@@ -209,6 +223,7 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog= github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4= github.com/rs/xid v1.3.0 h1:6NjYksEUlhurdVehpc7S7dk6DAmcKv8V9gG0FsVN2U4=
github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/xid v1.3.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ= github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4= github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE= github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
@@ -234,6 +249,8 @@ github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY= github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0= github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo= github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.8.0 h1:7aJaZx1B85qltLMc546zn58BxxfZdR/W22ej9CFoEf0=
github.com/spf13/cobra v1.8.0/go.mod h1:WXLWApfZ71AjXPya3WOlMsY9yMs7YeiHhFVlvLyhcho=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI= github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
@@ -295,22 +312,14 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68= github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8= golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc= golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
@@ -322,8 +331,8 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
@@ -333,8 +342,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -344,18 +353,21 @@ golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLL
golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201010224723-4f7140c49acb/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= golang.org/x/net v0.0.0-20201031054903-ff519b6c9102/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -368,7 +380,11 @@ golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.4.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
@@ -376,24 +392,25 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.32.0 h1:DR4lr0TjUs3epypdhTOkMmuF5CDFJ/8pOnbzMZPQ7bg=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.32.0/go.mod h1:uZG1FhGx848Sqfsq4/DlJr3xGGsYMu/L5GW4abiaEPQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY= golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
@@ -402,38 +419,37 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg=
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
+46 -31
View File
@@ -1,35 +1,50 @@
# go-gost/x # GO-GOST/X KNOWLEDGE BASE
Local fork of `github.com/go-gost/x`. Standalone Go module, used by `go-gost/` via `replace => ./x`. **Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## Key packages ## 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.
| Dir | Role | ## STRUCTURE
|-----|------|
| `handler/` | Protocol handlers (socks, http, tunnel, relay, ...) |
| `listener/` | Inbound listeners (tcp, udp, tun, tap, redirect, ...) |
| `dialer/` | Outbound dialers (tcp, tls, ws, quic, ...) |
| `connector/` | Outbound connect implementations |
| `service/` | Service wrappers + reporting hooks |
| `socket/` | WebSocket reporter / panel integration |
| `config/` | Config model + parsing/load/reload |
| `registry/` | Component registries (`Register{Type}(name, creator)`) |
| `api/` | Gin management API + embedded swagger docs |
| `limiter/` | Traffic/rate/conn limiters |
| `internal/` | gRPC proto, net utils, sniffing, TLS |
## Conventions
- Each protocol follows `{type}.go` + `metadata.go` pattern.
- OS-specific code uses `name_[os].go` suffix (e.g. `tun_linux.go`).
- Run Go tooling from this directory for module resolution issues.
## Anti-patterns
- Don't edit `internal/util/grpc/proto/*.pb.go` or `*_grpc.pb.go` (generated).
## Commands
```bash
go test ./...
``` ```
go-gost/x/
├── api/ # Gin management API + embedded swagger docs (22 files)
├── config/ # Config model + parsing/load/reload
├── connector/ # Outbound connect implementations
├── dialer/ # Outbound dialers (tcp/tls/ws/quic/...)
├── handler/ # Protocol handlers (socks/http/tunnel/relay/...)
├── listener/ # Inbound listeners (tcp/udp/tun/tap/redirect/...)
├── limiter/ # Traffic/rate/conn limiters
├── registry/ # Registries for services/handlers/listeners/etc (20 files)
├── service/ # Service wrappers + reporting hooks
├── socket/ # WebSocket reporter / panel integration (6 files)
└── internal/ # Shared internals (grpc proto, net utils, sniffing, tls, ...)
```
## WHERE TO LOOK
| Task | Location | Notes |
|------|----------|-------|
| **Management API routes/auth** | `go-gost/x/api/api.go` | `/docs`, `/config/*`; BasicAuth + interceptor |
| **Service config parsing** | `go-gost/x/config/parsing/` | Converts config to running services |
| **Add a handler** | `go-gost/x/handler/` | Per-protocol subdirs |
| **Add a listener/dialer** | `go-gost/x/listener/`, `go-gost/x/dialer/` | Transport variants |
| **Panel reporting** | `go-gost/x/socket/` | WebSocket + HTTP report URL hooks |
| **Register new component** | `go-gost/x/registry/` | `Register{Type}(name, creator)` |
## CONVENTIONS
- `go-gost/x/` is a standalone Go module (`go-gost/x/go.mod`); run go tooling from this dir when debugging module resolution.
- Generated gRPC/proto code lives under `go-gost/x/internal/util/grpc/proto/`.
- Handlers/listeners/dialers follow consistent pattern: `{type}.go` + `metadata.go` per protocol.
- OS-specific code uses `name_[os].go` suffix (e.g., `tun_linux.go`, `tun_darwin.go`).
## ANTI-PATTERNS
- Do not edit generated files in `go-gost/x/internal/util/grpc/proto/` (`*.pb.go`, `*_grpc.pb.go`).
## COMMANDS
```bash
cd go-gost/x
go test ./...
```
+2 -2
View File
@@ -135,7 +135,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
postDown = mdutil.GetStrings(md, parsing.MDKeyPostDown) postDown = mdutil.GetStrings(md, parsing.MDKeyPostDown)
ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain) ignoreChain = mdutil.GetBool(md, parsing.MDKeyIgnoreChain)
if md != nil && md.IsExists(parsing.MDKeyEnableStats) { if md.IsExists(parsing.MDKeyEnableStats) {
enableStats = mdutil.GetBool(md, parsing.MDKeyEnableStats) enableStats = mdutil.GetBool(md, parsing.MDKeyEnableStats)
} }
@@ -157,7 +157,7 @@ func ParseService(cfg *config.ServiceConfig) (service.Service, error) {
resetTraffic := true resetTraffic := true
if cfg.Metadata != nil { if cfg.Metadata != nil {
md := metadata.NewMetadata(cfg.Metadata) md := metadata.NewMetadata(cfg.Metadata)
if md != nil && md.IsExists(parsing.MDKeyObserverResetTraffic) { if md.IsExists(parsing.MDKeyObserverResetTraffic) {
resetTraffic = mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic) resetTraffic = mdutil.GetBool(md, parsing.MDKeyObserverResetTraffic)
} }
} }
-4
View File
@@ -11,7 +11,6 @@ import (
"github.com/go-gost/core/logger" "github.com/go-gost/core/logger"
md "github.com/go-gost/core/metadata" md "github.com/go-gost/core/metadata"
kcp_util "github.com/go-gost/x/internal/util/kcp" kcp_util "github.com/go-gost/x/internal/util/kcp"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/xtaci/kcp-go/v5" "github.com/xtaci/kcp-go/v5"
"github.com/xtaci/smux" "github.com/xtaci/smux"
@@ -49,9 +48,6 @@ func (d *kcpDialer) Init(md md.Metadata) (err error) {
} }
d.md.config.Init() d.md.config.Init()
if md != nil && md.IsExists("kcp.nc") {
d.md.config.NoCongestion = mdutil.GetInt(md, "kcp.nc")
}
return nil return nil
} }
+14 -54
View File
@@ -40,61 +40,21 @@ func (d *kcpDialer) parseMetadata(md mdata.Metadata) (err error) {
d.md.config = cfg d.md.config = cfg
} }
if d.md.config == nil { if d.md.config == nil {
d.md.config = kcp_util.DefaultConfig.Clone() d.md.config = kcp_util.DefaultConfig
} else {
d.md.config = d.md.config.Clone()
}
if md != nil {
if md.IsExists("kcp.tcp") {
d.md.config.TCP = mdutil.GetBool(md, "kcp.tcp")
}
if md.IsExists("kcp.key") {
d.md.config.Key = mdutil.GetString(md, "kcp.key")
}
if md.IsExists("kcp.crypt") {
d.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
}
if md.IsExists("kcp.mode") {
d.md.config.Mode = mdutil.GetString(md, "kcp.mode")
}
if md.IsExists("kcp.keepalive") {
d.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
}
if md.IsExists("kcp.interval") {
d.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
}
if md.IsExists("kcp.mtu") {
d.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
}
if md.IsExists("kcp.rcvwnd") {
d.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
}
if md.IsExists("kcp.sndwnd") {
d.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
}
if md.IsExists("kcp.sockbuf") {
d.md.config.SockBuf = mdutil.GetInt(md, "kcp.sockbuf")
}
if md.IsExists("kcp.smuxver") {
d.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
}
if md.IsExists("kcp.smuxbuf") {
d.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
}
if md.IsExists("kcp.streambuf") {
d.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
}
if md.IsExists("kcp.nocomp") {
d.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
}
if md.IsExists("kcp.datashard") {
d.md.config.DataShard = mdutil.GetInt(md, "kcp.datashard")
}
if md.IsExists("kcp.parityshard") {
d.md.config.ParityShard = mdutil.GetInt(md, "kcp.parityshard")
}
} }
d.md.config.TCP = mdutil.GetBool(md, "kcp.tcp", "tcp")
d.md.config.Key = mdutil.GetString(md, "kcp.key")
d.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
d.md.config.Mode = mdutil.GetString(md, "kcp.mode")
d.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
d.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
d.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
d.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
d.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
d.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
d.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
d.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
d.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout) d.md.handshakeTimeout = mdutil.GetDuration(md, handshakeTimeout)
return return
+15 -13
View File
@@ -1,6 +1,8 @@
module github.com/go-gost/x module github.com/go-gost/x
go 1.25.0 go 1.22.0
toolchain go1.23.4
require github.com/shirou/gopsutil/v3 v3.24.5 require github.com/shirou/gopsutil/v3 v3.24.5
@@ -26,7 +28,7 @@ require (
github.com/pion/dtls/v2 v2.2.6 github.com/pion/dtls/v2 v2.2.6
github.com/pires/go-proxyproto v0.7.0 github.com/pires/go-proxyproto v0.7.0
github.com/prometheus/client_golang v1.19.1 github.com/prometheus/client_golang v1.19.1
github.com/quic-go/quic-go v0.49.1 github.com/quic-go/quic-go v0.48.2
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66
github.com/rs/xid v1.3.0 github.com/rs/xid v1.3.0
github.com/shadowsocks/go-shadowsocks2 v0.1.5 github.com/shadowsocks/go-shadowsocks2 v0.1.5
@@ -43,15 +45,15 @@ require (
github.com/xtaci/tcpraw v1.2.25 github.com/xtaci/tcpraw v1.2.25
github.com/yl2chen/cidranger v1.0.2 github.com/yl2chen/cidranger v1.0.2
github.com/zalando/go-keyring v0.2.4 github.com/zalando/go-keyring v0.2.4
golang.org/x/crypto v0.50.0 golang.org/x/crypto v0.31.0
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 golang.org/x/exp v0.0.0-20241210194714-1829a127f884
golang.org/x/net v0.53.0 golang.org/x/net v0.33.0
golang.org/x/sys v0.43.0 golang.org/x/sys v0.28.0
golang.org/x/text v0.36.0 golang.org/x/text v0.21.0
golang.org/x/time v0.5.0 golang.org/x/time v0.5.0
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173
google.golang.org/grpc v1.80.0 google.golang.org/grpc v1.67.1
google.golang.org/protobuf v1.36.11 google.golang.org/protobuf v1.35.1
gopkg.in/natefinch/lumberjack.v2 v2.2.1 gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
) )
@@ -126,11 +128,11 @@ require (
go.uber.org/mock v0.5.0 // indirect go.uber.org/mock v0.5.0 // indirect
go.uber.org/multierr v1.11.0 // indirect go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.8.0 // indirect golang.org/x/arch v0.8.0 // indirect
golang.org/x/mod v0.34.0 // indirect golang.org/x/mod v0.22.0 // indirect
golang.org/x/sync v0.20.0 // indirect golang.org/x/sync v0.10.0 // indirect
golang.org/x/term v0.42.0 // indirect golang.org/x/term v0.27.0 // indirect
golang.org/x/tools v0.43.0 // indirect golang.org/x/tools v0.28.0 // indirect
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
gopkg.in/ini.v1 v1.67.0 // indirect gopkg.in/ini.v1 v1.67.0 // indirect
) )
+28 -46
View File
@@ -63,10 +63,8 @@ github.com/go-gost/relay v0.5.0 h1:JG1tgy/KWiVXS0ukuVXvbM0kbYuJTWxYpJ5JwzsCf/c=
github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8= github.com/go-gost/relay v0.5.0/go.mod h1:lcX+23LCQ3khIeASBo+tJ/WbwXFO32/N5YN6ucuYTG8=
github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKrzmkCoU= github.com/go-gost/tls-dissector v0.1.1 h1:2zUOTPzCQAUQ54Rpy0UEi3JPMQSYsIFSeFeKrzmkCoU=
github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs= github.com/go-gost/tls-dissector v0.1.1/go.mod h1:/9QfdewqmHdaE362Hv5nDaSWLx3pCmtD870d6GaquXs=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.2 h1:6pFjapn8bFcIbiKo3XT4j/BhANplGihG6tvd+8rYgrY=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/logr v1.4.2/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE=
github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY= github.com/go-ole/go-ole v1.2.6 h1:/Fpf6oFPoeFik9ty7siob0G6Ke8QvQEuVcuChpwXzpY=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
@@ -98,8 +96,6 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4= github.com/google/btree v1.0.1 h1:gK4Kx5IaGY9CD5sPJ36FHiBJ6ZXl0kilRiiCj+jdYp4=
@@ -109,8 +105,8 @@ github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMyw
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8= github.com/google/gopacket v1.1.19 h1:ves8RnFZPGiFnTS0uPQStjwru6uO6h+nlr9j6fL7kF8=
github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo= github.com/google/gopacket v1.1.19/go.mod h1:iJ8V8n6KS+z2U1A8pUwu8bW5SyEMkXJB8Yo/Vo+TKTo=
@@ -197,8 +193,8 @@ github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k
github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo=
github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI=
github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg=
github.com/quic-go/quic-go v0.49.1 h1:e5JXpUyF0f2uFjckQzD8jTghZrOUK1xxDqqZhlwixo0= github.com/quic-go/quic-go v0.48.2 h1:wsKXZPeGWpMpCGSWqOcqpW2wZYic/8T3aqiOID0/KWE=
github.com/quic-go/quic-go v0.49.1/go.mod h1:s2wDnmCdooUQBmQfpUSTCYBl1/D4FcqbULMMkASvR6s= github.com/quic-go/quic-go v0.48.2/go.mod h1:yBgs3rWBOADpga7F+jJsb6Ybg1LSYiQvwWlLX+/6HMs=
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg= github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66 h1:4WFk6u3sOT6pLa1kQ50ZVdm8BQFgJNA117cepZxtLIg=
github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw= github.com/quic-go/webtransport-go v0.8.1-0.20241018022711-4ac2c9250e66/go.mod h1:Vp72IJajgeOL6ddqrAhmp7IM9zbTcgkQxD/YdxrVwMw=
github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg= github.com/riobard/go-bloom v0.0.0-20200614022211-cdc8013cb5b3 h1:f/FNXud6gA3MNr8meMVVGxhp+QBTqY91tM8HjEuMjGg=
@@ -290,18 +286,6 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68= github.com/zalando/go-keyring v0.2.4 h1:wi2xxTqdiwMKbM6TWwi+uJCG/Tum2UV0jqaQhCa9/68=
github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk= github.com/zalando/go-keyring v0.2.4/go.mod h1:HL4k+OXQfJUWaMnqyuSOc0drfGPX2b51Du6K+MRgZMk=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
go.opentelemetry.io/otel v1.39.0/go.mod h1:kLlFTywNWrFyEdH0oj2xK0bFYZtHRYUdv1NklR/tgc8=
go.opentelemetry.io/otel/metric v1.39.0 h1:d1UzonvEZriVfpNKEVmHXbdf909uGTOQjA0HF0Ls5Q0=
go.opentelemetry.io/otel/metric v1.39.0/go.mod h1:jrZSWL33sD7bBxg1xjrqyDjnuzTUB0x1nBERXd7Ftcs=
go.opentelemetry.io/otel/sdk v1.39.0 h1:nMLYcjVsvdui1B/4FRkwjzoRVsMK8uL/cj0OyhKzt18=
go.opentelemetry.io/otel/sdk v1.39.0/go.mod h1:vDojkC4/jsTJsE+kh+LXYQlbL8CgrEcwmt1ENZszdJE=
go.opentelemetry.io/otel/sdk/metric v1.39.0 h1:cXMVVFVgsIf2YL6QkRF4Urbr/aMInf+2WKg+sEJTtB8=
go.opentelemetry.io/otel/sdk/metric v1.39.0/go.mod h1:xq9HEVH7qeX69/JnwEfp6fVq5wosJsY1mt4lLfYdVew=
go.opentelemetry.io/otel/trace v1.39.0 h1:2d2vfpEDmCJ5zVYz7ijaJdOF59xLomrvj7bjt6/qCJI=
go.opentelemetry.io/otel/trace v1.39.0/go.mod h1:88w4/PnZSazkGzz/w84VHpQafiU4EtqqlVdxWy+rNOA=
go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU=
go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
@@ -317,8 +301,8 @@ golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I= golang.org/x/crypto v0.0.0-20210220033148-5ea612d1eb83/go.mod h1:jdWPYTVW3xRLrWPugEBEK3UY2ZEsg3UU495nc5E+M+I=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU= golang.org/x/crypto v0.5.0/go.mod h1:NK/OQwhpMQP3MwtdjgLlYHnH9ebylxKWv3e0fK+mkQU=
golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= golang.org/x/crypto v0.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU= golang.org/x/exp v0.0.0-20241210194714-1829a127f884 h1:Y/Mj/94zIQQGHVSv1tTtQBDaQaJe62U9bkDZKKyhPCU=
golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c= golang.org/x/exp v0.0.0-20241210194714-1829a127f884/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
@@ -328,8 +312,8 @@ golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHl
golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY= golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg= golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.34.0 h1:xIHgNUUnW6sYkcM5Jleh05DvLOtwc6RitGHbDk4akRI= golang.org/x/mod v0.22.0 h1:D4nJWe9zXqHOmWqj4VMOJhvzj7bEZg4wEYa759z1pH4=
golang.org/x/mod v0.34.0/go.mod h1:ykgH52iCZe79kzLLMhyCUzhMci+nQj+0XkbXpNYtVjY= golang.org/x/mod v0.22.0/go.mod h1:6SkKJ3Xj0I0BrPOZoBy3bdMptDDU9oJrpohJ3eWZ1fY=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -342,15 +326,15 @@ golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws= golang.org/x/net v0.5.0/go.mod h1:DivGGAXEgPSlEBzxGzZI+ZLohi+xUj054jfeKui00ws=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U= golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@@ -368,22 +352,22 @@ golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= golang.org/x/sys v0.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw= golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ= golang.org/x/term v0.4.0/go.mod h1:9P2UbLfCdcvo3p/nzKvsmas4TnlujnuoV9hGgYzW1lQ=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.6.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk= golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -394,8 +378,8 @@ golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBn
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28= golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.43.0 h1:12BdW9CeB3Z+J/I/wj34VMl8X+fEXBxVR90JeMX5E7s= golang.org/x/tools v0.28.0 h1:WuB6qZ4RPCQo5aP3WdKZS7i595EdWqWR8vqJTlwTVK8=
golang.org/x/tools v0.43.0/go.mod h1:uHkMso649BX2cZK6+RpuIPXS3ho2hZo4FVwfoy1vIk0= golang.org/x/tools v0.28.0/go.mod h1:dcIOrVd3mfQKTgrDVQHqCPMWy6lnhfhtX3hLXYVLfRw=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
@@ -403,29 +387,27 @@ golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeu
golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173 h1:/jFs0duh4rdb8uIfPMv78iAJGcPKDeqAFnaLBropIC4=
golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA= golang.zx2c4.com/wireguard v0.0.0-20231211153847-12269c276173/go.mod h1:tkCQ4FQXmpAgYVh++1cq16/dH4QJtmvpRv19DWGAHSA=
gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4=
gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM= google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc= google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc= google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516 h1:sNrWoksmOyF5bvJUcnmbeAmQi8baNhqg5IWaI3llQqU= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260120221211-b8f7ae30c516/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c= google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg= google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY= google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk= google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak= google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.80.0 h1:Xr6m2WmWZLETvUNvIUmeD5OAagMw3FiKmMlTdViWsHM= google.golang.org/grpc v1.67.1 h1:zWnc1Vrcno+lHZCOofnIMvycFcc0QRGIzm9dhnDX68E=
google.golang.org/grpc v1.80.0/go.mod h1:ho/dLnxwi3EDJA4Zghp7k2Ec1+c2jqup0bFkw07bwF4= google.golang.org/grpc v1.67.1/go.mod h1:1gLDyUQU7CTLJI90u3nXZ9ekeghjeM7pTDZlqFNg2AA=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.35.1 h1:m3LfL6/Ca+fqnjnlqQXNpFPABW1UD7mjh8KO2mKFytA=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.35.1/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
-1
View File
@@ -227,7 +227,6 @@ func (c *conn) SetIdle(idle bool) {
func (c *conn) WriteQueue(b []byte) error { func (c *conn) WriteQueue(b []byte) error {
select { select {
case c.rc <- b: case c.rc <- b:
c.SetIdle(false)
return nil return nil
case <-c.closed: case <-c.closed:
+1 -10
View File
@@ -40,7 +40,7 @@ var (
SnmpLog: "", SnmpLog: "",
SnmpPeriod: 60, SnmpPeriod: 60,
Signal: false, Signal: false,
TCP: false, // default UDP mode (use tcpraw when true) TCP: false,
} }
) )
@@ -89,15 +89,6 @@ func ParseFromFile(filename string) (*Config, error) {
return config, nil return config, nil
} }
// Clone returns a deep copy of the Config.
func (c *Config) Clone() *Config {
if c == nil {
return nil
}
clone := *c
return &clone
}
// Init initializes the KCP config. // Init initializes the KCP config.
func (c *Config) Init() { func (c *Config) Init() {
switch c.Mode { switch c.Mode {
-4
View File
@@ -15,7 +15,6 @@ import (
limiter_wrapper "github.com/go-gost/x/limiter/traffic/wrapper" limiter_wrapper "github.com/go-gost/x/limiter/traffic/wrapper"
metrics "github.com/go-gost/x/metrics/wrapper" metrics "github.com/go-gost/x/metrics/wrapper"
stats "github.com/go-gost/x/observer/stats/wrapper" stats "github.com/go-gost/x/observer/stats/wrapper"
mdutil "github.com/go-gost/x/metadata/util"
"github.com/go-gost/x/registry" "github.com/go-gost/x/registry"
"github.com/xtaci/kcp-go/v5" "github.com/xtaci/kcp-go/v5"
"github.com/xtaci/smux" "github.com/xtaci/smux"
@@ -54,9 +53,6 @@ func (l *kcpListener) Init(md md.Metadata) (err error) {
config := l.md.config config := l.md.config
config.Init() config.Init()
if md != nil && md.IsExists("kcp.nc") {
config.NoCongestion = mdutil.GetInt(md, "kcp.nc")
}
var conn net.PacketConn var conn net.PacketConn
if config.TCP { if config.TCP {
+14 -54
View File
@@ -43,61 +43,21 @@ func (l *kcpListener) parseMetadata(md mdata.Metadata) (err error) {
} }
if l.md.config == nil { if l.md.config == nil {
l.md.config = kcp_util.DefaultConfig.Clone() l.md.config = kcp_util.DefaultConfig
} else {
l.md.config = l.md.config.Clone()
}
if md != nil {
if md.IsExists("kcp.tcp") {
l.md.config.TCP = mdutil.GetBool(md, "kcp.tcp")
}
if md.IsExists("kcp.key") {
l.md.config.Key = mdutil.GetString(md, "kcp.key")
}
if md.IsExists("kcp.crypt") {
l.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
}
if md.IsExists("kcp.mode") {
l.md.config.Mode = mdutil.GetString(md, "kcp.mode")
}
if md.IsExists("kcp.keepalive") {
l.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
}
if md.IsExists("kcp.interval") {
l.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
}
if md.IsExists("kcp.mtu") {
l.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
}
if md.IsExists("kcp.rcvwnd") {
l.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
}
if md.IsExists("kcp.sndwnd") {
l.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
}
if md.IsExists("kcp.sockbuf") {
l.md.config.SockBuf = mdutil.GetInt(md, "kcp.sockbuf")
}
if md.IsExists("kcp.smuxver") {
l.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
}
if md.IsExists("kcp.smuxbuf") {
l.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
}
if md.IsExists("kcp.streambuf") {
l.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
}
if md.IsExists("kcp.nocomp") {
l.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
}
if md.IsExists("kcp.datashard") {
l.md.config.DataShard = mdutil.GetInt(md, "kcp.datashard")
}
if md.IsExists("kcp.parityshard") {
l.md.config.ParityShard = mdutil.GetInt(md, "kcp.parityshard")
}
} }
l.md.config.TCP = mdutil.GetBool(md, "kcp.tcp", "tcp")
l.md.config.Key = mdutil.GetString(md, "kcp.key")
l.md.config.Crypt = mdutil.GetString(md, "kcp.crypt")
l.md.config.Mode = mdutil.GetString(md, "kcp.mode")
l.md.config.KeepAlive = mdutil.GetInt(md, "kcp.keepalive")
l.md.config.Interval = mdutil.GetInt(md, "kcp.interval")
l.md.config.MTU = mdutil.GetInt(md, "kcp.mtu")
l.md.config.RcvWnd = mdutil.GetInt(md, "kcp.rcvwnd")
l.md.config.SndWnd = mdutil.GetInt(md, "kcp.sndwnd")
l.md.config.SmuxVer = mdutil.GetInt(md, "kcp.smuxver")
l.md.config.SmuxBuf = mdutil.GetInt(md, "kcp.smuxbuf")
l.md.config.StreamBuf = mdutil.GetInt(md, "kcp.streambuf")
l.md.config.NoComp = mdutil.GetBool(md, "kcp.nocomp")
l.md.backlog = mdutil.GetInt(md, backlog) l.md.backlog = mdutil.GetInt(md, backlog)
if l.md.backlog <= 0 { if l.md.backlog <= 0 {
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
const ( const (
defaultTTL = 30 * time.Second defaultTTL = 5 * time.Second
defaultReadBufferSize = 8192 defaultReadBufferSize = 8192
defaultReadQueueSize = 1024 defaultReadQueueSize = 1024
defaultBacklog = 128 defaultBacklog = 128
+1 -1
View File
@@ -8,7 +8,7 @@ import (
) )
const ( const (
defaultTTL = 30 * time.Second defaultTTL = 5 * time.Second
defaultReadBufferSize = 8192 defaultReadBufferSize = 8192
defaultReadQueueSize = 128 defaultReadQueueSize = 128
defaultBacklog = 128 defaultBacklog = 128
-4
View File
@@ -60,8 +60,6 @@ func GetInt(md metadata.Metadata, keys ...string) (v int) {
} }
case int: case int:
v = vv v = vv
case float64:
v = int(vv)
case string: case string:
v, _ = strconv.Atoi(vv) v, _ = strconv.Atoi(vv)
} }
@@ -107,8 +105,6 @@ func GetDuration(md metadata.Metadata, keys ...string) (v time.Duration) {
switch vv := md.Get(key).(type) { switch vv := md.Get(key).(type) {
case int: case int:
v = time.Duration(vv) * time.Second v = time.Duration(vv) * time.Second
case float64:
v = time.Duration(vv) * time.Second
case string: case string:
v, _ = time.ParseDuration(vv) v, _ = time.ParseDuration(vv)
if v == 0 { if v == 0 {
+4 -4
View File
@@ -81,7 +81,7 @@ func (p *httpPlugin) Register(ctx context.Context, service *sd.Service, opts ...
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s", resp.Status) return fmt.Errorf(resp.Status)
} }
return nil return nil
@@ -119,7 +119,7 @@ func (p *httpPlugin) Deregister(ctx context.Context, service *sd.Service) error
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s", resp.Status) return fmt.Errorf(resp.Status)
} }
return nil return nil
@@ -157,7 +157,7 @@ func (p *httpPlugin) Renew(ctx context.Context, service *sd.Service) error {
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return fmt.Errorf("%s", resp.Status) return fmt.Errorf(resp.Status)
} }
return nil return nil
@@ -189,7 +189,7 @@ func (p *httpPlugin) Get(ctx context.Context, name string) (services []*sd.Servi
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s", resp.Status) return nil, fmt.Errorf(resp.Status)
} }
res := &httpGetResponse{} res := &httpGetResponse{}
+61 -20
View File
@@ -88,45 +88,56 @@ func (m *GlobalTrafficManager) startReporting() {
// collectAndReport 收集所有服务流量并合并上报 // collectAndReport 收集所有服务流量并合并上报
func (m *GlobalTrafficManager) collectAndReport() { func (m *GlobalTrafficManager) collectAndReport() {
m.mu.Lock() m.mu.Lock()
// 如果没有流量,直接返回
if len(m.serviceTraffic) == 0 { if len(m.serviceTraffic) == 0 {
m.mu.Unlock() m.mu.Unlock()
return return
} }
reportItems := make([]TrafficReportItem, 0, len(m.serviceTraffic)) // 复制当前所有流量数据(避免长时间持锁)
trafficSnapshot := make(map[string]*ServiceTraffic)
reportData := make(map[string]struct {
up int64
down int64
})
for name, traffic := range m.serviceTraffic { for name, traffic := range m.serviceTraffic {
traffic.mu.Lock() traffic.mu.Lock()
up := traffic.UpBytes if traffic.UpBytes > 0 || traffic.DownBytes > 0 {
down := traffic.DownBytes trafficSnapshot[name] = traffic
if up > 0 || down > 0 { reportData[name] = struct {
traffic.UpBytes = 0 up int64
traffic.DownBytes = 0 down int64
}{
up: traffic.UpBytes,
down: traffic.DownBytes,
}
} }
traffic.mu.Unlock() traffic.mu.Unlock()
if up > 0 || down > 0 {
reportItems = append(reportItems, TrafficReportItem{
N: name,
U: up,
D: down,
})
}
} }
m.mu.Unlock() m.mu.Unlock()
if len(reportItems) == 0 { // 如果没有需要上报的流量,返回
if len(reportData) == 0 {
return return
} }
// 构建上报数据数组(保持每个服务独立)
reportItems := make([]TrafficReportItem, 0, len(reportData))
var totalUp, totalDown int64 var totalUp, totalDown int64
for _, item := range reportItems {
totalUp += item.U for serviceName, data := range reportData {
totalDown += item.D reportItems = append(reportItems, TrafficReportItem{
N: serviceName, // 保持服务名不变
U: data.up,
D: data.down,
})
totalUp += data.up
totalDown += data.down
} }
// 批量发送上报请求(一次HTTP请求包含所有服务)
success, err := sendBatchTrafficReport(m.ctx, reportItems) success, err := sendBatchTrafficReport(m.ctx, reportItems)
if err != nil { if err != nil {
fmt.Printf("❌ 全局流量上报失败: %v (总流量: ↑%d ↓%d, %d个服务)\n", err, totalUp, totalDown, len(reportItems)) fmt.Printf("❌ 全局流量上报失败: %v (总流量: ↑%d ↓%d, %d个服务)\n", err, totalUp, totalDown, len(reportItems))
@@ -135,6 +146,36 @@ func (m *GlobalTrafficManager) collectAndReport() {
if !success { if !success {
fmt.Printf("⚠️ 全局流量上报未成功 (总流量: ↑%d ↓%d, %d个服务)\n", totalUp, totalDown, len(reportItems)) fmt.Printf("⚠️ 全局流量上报未成功 (总流量: ↑%d ↓%d, %d个服务)\n", totalUp, totalDown, len(reportItems))
return
}
// 上报成功,清空已上报的流量
m.clearReportedTraffic(reportData)
}
// clearReportedTraffic 清空已成功上报的流量
func (m *GlobalTrafficManager) clearReportedTraffic(reportedData map[string]struct {
up int64
down int64
}) {
m.mu.Lock()
defer m.mu.Unlock()
for serviceName, reported := range reportedData {
if traffic, exists := m.serviceTraffic[serviceName]; exists {
traffic.mu.Lock()
// 减去已上报的流量
traffic.UpBytes -= reported.up
traffic.DownBytes -= reported.down
// 如果流量归零,从map中删除该服务记录(避免内存泄漏)
if traffic.UpBytes <= 0 && traffic.DownBytes <= 0 {
traffic.mu.Unlock()
delete(m.serviceTraffic, serviceName)
} else {
traffic.mu.Unlock()
}
}
} }
} }
-68
View File
@@ -102,71 +102,3 @@ type updateLimiterRequest struct {
type deleteLimiterRequest struct { type deleteLimiterRequest struct {
Limiter string `json:"limiter"` Limiter string `json:"limiter"`
} }
func createConnLimiter(req createLimiterRequest) error {
name := strings.TrimSpace(req.Data.Name)
if name == "" {
return errors.New("limiter name is required")
}
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
return errors.New("conn limiter " + name + " already exists")
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func updateConnLimiter(req updateLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
req.Data.Name = name
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
v := parser.ParseConnLimiter(&req.Data)
if err := registry.ConnLimiterRegistry().Register(name, v); err != nil {
return errors.New("conn limiter " + name + " already exists")
}
if c := config.Global(); c != nil {
for i := range c.CLimiters {
if c.CLimiters[i].Name == name {
c.CLimiters[i] = &req.Data
return nil
}
}
c.CLimiters = append(c.CLimiters, &req.Data)
}
return nil
}
func deleteConnLimiter(req deleteLimiterRequest) error {
name := strings.TrimSpace(req.Limiter)
if registry.ConnLimiterRegistry().IsRegistered(name) {
registry.ConnLimiterRegistry().Unregister(name)
}
if c := config.Global(); c != nil {
limiteres := c.CLimiters
c.CLimiters = nil
for _, s := range limiteres {
if s.Name == name {
continue
}
c.CLimiters = append(c.CLimiters, s)
}
}
return nil
}
-208
View File
@@ -836,18 +836,6 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
err = w.handleDeleteLimiter(cmd.Data) err = w.handleDeleteLimiter(cmd.Data)
response.Type = "DeleteLimitersResponse" response.Type = "DeleteLimitersResponse"
needSaveConfig = true needSaveConfig = true
case "AddCLimiters":
err = w.handleAddCLimiter(cmd.Data)
response.Type = "AddCLimitersResponse"
needSaveConfig = true
case "UpdateCLimiters":
err = w.handleUpdateCLimiter(cmd.Data)
response.Type = "UpdateCLimitersResponse"
needSaveConfig = true
case "DeleteCLimiters":
err = w.handleDeleteCLimiter(cmd.Data)
response.Type = "DeleteCLimitersResponse"
needSaveConfig = true
// TCP Ping 诊断命令(只读,不需要保存配置) // TCP Ping 诊断命令(只读,不需要保存配置)
case "TcpPing": case "TcpPing":
@@ -857,13 +845,6 @@ func (w *WebSocketReporter) routeCommand(cmd CommandMessage) {
response.Data = tcpPingResult response.Data = tcpPingResult
// needSaveConfig = false (默认值) // needSaveConfig = false (默认值)
// UDP Ping 诊断命令(只读,不需要保存配置)
case "UdpPing":
var udpPingResult TcpPingResponse
udpPingResult, err = w.handleUdpPing(cmd.Data)
response.Type = "UdpPingResponse"
response.Data = udpPingResult
// Service monitor check (read-only) // Service monitor check (read-only)
case "ServiceMonitorCheck": case "ServiceMonitorCheck":
var checkResult ServiceMonitorCheckResult var checkResult ServiceMonitorCheckResult
@@ -1142,67 +1123,6 @@ func (w *WebSocketReporter) handleDeleteLimiter(data interface{}) error {
return deleteLimiter(deleteReq) return deleteLimiter(deleteReq)
} }
func (w *WebSocketReporter) handleAddCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析限流器配置失败: %v", err)
}
req := createLimiterRequest{Data: limiterConfig}
return createConnLimiter(req)
}
func (w *WebSocketReporter) handleUpdateCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var updateReq struct {
Limiter string `json:"limiter"`
Data config.LimiterConfig `json:"data"`
}
if err := json.Unmarshal(jsonData, &updateReq); err != nil {
var limiterConfig config.LimiterConfig
if err := json.Unmarshal(jsonData, &limiterConfig); err != nil {
return fmt.Errorf("解析更新请求失败: %v", err)
}
updateReq.Limiter = limiterConfig.Name
updateReq.Data = limiterConfig
}
req := updateLimiterRequest{
Limiter: updateReq.Limiter,
Data: updateReq.Data,
}
return updateConnLimiter(req)
}
func (w *WebSocketReporter) handleDeleteCLimiter(data interface{}) error {
jsonData, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("序列化数据失败: %v", err)
}
var deleteReq deleteLimiterRequest
if err := json.Unmarshal(jsonData, &deleteReq); err != nil {
var limiterName string
if err := json.Unmarshal(jsonData, &limiterName); err != nil {
return fmt.Errorf("解析删除请求失败: %v", err)
}
deleteReq.Limiter = limiterName
}
return deleteConnLimiter(deleteReq)
}
// handleSetProtocol 处理设置屏蔽协议的命令 // handleSetProtocol 处理设置屏蔽协议的命令
func (w *WebSocketReporter) handleSetProtocol(data interface{}) error { func (w *WebSocketReporter) handleSetProtocol(data interface{}) error {
jsonData, err := json.Marshal(data) jsonData, err := json.Marshal(data)
@@ -1753,64 +1673,6 @@ func (w *WebSocketReporter) handleTcpPing(data interface{}) (TcpPingResponse, er
return response, nil return response, nil
} }
func (w *WebSocketReporter) handleUdpPing(data interface{}) (TcpPingResponse, error) {
jsonData, err := json.Marshal(data)
if err != nil {
return TcpPingResponse{}, fmt.Errorf("序列化UDP ping数据失败: %v", err)
}
var req TcpPingRequest
if err := json.Unmarshal(jsonData, &req); err != nil {
return TcpPingResponse{}, fmt.Errorf("解析UDP ping请求失败: %v", err)
}
if net.ParseIP(req.IP) == nil && !isValidHostname(req.IP) {
return TcpPingResponse{
IP: req.IP,
Port: req.Port,
Success: false,
ErrorMessage: "无效的IP地址或主机名",
RequestId: req.RequestId,
}, nil
}
if req.Port <= 0 || req.Port > 65535 {
return TcpPingResponse{
IP: req.IP,
Port: req.Port,
Success: false,
ErrorMessage: "无效的端口号,范围应为1-65535",
RequestId: req.RequestId,
}, nil
}
if req.Count <= 0 {
req.Count = 4
}
if req.Timeout <= 0 {
req.Timeout = 5000
}
avgTime, packetLoss, err := udpPingHost(req.IP, req.Port, req.Count, req.Timeout)
response := TcpPingResponse{
IP: req.IP,
Port: req.Port,
RequestId: req.RequestId,
}
if err != nil {
response.Success = false
response.ErrorMessage = err.Error()
} else {
response.Success = true
response.AverageTime = avgTime
response.PacketLoss = packetLoss
}
return response, nil
}
// handleServiceMonitorCheck executes a service monitor check on this node. // handleServiceMonitorCheck executes a service monitor check on this node.
// It always returns a result (command execution is considered successful even if the check fails). // It always returns a result (command execution is considered successful even if the check fails).
func (w *WebSocketReporter) handleServiceMonitorCheck(data interface{}) (ServiceMonitorCheckResult, error) { func (w *WebSocketReporter) handleServiceMonitorCheck(data interface{}) (ServiceMonitorCheckResult, error) {
@@ -2089,76 +1951,6 @@ func tcpPingHost(ip string, port int, count int, timeoutMs int) (float64, float6
return avgTime, packetLoss, nil return avgTime, packetLoss, nil
} }
func udpPingHost(ip string, port int, count int, timeoutMs int) (float64, float64, error) {
var totalTime float64
var successCount int
timeout := time.Duration(timeoutMs) * time.Millisecond
target := net.JoinHostPort(ip, fmt.Sprintf("%d", port))
fmt.Printf("🔍 开始UDP ping测试: %s,次数: %d,超时: %dms\n", target, count, timeoutMs)
if net.ParseIP(ip) == nil {
fmt.Printf("🔍 检测到域名,正在解析DNS...\n")
dnsStart := time.Now()
addrs, err := net.LookupHost(ip)
dnsDuration := time.Since(dnsStart)
if err != nil {
return 0, 100.0, fmt.Errorf("DNS解析失败: %v", err)
}
if len(addrs) == 0 {
return 0, 100.0, fmt.Errorf("DNS解析未返回任何IP地址")
}
fmt.Printf("✅ DNS解析完成 (%.2fms),解析到 %d 个IP: %v\n",
dnsDuration.Seconds()*1000, len(addrs), addrs)
target = net.JoinHostPort(addrs[0], fmt.Sprintf("%d", port))
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
} else {
fmt.Printf("🎯 使用IP地址进行测试: %s\n", target)
}
addr, err := net.ResolveUDPAddr("udp", target)
if err != nil {
return 0, 100.0, fmt.Errorf("解析UDP地址失败: %v", err)
}
for i := 0; i < count; i++ {
start := time.Now()
conn, err := net.DialTimeout("udp", addr.String(), timeout)
elapsed := time.Since(start)
if err != nil {
fmt.Printf(" 第%d次UDP连接失败: %v (%.2fms)\n", i+1, err, elapsed.Seconds()*1000)
} else {
fmt.Printf(" 第%d次UDP连接成功: %.2fms\n", i+1, elapsed.Seconds()*1000)
conn.Close()
totalTime += elapsed.Seconds() * 1000
successCount++
}
if i < count-1 {
time.Sleep(100 * time.Millisecond)
}
}
if successCount == 0 {
return 0, 100.0, fmt.Errorf("所有UDP连接尝试都失败")
}
avgTime := totalTime / float64(successCount)
packetLoss := float64(count-successCount) / float64(count) * 100
fmt.Printf("✅ UDP ping完成: 平均连接时间 %.2fms,失败率 %.1f%%\n", avgTime, packetLoss)
return avgTime, packetLoss, nil
}
// isValidHostname 验证主机名格式 // isValidHostname 验证主机名格式
func isValidHostname(hostname string) bool { func isValidHostname(hostname string) bool {
if len(hostname) == 0 || len(hostname) > 253 { if len(hostname) == 0 || len(hostname) > 253 {
-87
View File
@@ -1,87 +0,0 @@
# 069: UDP 隧道转发断流修复 + KCP 协议支持
## 问题分析
GOST 隧道转发 UDP 断流的根因涉及三个超时层:
### 1. UDP 虚拟连接 TTL 过短(5秒)— 主因
- `go-gost/x/listener/udp/metadata.go:11`: `defaultTTL = 5s`
- `go-gost/x/internal/net/udp/pool.go:79-115`: `idleCheck()` 每 TTL 周期标记空闲连接,下一周期关闭
- **影响**: UDP 流无数据包超过 ~10 秒即被静默关闭
- **后端配置**: `control_plane.go:1607-1613` 设置了 `keepAlive: true`,但仅在 `tunnelTLSProtocol=true` 时设 `ttl: "10s"`,常规隧道转发不设 TTL 覆盖
### 2. Smux Keepalive 未显式配置
- smux `DefaultConfig()`: `KeepAliveInterval=10s`, `KeepAliveTimeout=30s`
- 后端 `buildTunnelChainServiceConfig` 生成 relay handler 时不设置 mux 参数
- 依赖 smux 默认值,但中间 NAT/防火墙可能先于 30s 断开 TCP 连接
### 3. 隧道仅依赖 TCP 传输 — 无 UDP 备选
- 隧道链节点间仅支持 TLS/TCP 传输
- TCP 队头阻塞 + 单连接承载所有流 + 无 UDP 级别的容错
## 修复方案
### Part A: 修复现有问题(低风险,即时生效)
#### A1. 增大 UDP listener 默认 TTL
- **文件**: `go-gost/x/listener/udp/metadata.go`
- **改动**: `defaultTTL = 30s`(从 5s)
- **原因**: 5s 对实际场景太激进,DNS 等长间隔 UDP 应用会被误杀
#### A2. 后端生成 UDP forward 配置时强制设置 TTL
- **文件**: `go-backend/internal/http/handler/control_plane.go`
- **改动**: `buildForwardServiceConfigs` 中始终设置 `listener.metadata.ttl = "30s"`
- **原因**: 确保配置显式覆盖,不依赖 go-gost 默认值
#### A3. 后端隧道链服务配置加入 mux keepalive
- **文件**: `go-backend/internal/http/handler/mutations.go`
- **改动**: `buildTunnelChainServiceConfig` 中的 relay handler 加入 mux metadata:
```go
"mux.keepaliveInterval": "15s",
"mux.keepaliveTimeout": "45s",
```
### Part B: 新增 KCP 协议支持(新功能)
#### B1. 后端支持 `kcp` 作为隧道链传输协议
- **文件**: `go-backend/internal/http/handler/mutations.go`
- **改动**:
- `buildTunnelChainConfig`: 识别 `kcp` 协议,生成对应的 connector/dialer
- `buildTunnelChainServiceConfig`: 识别 `kcp` 协议,生成 KCP listener
- `isTLSTunnelProtocol` → `isTCPTunnelProtocol`(包含 TLS)
- KCP dialer 设置 `kcp.keepalive` 参数
- KCP 配置无需 `nodelay`(那是 TLS 特有)
#### B2. 前端支持选择 `kcp` 协议
- **文件**: `vite-frontend/src/pages/tunnel/` 相关表单
- **改动**: 协议选择器中加入 KCP 选项
#### B3. 确认 go-gost KCP 组件正常
- KCP dialer: `go-gost/x/dialer/kcp/` — 已存在
- KCP listener: `go-gost/x/listener/kcp/` — 已存在
- KCP connector: 使用 `relay` connector 通过 KCP dialer 连接
- 验证 KCP 配置的 metadata 参数传递正确
## 任务清单
- [x] A1: 增大 UDP listener 默认 TTL (5s→30s)
- [x] A2: 后端 forward 配置强制设置 UDP TTL=30s
- [x] A3: 后端隧道链 relay handler 加入 mux keepalive
- [x] B1: 后端支持 kcp 作为隧道链协议
- [x] B2: 前端协议选择器加入 KCP
- [x] B3: 验证 go-gost KCP 组件配置正确
- [x] C1: go-backend 合约测试 (187 passed)
- [x] C2: go-gost 编译验证 (passed)
- [x] C3: vite-frontend 编译验证 (passed)
## 文件变更清单
| 文件 | 变更类型 |
|------|----------|
| `go-gost/x/listener/udp/metadata.go` | 修改 defaultTTL: 5s→30s |
| `go-gost/x/listener/rudp/metadata.go` | 修改 defaultTTL: 5s→30s |
| `go-backend/internal/http/handler/control_plane.go` | buildForwardServiceConfigs: 强制设置 UDP TTL=30s, 移除 tunnelTLSProtocol 参数 |
| `go-backend/internal/http/handler/control_plane_test.go` | 更新测试调用签名 |
| `go-backend/internal/http/handler/mutations.go` | 新增 isTCPTunnelProtocol/isKCPTunnelProtocol/buildTunnelDialerConfig/buildTunnelListenerConfig; 隧道链配置加入 mux keepalive + KCP 支持 |
| `go-backend/internal/http/handler/federation.go` | federation 配置同步加入 mux keepalive + KCP 支持 |
| `vite-frontend/src/pages/tunnel.tsx` | 协议选择器新增 KCP 选项 (3 处)
+1 -1
View File
@@ -82,7 +82,7 @@ python with_server.py --backend-port 8080 --frontend-port 5173 -- pytest -v
# Custom server commands # Custom server commands
python with_server.py \ python with_server.py \
--server "make run" --port 6365 --cwd go-backend \ --server "make run" --port 6365 --cwd go-backend \
--server "pnpm run dev" --port 3000 --cwd vite-frontend \ --server "npm run dev" --port 3000 --cwd vite-frontend \
-- pytest -v -- pytest -v
``` ```
+2 -2
View File
@@ -145,7 +145,7 @@ Examples:
# Custom server configuration # Custom server configuration
python with_server.py \\ python with_server.py \\
--server "make run" --port 6365 --cwd go-backend \\ --server "make run" --port 6365 --cwd go-backend \\
--server "pnpm run dev" --port 3000 --cwd vite-frontend \\ --server "npm run dev" --port 3000 --cwd vite-frontend \\
-- pytest -v -- pytest -v
# Use custom backend port # Use custom backend port
@@ -302,7 +302,7 @@ def build_servers(args) -> list[ServerProcess]:
servers.append( servers.append(
ServerProcess( ServerProcess(
command="pnpm run dev", command="npm run dev",
port=args.frontend_port, port=args.frontend_port,
cwd=root / args.frontend_cwd, cwd=root / args.frontend_cwd,
env=frontend_env, env=frontend_env,
+1 -2
View File
@@ -24,8 +24,7 @@ dist-ssr
*.sw? *.sw?
yarn.lock pnpm-lock.yaml
package-lock.json
yarn.lock yarn.lock
package-lock.json package-lock.json
bun.lockb bun.lockb
+66 -36
View File
@@ -1,40 +1,70 @@
# vite-frontend # VITE FRONTEND KNOWLEDGE BASE
React dashboard for FLVX. rolldown-vite + TypeScript + Tailwind v4 + shadcn/radix. **Generated:** Fri Mar 20 2026
**Commit:** f45f960
**Branch:** main
**Tag:** 2.1.9-beta6
## Structure ## OVERVIEW
Web management console for FLVX.
**Stack:** React 18, rolldown-vite, TypeScript, Tailwind CSS v4, shadcn/radix primitives with HeroUI-compatible bridge.
| Dir/File | Role | ## STRUCTURE
|----------|------| ```
| `src/App.tsx` | Routes + ProtectedRoute + H5 layout selection | vite-frontend/
| `src/main.tsx` | Entry: ReactDOM + BrowserRouter | ├── src/
| `src/api/` | Axios wrapper, sends raw JWT in `Authorization` | │ ├── api/ # Axios wrapper + typed endpoint helpers
| `src/pages/` | Route views (forward, node, tunnel, settings, ...) | │ ├── components/ui/ # shadcn/radix primitive components
| `src/shadcn-bridge/heroui/` | HeroUI-compatible facade — **import from here only** | │ ├── shadcn-bridge/heroui/ # HeroUI-compatible facade (23 components)
| `src/components/ui/` | shadcn/radix primitives | │ ├── pages/ # Route views + page modules (forward/node/tunnel)
| `src/styles/globals.css` | Base styles — **must import `tailwind-theme.pcss`** | │ ├── hooks/ # H5/WebView/mobile hooks
| `src/styles/tailwind-theme.pcss` | Tailwind v4 `@theme inline` semantic tokens | │ ├── styles/
| `vite.config.ts` | host `0.0.0.0:3000`, `minify: false`, `treeshake: false` | │ │ ├── globals.css # Base styles + imports tailwind-theme.pcss
│ │ └── tailwind-theme.pcss # Tailwind v4 @theme inline semantic token mapping
## Conventions │ ├── App.tsx # Routes + ProtectedRoute + H5 layout selection
│ ├── main.tsx # ReactDOM + BrowserRouter + Provider
- **Auth**: raw JWT in `Authorization` header — no `Bearer` prefix. │ └── provider.tsx # Toast/theme/provider composition
- **API envelope**: `{code, msg, data, ts}`, code 0 = success. ├── components.json # shadcn/ui config
- **UI imports**: `src/shadcn-bridge/heroui/*` only, never `@heroui/*` or `@nextui-org/*`. ├── tailwind.config.js # Compatibility config for migration scaffolding
- **Theme**: don't remove `tailwind-theme.pcss` import from `globals.css` — breaks semantic classes (`bg-primary`, `text-foreground`, `border-input`). ├── vite.config.ts # base '/', host 0.0.0.0:3000; minify/treeshake disabled
└── package.json
## Anti-patterns ```
- Don't add `Bearer` prefix to auth header. ## WHERE TO LOOK
- Don't reintroduce `@heroui/*` or `@nextui-org/*` packages. | Task | Location | Notes |
- Don't add frontend tests (no test infrastructure). |------|----------|-------|
- Don't remove `tailwind-theme.pcss` import. | **Route definitions** | `src/App.tsx` | React Router v6 + ProtectedRoute |
| **API Client/Auth header** | `src/api/network.ts` | Sends raw JWT in `Authorization` header |
## Commands | **Login Flow** | `src/pages/index.tsx` | Calls `login()`, stores `localStorage.token` |
| **Auth helpers** | `src/utils/auth.ts`, `src/utils/jwt.ts` | Role checks + token expiration parsing |
```bash | **UI bridge usage** | `src/shadcn-bridge/heroui/` | Import from bridge, not `@heroui/*` |
pnpm install | **Button parity mapping** | `src/shadcn-bridge/heroui/button.tsx` | Legacy `color`/`variant` mapped to shadcn classes |
pnpm run dev # http://0.0.0.0:3000 | **Semantic theme tokens** | `src/styles/tailwind-theme.pcss` | Restores classes like `bg-primary`, `border-input` |
pnpm run build # tsc && vite build | **Theme wiring** | `src/styles/globals.css` | Must import `./tailwind-theme.pcss` |
pnpm run lint # eslint --fix
## CONVENTIONS
- **Auth Header**: Use raw JWT token (no `Bearer` prefix).
- **API Envelope**: Responses follow `{code, msg, data, ts}`.
- **UI Imports**: Use `src/shadcn-bridge/heroui/*` in app pages/layouts for compatibility.
- **Semantic Colors**: Keep `globals.css -> tailwind-theme.pcss` import intact or semantic classes break.
- **Build profile**: `minify: false`, `treeshake: false` for debugging.
- **Layout mode**: H5/mobile mode controlled by existing route/query and hook logic.
## ANTI-PATTERNS
- **DO NOT ADD** `Bearer` to auth header in frontend requests.
- **DO NOT REINTRODUCE** `@heroui/*` or `@nextui-org/*` dependencies.
- **DO NOT REMOVE** `src/styles/tailwind-theme.pcss` import from `src/styles/globals.css`.
- **DO NOT ADD** frontend tests; no Vitest/Jest setup exists.
## NOTES
- Uses `rolldown-vite` (experimental Rust bundler) instead of standard Vite.
- Build outputs are non-minified (debugging mode).
- No test infrastructure exists (Vitest/Jest not configured).
## COMMANDS
```bash
cd vite-frontend
npm run dev
npm run build
npm run lint
``` ```
+3 -3
View File
@@ -3,11 +3,11 @@ FROM node:20.19.0 AS builder
WORKDIR /app WORKDIR /app
COPY package.json pnpm-lock.yaml* ./ COPY package*.json ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile RUN npm install --legacy-peer-deps
COPY . . COPY . .
RUN pnpm run build RUN npm run build
# 生产阶段 # 生产阶段
FROM nginx:stable-alpine AS production-stage FROM nginx:stable-alpine AS production-stage
+4 -4
View File
@@ -23,16 +23,16 @@ git clone https://github.com/frontio-ai/vite-template.git
### Install dependencies ### Install dependencies
You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `pnpm`: You can use one of them `npm`, `yarn`, `pnpm`, `bun`, Example using `npm`:
```bash ```bash
pnpm install npm install
``` ```
### Start development server ### Run the development server
```bash ```bash
pnpm run dev npm run dev
``` ```
### Setup pnpm (optional) ### Setup pnpm (optional)
+1 -2
View File
@@ -52,8 +52,7 @@
"tailwind-merge": "^2.5.5", "tailwind-merge": "^2.5.5",
"tailwind-variants": "1.0.0", "tailwind-variants": "1.0.0",
"tailwindcss": "4.1.11", "tailwindcss": "4.1.11",
"tailwindcss-animate": "^1.0.7", "tailwindcss-animate": "^1.0.7"
"workbox-window": "^7.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/compat": "1.2.8", "@eslint/compat": "1.2.8",
-9002
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 666 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 929 KiB

+22 -55
View File
@@ -1,11 +1,5 @@
import { import { Route, Routes, useLocation, useNavigate } from "react-router-dom";
Route, import { useEffect } from "react";
Routes,
useLocation,
useNavigate,
Navigate,
} from "react-router-dom";
import { useEffect, useState } from "react";
import { AnimatePresence } from "framer-motion"; import { AnimatePresence } from "framer-motion";
import IndexPage from "@/pages/index"; import IndexPage from "@/pages/index";
@@ -21,14 +15,15 @@ import ProfilePage from "@/pages/profile";
import LimitPage from "@/pages/limit"; import LimitPage from "@/pages/limit";
import ConfigPage from "@/pages/config"; import ConfigPage from "@/pages/config";
import PanelSharingPage from "@/pages/panel-sharing"; import PanelSharingPage from "@/pages/panel-sharing";
import { SettingsPage } from "@/pages/settings";
import AdminLayout from "@/layouts/admin"; import AdminLayout from "@/layouts/admin";
import H5Layout from "@/layouts/h5"; import H5Layout from "@/layouts/h5";
import H5SimpleLayout from "@/layouts/h5-simple"; import H5SimpleLayout from "@/layouts/h5-simple";
import { isLoggedIn } from "@/utils/auth"; import { isLoggedIn } from "@/utils/auth";
import { siteConfig, updateSiteConfig } from "@/config/site"; import { siteConfig, updateSiteConfig } from "@/config/site";
import { useH5Mode } from "@/hooks/useH5Mode"; import { useH5Mode } from "@/hooks/useH5Mode";
import { SESSION_UPDATED_EVENT } from "@/utils/session";
// 简化的路由保护组件 - 使用 React Router 导航避免循环
const ProtectedRoute = ({ const ProtectedRoute = ({
children, children,
useSimpleLayout = false, useSimpleLayout = false,
@@ -38,36 +33,23 @@ const ProtectedRoute = ({
useSimpleLayout?: boolean; useSimpleLayout?: boolean;
skipLayout?: boolean; skipLayout?: boolean;
}) => { }) => {
const authenticated = isLoggedIn();
const isH5 = useH5Mode(); const isH5 = useH5Mode();
const navigate = useNavigate(); const navigate = useNavigate();
const [authenticated, setAuthenticated] = useState(() => isLoggedIn());
useEffect(() => {
const handleSessionChange = () => {
const loggedIn = isLoggedIn();
setAuthenticated(loggedIn);
if (!loggedIn) {
navigate("/", { replace: true });
}
};
window.addEventListener(SESSION_UPDATED_EVENT, handleSessionChange);
return () => {
window.removeEventListener(SESSION_UPDATED_EVENT, handleSessionChange);
};
}, [navigate]);
useEffect(() => { useEffect(() => {
if (!authenticated) { if (!authenticated) {
// 使用 React Router 导航,避免无限跳转
navigate("/", { replace: true }); navigate("/", { replace: true });
} }
}, [authenticated, navigate]); }, [authenticated, navigate]);
if (!authenticated) { if (!authenticated) {
return <Navigate replace to="/" />; return (
<div className="flex items-center justify-center min-h-screen bg-mesh-gradient">
<div className="text-lg text-gray-700 dark:text-gray-200" />
</div>
);
} }
// 如果跳过布局,直接返回子组件 // 如果跳过布局,直接返回子组件
@@ -95,7 +77,11 @@ const LoginRoute = () => {
}, [authenticated, navigate]); }, [authenticated, navigate]);
if (authenticated) { if (authenticated) {
return <Navigate replace to="/dashboard" />; return (
<div className="flex items-center justify-center min-h-screen bg-mesh-gradient">
<div className="text-lg text-gray-700 dark:text-gray-200" />
</div>
);
} }
return <IndexPage />; return <IndexPage />;
@@ -108,39 +94,21 @@ function App() {
useEffect(() => { useEffect(() => {
const updateBg = () => { const updateBg = () => {
const customBg = siteConfig.app_bg_image; const customBg = siteConfig.app_bg_image;
if (customBg) { if (customBg) {
if (customBg === "theme") { if (customBg === "theme") {
document.documentElement.style.removeProperty("--custom-bg-image"); document.documentElement.style.removeProperty("--custom-bg-image");
document.documentElement.style.removeProperty("--custom-bg-color"); document.documentElement.style.removeProperty("--custom-bg-color");
document.documentElement.classList.add("has-theme-bg"); document.documentElement.classList.add("has-theme-bg");
document.documentElement.classList.remove("has-custom-bg"); document.documentElement.classList.remove("has-custom-bg");
} else if ( } else if (customBg.startsWith("http") || customBg.startsWith("data:") || customBg.startsWith("/") || customBg.startsWith("blob:")) {
customBg.startsWith("http") || document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`);
customBg.startsWith("data:") || document.documentElement.style.setProperty("--custom-bg-color", "transparent");
customBg.startsWith("/") ||
customBg.startsWith("blob:")
) {
document.documentElement.style.setProperty(
"--custom-bg-image",
`url(${customBg})`,
);
document.documentElement.style.setProperty(
"--custom-bg-color",
"transparent",
);
document.documentElement.classList.add("has-custom-bg"); document.documentElement.classList.add("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg"); document.documentElement.classList.remove("has-theme-bg");
} else { } else {
// Assume solid color like "#ffffff", "white", etc. // Assume solid color like "#ffffff", "white", etc.
document.documentElement.style.setProperty( document.documentElement.style.setProperty("--custom-bg-image", "none");
"--custom-bg-image", document.documentElement.style.setProperty("--custom-bg-color", customBg);
"none",
);
document.documentElement.style.setProperty(
"--custom-bg-color",
customBg,
);
document.documentElement.classList.add("has-custom-bg"); document.documentElement.classList.add("has-custom-bg");
document.documentElement.classList.remove("has-theme-bg"); document.documentElement.classList.remove("has-theme-bg");
} }
@@ -151,10 +119,8 @@ function App() {
document.documentElement.classList.remove("has-theme-bg"); document.documentElement.classList.remove("has-theme-bg");
} }
}; };
updateBg(); updateBg();
window.addEventListener("site-config-updated", updateBg); window.addEventListener("site-config-updated", updateBg);
return () => { return () => {
window.removeEventListener("site-config-updated", updateBg); window.removeEventListener("site-config-updated", updateBg);
}; };
@@ -277,6 +243,7 @@ function App() {
} }
path="/panel-sharing" path="/panel-sharing"
/> />
<Route element={<SettingsPage />} path="/settings" />
</Routes> </Routes>
</AnimatePresence> </AnimatePresence>
); );
+3 -8
View File
@@ -149,12 +149,9 @@ export const deleteTunnelWithForwards = (data: {
data, data,
); );
export const previewBatchTunnelDelete = (ids: number[]) => export const previewBatchTunnelDelete = (ids: number[]) =>
Network.post<TunnelBatchDeletePreviewApiData>( Network.post<TunnelBatchDeletePreviewApiData>("/tunnel/batch-delete-preview", {
"/tunnel/batch-delete-preview", ids,
{ });
ids,
},
);
export const batchDeleteTunnelsWithForwards = (data: { export const batchDeleteTunnelsWithForwards = (data: {
ids: number[]; ids: number[];
action: "replace" | "delete_forwards"; action: "replace" | "delete_forwards";
@@ -499,14 +496,12 @@ export const getServiceMonitorResults = (
options?: { limit?: number; start?: number; end?: number }, options?: { limit?: number; start?: number; end?: number },
) => { ) => {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (options?.start != null && options?.end != null) { if (options?.start != null && options?.end != null) {
params.start = String(options.start); params.start = String(options.start);
params.end = String(options.end); params.end = String(options.end);
} else if (options?.limit != null) { } else if (options?.limit != null) {
params.limit = String(options.limit); params.limit = String(options.limit);
} }
return Network.get<ServiceMonitorResultApiItem[]>( return Network.get<ServiceMonitorResultApiItem[]>(
`/monitor/services/${monitorId}/results`, `/monitor/services/${monitorId}/results`,
params, params,
-4
View File
@@ -20,7 +20,6 @@ export interface UserApiItem {
num: number; num: number;
expTime?: number; expTime?: number;
flowResetTime?: number; flowResetTime?: number;
maxConn?: number;
inFlow?: number; inFlow?: number;
outFlow?: number; outFlow?: number;
dailyQuotaGB?: number; dailyQuotaGB?: number;
@@ -201,7 +200,6 @@ export interface UserPackageInfoApiData {
num: number; num: number;
expTime?: string; expTime?: string;
flowResetTime?: number; flowResetTime?: number;
maxConn?: number;
[key: string]: unknown; [key: string]: unknown;
}; };
tunnelPermissions: UserTunnelPermissionApiItem[]; tunnelPermissions: UserTunnelPermissionApiItem[];
@@ -278,7 +276,6 @@ export interface UserMutationPayload {
num?: number; num?: number;
expTime?: number | string; expTime?: number | string;
flowResetTime?: number; flowResetTime?: number;
maxConn?: number;
dailyQuotaGB?: number; dailyQuotaGB?: number;
monthlyQuotaGB?: number; monthlyQuotaGB?: number;
tunnelFlow?: number; tunnelFlow?: number;
@@ -341,7 +338,6 @@ export interface UserTunnelAssignPayload {
num?: number; num?: number;
expTime?: number; expTime?: number;
flowResetTime?: number; flowResetTime?: number;
maxConn?: number;
status?: number; status?: number;
speedId?: number | null; speedId?: number | null;
tunnels?: Array<{ tunnelId: number; speedId?: number | null }>; tunnels?: Array<{ tunnelId: number; speedId?: number | null }>;
@@ -13,10 +13,10 @@ export const AnimatedPage = ({
className?: string; className?: string;
}) => ( }) => (
<motion.div <motion.div
animate={{ y: 0 }} animate={{ opacity: 1, y: 0 }}
className={className} className={className}
exit={{ y: -8 }} exit={{ opacity: 0, y: -8 }}
initial={{ y: 16 }} initial={{ opacity: 0, y: 16 }}
transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }} transition={{ duration: 0.28, ease: [0.25, 0.46, 0.45, 0.94] }}
> >
{children} {children}
@@ -28,8 +28,9 @@ export const AnimatedPage = ({
* Children should use `staggerItem` as their `variants` prop. * Children should use `staggerItem` as their `variants` prop.
*/ */
export const staggerContainer = { export const staggerContainer = {
hidden: {}, hidden: { opacity: 0 },
show: { show: {
opacity: 1,
transition: { transition: {
staggerChildren: 0.05, staggerChildren: 0.05,
delayChildren: 0.05, delayChildren: 0.05,
@@ -41,8 +42,9 @@ export const staggerContainer = {
* Individual stagger item variant — fade-in + slide-up. * Individual stagger item variant — fade-in + slide-up.
*/ */
export const staggerItem = { export const staggerItem = {
hidden: { y: 12 }, hidden: { opacity: 0, y: 12 },
show: { show: {
opacity: 1,
y: 0, y: 0,
transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] }, transition: { duration: 0.25, ease: [0.25, 0.46, 0.45, 0.94] },
}, },
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -81,7 +81,7 @@ export const Navbar = () => {
<button <button
className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors" className="p-2 text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 transition-colors"
title="面板设置" title="面板设置"
onClick={() => navigate("/config")} onClick={() => navigate("/settings")}
> >
<svg <svg
className="w-5 h-5" className="w-5 h-5"
@@ -7,14 +7,13 @@
* • "Reset to default" option * • "Reset to default" option
*/ */
import type { ThemeMode } from "@/themes/registry";
import React from "react"; import React from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { useThemeContext } from "@/themes/context"; import { useThemeContext } from "@/themes/context";
import type { ThemeMode } from "@/themes/registry";
// ─── Constants ────────────────────────────────────────────────────────────── // ─── Constants ──────────────────────────────────────────────────────────────
@@ -40,14 +39,12 @@ export const ThemeSettings: React.FC = () => {
const handleModeChange = (m: ThemeMode) => { const handleModeChange = (m: ThemeMode) => {
setMode(m); setMode(m);
const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统"; const label = m === "light" ? "亮色" : m === "dark" ? "暗色" : "跟随系统";
toast.success(`已切换为${label}模式`); toast.success(`已切换为${label}模式`);
}; };
const handleThemeSelect = (id: string) => { const handleThemeSelect = (id: string) => {
switchTheme(id); switchTheme(id);
const theme = themes.find((t) => t.id === id); const theme = themes.find((t) => t.id === id);
toast.success(`已切换主题「${theme?.name ?? id}」`); toast.success(`已切换主题「${theme?.name ?? id}」`);
}; };
@@ -128,13 +125,7 @@ export const ThemeSettings: React.FC = () => {
<div className="flex-1" style={{ background: secondary }} /> <div className="flex-1" style={{ background: secondary }} />
<div className="flex-1" style={{ background: success }} /> <div className="flex-1" style={{ background: success }} />
<div className="flex-1" style={{ background: danger }} /> <div className="flex-1" style={{ background: danger }} />
<div <div className="flex-1" style={{ background: bg, borderLeft: "1px solid rgba(0,0,0,0.06)" }} />
className="flex-1"
style={{
background: bg,
borderLeft: "1px solid rgba(0,0,0,0.06)",
}}
/>
</div> </div>
{/* Info */} {/* Info */}
+9 -8
View File
@@ -1,5 +1,4 @@
import * as React from "react"; import * as React from "react";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
function Card({ className, style, ...props }: React.ComponentProps<"div">) { function Card({ className, style, ...props }: React.ComponentProps<"div">) {
@@ -8,18 +7,16 @@ function Card({ className, style, ...props }: React.ComponentProps<"div">) {
className={cn( className={cn(
"relative flex flex-col transition-all duration-300", "relative flex flex-col transition-all duration-300",
"rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]", "rounded-[24px] text-card-foreground shadow-[0_12px_40px_rgba(0,0,0,0.12)] dark:shadow-[0_12px_40px_rgba(0,0,0,0.4)]",
className, className
)} )}
data-slot="card"
style={{ style={{
...style, ...style,
background: background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
"linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)", boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
boxShadow:
"inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 12px 40px rgba(0,0,0,0.12)",
backdropFilter: "blur(24px) saturate(180%)", backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)", WebkitBackdropFilter: "blur(24px) saturate(180%)",
}} }}
data-slot="card"
{...props} {...props}
/> />
); );
@@ -66,7 +63,11 @@ function CardDescription({ className, ...props }: React.ComponentProps<"p">) {
function CardContent({ className, ...props }: React.ComponentProps<"div">) { function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return ( return (
<div className={cn("p-6", className)} data-slot="card-content" {...props} /> <div
className={cn("p-6", className)}
data-slot="card-content"
{...props}
/>
); );
} }
+3 -5
View File
@@ -62,15 +62,13 @@ function DialogContent({
"rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10", "rounded-[24px] text-foreground shadow-[0_20px_60px_rgba(0,0,0,0.2)] dark:shadow-[0_20px_60px_rgba(0,0,0,0.5)] border border-white/80 dark:border-white/10",
className, className,
)} )}
data-slot="dialog-content"
style={{ style={{
background: background: "linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)",
"linear-gradient(135deg, rgba(255,255,255,0.4) 0%, rgba(255,255,255,0.1) 40%, rgba(255,255,255,0.05) 60%, rgba(255,255,255,0.2) 100%)", boxShadow: "inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
boxShadow:
"inset 0 1px 1px rgba(255,255,255,0.8), inset 0 0 0 1px rgba(255,255,255,0.3), inset 0 -1px 1px rgba(0,0,0,0.1), 0 20px 60px rgba(0,0,0,0.2)",
backdropFilter: "blur(24px) saturate(180%)", backdropFilter: "blur(24px) saturate(180%)",
WebkitBackdropFilter: "blur(24px) saturate(180%)", WebkitBackdropFilter: "blur(24px) saturate(180%)",
}} }}
data-slot="dialog-content"
{...props} {...props}
> >
<div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto"> <div className="flex flex-col flex-1 min-h-0 gap-4 p-6 rounded-[inherit] overflow-hidden bg-white/40 dark:bg-zinc-900/40 w-full h-full relative z-10 pointer-events-auto">
@@ -85,7 +85,7 @@ function DropdownMenuSubContent({
return ( return (
<DropdownMenuPrimitive.SubContent <DropdownMenuPrimitive.SubContent
className={cn( className={cn(
"z-50 min-w-32 overflow-y-auto rounded-md border border-default-200 bg-white p-1.5 text-foreground shadow-lg max-h-[--radix-dropdown-menu-content-available-height] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50", "z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className, className,
)} )}
data-slot="dropdown-menu-sub-content" data-slot="dropdown-menu-sub-content"
@@ -103,7 +103,7 @@ function DropdownMenuContent({
<DropdownMenuPrimitive.Portal> <DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content <DropdownMenuPrimitive.Content
className={cn( className={cn(
"z-50 min-w-32 overflow-y-auto rounded-md border border-default-200 bg-white p-1.5 text-foreground shadow-md max-h-[--radix-dropdown-menu-content-available-height] [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none] data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50", "z-50 min-w-32 overflow-hidden rounded-md border border-default-200 bg-white p-1 text-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=top]:slide-in-from-bottom-2 data-[side=bottom]:slide-in-from-top-2 dark:bg-default-50",
className, className,
)} )}
data-slot="dropdown-menu-content" data-slot="dropdown-menu-content"
+2 -4
View File
@@ -32,10 +32,8 @@ const getInitialConfig = () => {
localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const cachedAppBgImage = const cachedAppBgImage =
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || ""; localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
const isCommercial = const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
const hideFooterBrand =
localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true";
if (cachedAppName) { if (cachedAppName) {
return { return {
+10 -14
View File
@@ -47,7 +47,7 @@ export default function AdminLayout({
const [isCollapsed, setIsCollapsed] = useState( const [isCollapsed, setIsCollapsed] = useState(
() => localStorage.getItem("sidebar_collapsed") === "true", () => localStorage.getItem("sidebar_collapsed") === "true",
); );
const [isAdmin, setIsAdmin] = useState(() => getAdminFlag()); const [isAdmin, setIsAdmin] = useState(false);
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null); const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>( const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
null, null,
@@ -198,23 +198,19 @@ export default function AdminLayout({
if (adminFlag) { if (adminFlag) {
setMonitorAllowed(true); setMonitorAllowed(true);
setMonitorAccessReason(null); setMonitorAccessReason(null);
return; return;
} }
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const res = await getMonitorAccess(); const res = await getMonitorAccess();
if (cancelled) return; if (cancelled) return;
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
setMonitorAllowed(Boolean(res.data.allowed)); setMonitorAllowed(Boolean(res.data.allowed));
setMonitorAccessReason( setMonitorAccessReason(
res.data.allowed ? null : res.data.reason || null, res.data.allowed ? null : (res.data.reason || null),
); );
return; return;
} }
// Fail open to preserve legacy navigation behavior. // Fail open to preserve legacy navigation behavior.
@@ -380,7 +376,7 @@ export default function AdminLayout({
${isMobile ? "fixed h-screen top-0 left-0 rounded-r-3xl" : "relative h-full rounded-3xl"} ${isMobile ? "fixed h-screen top-0 left-0 rounded-r-3xl" : "relative h-full rounded-3xl"}
${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"} ${isMobile && !mobileMenuVisible ? "-translate-x-full" : "translate-x-0"}
${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-[260px]"} ${isMobile ? "w-64" : isCollapsed ? "w-20" : "w-[260px]"}
bg-white/70 dark:bg-zinc-900/70 backdrop-blur-3xl bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl
shadow-[0_10px_30px_rgba(0,0,0,0.1)] shadow-[0_10px_30px_rgba(0,0,0,0.1)]
border border-white/80 dark:border-white/10 border border-white/80 dark:border-white/10
z-50 z-50
@@ -402,7 +398,7 @@ export default function AdminLayout({
</div> </div>
{/* 菜单导航 */} {/* 菜单导航 */}
<nav className="flex-1 px-4 overflow-y-auto overflow-x-hidden scrollbar-hide"> <nav className="flex-1 px-4 overflow-y-auto overflow-x-hidden [scrollbar-width:none]">
<ul className="space-y-2"> <ul className="space-y-2">
{filteredMenuItems.map((item) => { {filteredMenuItems.map((item) => {
const isActive = location.pathname === item.path; const isActive = location.pathname === item.path;
@@ -412,7 +408,6 @@ export default function AdminLayout({
return ( return (
<li key={item.path}> <li key={item.path}>
<motion.button <motion.button
aria-disabled={isMonitorBlocked}
className={` className={`
w-full flex items-center p-3 rounded-2xl text-left w-full flex items-center p-3 rounded-2xl text-left
relative min-h-[48px] overflow-hidden transition-colors relative min-h-[48px] overflow-hidden transition-colors
@@ -420,11 +415,12 @@ export default function AdminLayout({
${ ${
isActive isActive
? "text-primary dark:text-primary-400 font-semibold" ? "text-primary dark:text-primary-400 font-semibold"
: isMonitorBlocked : isMonitorBlocked
? "text-gray-500 dark:text-gray-400 font-medium" ? "text-gray-500 dark:text-gray-400 font-medium"
: "text-gray-600 dark:text-gray-300 font-medium" : "text-gray-600 dark:text-gray-300 font-medium"
} }
`} `}
aria-disabled={isMonitorBlocked}
title={ title={
isCollapsed isCollapsed
? isMonitorBlocked ? isMonitorBlocked
@@ -437,7 +433,7 @@ export default function AdminLayout({
> >
{isActive && ( {isActive && (
<motion.div <motion.div
className="absolute inset-0 rounded-2xl bg-white/60 dark:bg-white/10 backdrop-blur-xl shadow-[0_12px_36px_rgba(0,0,0,0.18)] border border-white dark:border-white/20" className="absolute inset-0 rounded-2xl bg-white/80 dark:bg-white/10 shadow-[0_2px_8px_rgba(0,0,0,0.04)] border border-white/50 dark:border-white/5"
layoutId="sidebar-active" layoutId="sidebar-active"
transition={{ transition={{
type: "spring", type: "spring",
@@ -555,7 +551,7 @@ export default function AdminLayout({
)} )}
{/* 主内容 */} {/* 主内容 */}
<main className="flex-1 overflow-y-auto scrollbar-hide"> <main className="flex-1 overflow-y-auto [scrollbar-width:none]">
<AnimatePresence mode="wait"> <AnimatePresence mode="wait">
<motion.div <motion.div
key={location.pathname} key={location.pathname}
+2 -5
View File
@@ -18,7 +18,7 @@ interface TabItem {
export default function H5Layout({ children }: { children: React.ReactNode }) { export default function H5Layout({ children }: { children: React.ReactNode }) {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const [isAdmin, setIsAdmin] = useState(() => getAdminFlag()); const [isAdmin, setIsAdmin] = useState(false);
const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null); const [monitorAllowed, setMonitorAllowed] = useState<boolean | null>(null);
const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>( const [monitorAccessReason, setMonitorAccessReason] = useState<string | null>(
null, null,
@@ -114,18 +114,15 @@ export default function H5Layout({ children }: { children: React.ReactNode }) {
} }
let cancelled = false; let cancelled = false;
(async () => { (async () => {
try { try {
const res = await getMonitorAccess(); const res = await getMonitorAccess();
if (cancelled) return; if (cancelled) return;
if (res.code === 0 && res.data) { if (res.code === 0 && res.data) {
setMonitorAllowed(Boolean(res.data.allowed)); setMonitorAllowed(Boolean(res.data.allowed));
setMonitorAccessReason( setMonitorAccessReason(
res.data.allowed ? null : res.data.reason || null, res.data.allowed ? null : (res.data.reason || null),
); );
return; return;
} }
setMonitorAllowed(true); setMonitorAllowed(true);
+2 -4
View File
@@ -13,9 +13,7 @@ const updateSW = registerSW({
toast( toast(
(t) => ( (t) => (
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<span className="text-sm font-medium text-foreground"> <span className="text-sm font-medium text-foreground">发现新版本,是否立即刷新以应用更新?</span>
发现新版本,是否立即刷新以应用更新?
</span>
<div className="flex gap-2 justify-end"> <div className="flex gap-2 justify-end">
<button <button
className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors" className="px-3 py-1.5 text-xs font-medium bg-primary text-primary-foreground hover:bg-primary/90 rounded-md transition-colors"
@@ -35,7 +33,7 @@ const updateSW = registerSW({
</div> </div>
</div> </div>
), ),
{ duration: Infinity, position: "bottom-right" }, { duration: Infinity, position: "bottom-right" }
); );
}, },
}); });
+38 -86
View File
@@ -89,8 +89,7 @@ const CONFIG_ITEMS: ConfigItem[] = [
{ {
key: "app_bg_image", key: "app_bg_image",
label: "自定义背景", label: "自定义背景",
description: description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
"上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
type: "bg_image", type: "bg_image",
}, },
{ {
@@ -142,8 +141,7 @@ const CONFIG_ITEMS: ConfigItem[] = [
{ {
key: "monitor_tunnel_quality_enabled", key: "monitor_tunnel_quality_enabled",
label: "实时隧道质量检测", label: "实时隧道质量检测",
description: description: "关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
"关闭后,前端停止自动刷新,后端停止实时隧道质量探测(全局配置)",
type: "switch", type: "switch",
}, },
{ {
@@ -390,13 +388,11 @@ export default function ConfigPage() {
const handleActivateLicense = async () => { const handleActivateLicense = async () => {
if (!licenseKeyInput.trim()) { if (!licenseKeyInput.trim()) {
toast.error("请输入有效的商业授权码"); toast.error("请输入有效的商业授权码");
return; return;
} }
setActivatingLicense(true); setActivatingLicense(true);
try { try {
const res = await activateLicense(licenseKeyInput.trim()); const res = await activateLicense(licenseKeyInput.trim());
if (res.code === 0) { if (res.code === 0) {
toast.success("商业版授权激活成功!"); toast.success("商业版授权激活成功!");
setLicenseKeyInput(""); setLicenseKeyInput("");
@@ -483,9 +479,7 @@ export default function ConfigPage() {
if (changedKeys.includes("monitor_tunnel_quality_enabled")) { if (changedKeys.includes("monitor_tunnel_quality_enabled")) {
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("monitorTunnelQualityEnabledChanged", { new CustomEvent("monitorTunnelQualityEnabledChanged", {
detail: { detail: { enabled: configs["monitor_tunnel_quality_enabled"] === "true" },
enabled: configs["monitor_tunnel_quality_enabled"] === "true",
},
}), }),
); );
} }
@@ -502,11 +496,7 @@ export default function ConfigPage() {
// 检查配置项是否应该显示(依赖检查) // 检查配置项是否应该显示(依赖检查)
const shouldShowItem = (item: ConfigItem): boolean => { const shouldShowItem = (item: ConfigItem): boolean => {
// 隐藏商业版专属的设置项,它们会在专门的卡片中展示 // 隐藏商业版专属的设置项,它们会在专门的卡片中展示
if ( if (["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key)) {
["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(
item.key,
)
) {
return false; return false;
} }
@@ -565,16 +555,12 @@ export default function ConfigPage() {
} }
}; };
const handleBgImageUpload = async ( const handleBgImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
e: React.ChangeEvent<HTMLInputElement>,
) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
if (!file) return; if (!file) return;
if (!file.type.startsWith("image/")) { if (!file.type.startsWith("image/")) {
toast.error("只能上传图片文件"); toast.error("只能上传图片文件");
return; return;
} }
@@ -582,10 +568,8 @@ export default function ConfigPage() {
try { try {
const compressedImage = await new Promise<string>((resolve, reject) => { const compressedImage = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
reader.onload = (event) => { reader.onload = (event) => {
const img = new Image(); const img = new Image();
img.onload = () => { img.onload = () => {
const canvas = document.createElement("canvas"); const canvas = document.createElement("canvas");
let width = img.width; let width = img.width;
@@ -608,17 +592,14 @@ export default function ConfigPage() {
canvas.width = width; canvas.width = width;
canvas.height = height; canvas.height = height;
const ctx = canvas.getContext("2d"); const ctx = canvas.getContext("2d");
if (!ctx) { if (!ctx) {
reject(new Error("Canvas context is null")); reject(new Error("Canvas context is null"));
return; return;
} }
ctx.drawImage(img, 0, 0, width, height); ctx.drawImage(img, 0, 0, width, height);
// Output as webp for better compression // Output as webp for better compression
const dataUrl = canvas.toDataURL("image/webp", 0.8); const dataUrl = canvas.toDataURL("image/webp", 0.8);
resolve(dataUrl); resolve(dataUrl);
}; };
img.onerror = () => reject(new Error("图片加载失败")); img.onerror = () => reject(new Error("图片加载失败"));
@@ -640,66 +621,62 @@ export default function ConfigPage() {
const renderBgImageUploader = () => { const renderBgImageUploader = () => {
const bgImage = configs["app_bg_image"] || ""; const bgImage = configs["app_bg_image"] || "";
const isImage = const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || bgImage.startsWith("/") || bgImage.startsWith("blob:");
bgImage.startsWith("http") ||
bgImage.startsWith("data:") ||
bgImage.startsWith("/") ||
bgImage.startsWith("blob:");
const isTheme = bgImage === "theme"; const isTheme = bgImage === "theme";
const isSolidColor = bgImage && !isImage && !isTheme; const isSolidColor = bgImage && !isImage && !isTheme;
return ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-4 w-full">
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<input <input
ref={bgImageFileInputRef}
accept="image/*"
className="hidden"
type="file" type="file"
accept="image/*"
ref={bgImageFileInputRef}
className="hidden"
onChange={handleBgImageUpload} onChange={handleBgImageUpload}
/> />
<Button <Button
color="primary" color="primary"
isLoading={bgImageUploading}
variant="flat" variant="flat"
onPress={() => bgImageFileInputRef.current?.click()} onPress={() => bgImageFileInputRef.current?.click()}
isLoading={bgImageUploading}
> >
上传图片 上传图片
</Button> </Button>
<Button <Button
color="secondary" color="secondary"
isDisabled={bgImageUploading || isTheme}
variant="flat" variant="flat"
onPress={() => handleConfigChange("app_bg_image", "theme")} onPress={() => handleConfigChange("app_bg_image", "theme")}
isDisabled={bgImageUploading || isTheme}
> >
自适应纯色 (跟随深色模式) 自适应纯色 (跟随深色模式)
</Button> </Button>
<Button <Button
color="default" color="default"
isDisabled={bgImageUploading || bgImage === "#ffffff"}
variant="flat" variant="flat"
onPress={() => handleConfigChange("app_bg_image", "#ffffff")} onPress={() => handleConfigChange("app_bg_image", "#ffffff")}
isDisabled={bgImageUploading || bgImage === "#ffffff"}
> >
白色纯色 白色纯色
</Button> </Button>
{bgImage && ( {bgImage && (
<Button <Button
color="danger" color="danger"
isDisabled={bgImageUploading}
variant="flat" variant="flat"
onPress={() => handleConfigChange("app_bg_image", "")} onPress={() => handleConfigChange("app_bg_image", "")}
isDisabled={bgImageUploading}
> >
恢复默认 恢复默认
</Button> </Button>
)} )}
</div> </div>
{bgImage && isImage && ( {bgImage && isImage && (
<div className="relative rounded-xl overflow-hidden border border-divider"> <div className="relative rounded-xl overflow-hidden border border-divider">
<img <img
src={bgImage}
alt="背景预览" alt="背景预览"
className="w-full max-h-48 object-cover opacity-80" className="w-full max-h-48 object-cover opacity-80"
src={bgImage}
/> />
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4"> <div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
<span className="text-white text-sm font-medium">预览效果</span> <span className="text-white text-sm font-medium">预览效果</span>
@@ -709,15 +686,13 @@ export default function ConfigPage() {
{bgImage && isTheme && ( {bgImage && isTheme && (
<div className="relative rounded-xl border border-divider bg-background h-32 flex items-center justify-center"> <div className="relative rounded-xl border border-divider bg-background h-32 flex items-center justify-center">
<span className="text-foreground text-sm font-medium"> <span className="text-foreground text-sm font-medium">当前为自适应纯色背景</span>
当前为自适应纯色背景
</span>
</div> </div>
)} )}
{bgImage && isSolidColor && ( {bgImage && isSolidColor && (
<div <div
className="relative rounded-xl border border-divider h-32 flex items-center justify-center" className="relative rounded-xl border border-divider h-32 flex items-center justify-center"
style={{ backgroundColor: bgImage }} style={{ backgroundColor: bgImage }}
> >
<span className="text-gray-500 bg-white/80 dark:bg-black/80 px-2 py-1 rounded text-sm font-medium border border-gray-200 dark:border-gray-800 shadow-sm"> <span className="text-gray-500 bg-white/80 dark:bg-black/80 px-2 py-1 rounded text-sm font-medium border border-gray-200 dark:border-gray-800 shadow-sm">
@@ -836,8 +811,8 @@ export default function ConfigPage() {
<div className="flex flex-wrap items-center gap-2"> <div className="flex flex-wrap items-center gap-2">
<Button <Button
color="primary" color="primary"
isDisabled={isCommercialDisabled}
isLoading={uploading} isLoading={uploading}
isDisabled={isCommercialDisabled}
size="sm" size="sm"
variant="flat" variant="flat"
onPress={() => triggerBrandFilePicker(key)} onPress={() => triggerBrandFilePicker(key)}
@@ -878,10 +853,7 @@ export default function ConfigPage() {
const renderConfigItem = (item: ConfigItem) => { const renderConfigItem = (item: ConfigItem) => {
const isChanged = const isChanged =
hasChanges && configs[item.key] !== originalConfigs[item.key]; hasChanges && configs[item.key] !== originalConfigs[item.key];
const isCommercialDisabled = const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(
item.key,
) && configs.is_commercial !== "true";
switch (item.type) { switch (item.type) {
case "bg_image": case "bg_image":
@@ -900,15 +872,13 @@ export default function ConfigPage() {
? "border-warning-300 data-[hover=true]:border-warning-400" ? "border-warning-300 data-[hover=true]:border-warning-400"
: "", : "",
}} }}
description={
isCommercialDisabled ? "需商业版授权才能修改此项" : undefined
}
isDisabled={isCommercialDisabled}
placeholder={item.placeholder} placeholder={item.placeholder}
size="md" size="md"
value={configs[item.key] || ""} value={configs[item.key] || ""}
variant="bordered" variant="bordered"
onChange={(e) => handleConfigChange(item.key, e.target.value)} onChange={(e) => handleConfigChange(item.key, e.target.value)}
isDisabled={isCommercialDisabled}
description={isCommercialDisabled ? "需商业版授权才能修改此项" : undefined}
/> />
); );
@@ -918,12 +888,12 @@ export default function ConfigPage() {
classNames={{ classNames={{
wrapper: isChanged ? "border-warning-300" : "", wrapper: isChanged ? "border-warning-300" : "",
}} }}
isDisabled={isCommercialDisabled}
isSelected={configs[item.key] === "true"} isSelected={configs[item.key] === "true"}
size="sm" size="sm"
onValueChange={(checked) => onValueChange={(checked) =>
handleConfigChange(item.key, checked ? "true" : "false") handleConfigChange(item.key, checked ? "true" : "false")
} }
isDisabled={isCommercialDisabled}
> >
<span className="text-sm text-gray-700 dark:text-gray-300"> <span className="text-sm text-gray-700 dark:text-gray-300">
{configs[item.key] === "true" ? "已启用" : "已禁用"} {configs[item.key] === "true" ? "已启用" : "已禁用"}
@@ -1156,8 +1126,7 @@ export default function ConfigPage() {
<div> <div>
<h2 className="text-xl font-semibold">商业版授权</h2> <h2 className="text-xl font-semibold">商业版授权</h2>
<p className="text-sm text-gray-600 dark:text-gray-400"> <p className="text-sm text-gray-600 dark:text-gray-400">
激活商业版授权以解锁自定义品牌功能(替换 激活商业版授权以解锁自定义品牌功能(替换 Logo、应用名称,移除底部版权信息等)
Logo、应用名称,移除底部版权信息等)
</p> </p>
</div> </div>
</div> </div>
@@ -1166,47 +1135,30 @@ export default function ConfigPage() {
<CardBody className="pt-8"> <CardBody className="pt-8">
<div className="flex items-end gap-3 max-w-lg mb-6"> <div className="flex items-end gap-3 max-w-lg mb-6">
<Input <Input
description={
configs.is_commercial === "true"
? configs.license_expiry && configs.license_expiry !== "never"
? `已激活商业版授权 (有效期至: ${new Date(configs.license_expiry).toLocaleDateString()})`
: "已激活商业版授权 (永久有效)"
: "需商业授权才能修改站名、图标并隐藏页脚品牌"
}
isDisabled={configs.is_commercial === "true"}
label="授权激活码" label="授权激活码"
placeholder="请输入 FLVX- 开头的商业授权码" placeholder="请输入 FLVX- 开头的商业授权码"
value={licenseKeyInput} value={licenseKeyInput}
variant="bordered" variant="bordered"
onChange={(e) => setLicenseKeyInput(e.target.value)} 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 <Button
className="mb-6"
color="primary" color="primary"
isDisabled={ className="mb-6"
configs.is_commercial === "true" || !licenseKeyInput.trim() isDisabled={configs.is_commercial === "true" || !licenseKeyInput.trim()}
}
isLoading={activatingLicense} isLoading={activatingLicense}
onPress={handleActivateLicense} onPress={handleActivateLicense}
> >
{configs.is_commercial === "true" ? "已授权" : "激活授权"} {configs.is_commercial === "true" ? "已授权" : "激活授权"}
</Button> </Button>
</div> </div>
{configs.is_commercial === "true" && ( {configs.is_commercial === "true" && (
<div className="space-y-6"> <div className="space-y-6">
<Divider className="my-2" /> <Divider className="my-2" />
<h3 className="text-md font-medium text-default-700"> <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) => (
</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 key={item.key}>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]"> <div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_2fr]">
<div className="space-y-1"> <div className="space-y-1">
@@ -1559,18 +1511,18 @@ export default function ConfigPage() {
<AnimatePresence> <AnimatePresence>
{hasChanges && ( {hasChanges && (
<motion.div <motion.div
animate={{ y: 0, opacity: 1 }}
className="fixed bottom-6 right-6 z-50"
exit={{ y: 100, opacity: 0 }}
initial={{ y: 100, opacity: 0 }} initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
transition={{ type: "spring", damping: 20, stiffness: 300 }} transition={{ type: "spring", damping: 20, stiffness: 300 }}
className="fixed bottom-6 right-6 z-50"
> >
<Button <Button
isIconOnly isIconOnly
className="w-12 h-12 rounded-full shadow-lg"
color="primary" color="primary"
isLoading={saving}
size="lg" size="lg"
className="w-12 h-12 rounded-full shadow-lg"
isLoading={saving}
onPress={handleSave} onPress={handleSave}
> >
{!saving && <SaveIcon className="w-5 h-5" />} {!saving && <SaveIcon className="w-5 h-5" />}
+10 -39
View File
@@ -651,44 +651,25 @@ export default function DashboardPage() {
{/* 顶部个人状态和下拉菜单 */} {/* 顶部个人状态和下拉菜单 */}
<div className="flex justify-between items-center mb-6"> <div className="flex justify-between items-center mb-6">
<div> <div>
<h1 className="text-2xl lg:text-3xl font-bold text-foreground"> <h1 className="text-2xl lg:text-3xl font-bold text-foreground">Good morning, {username}</h1>
Good morning, {username} <p className="text-sm text-default-500 mt-1">Here's what's happening with your network today.</p>
</h1>
<p className="text-sm text-default-500 mt-1">
Here&apos;s what&apos;s happening with your network today.
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Dropdown placement="bottom-end"> <Dropdown placement="bottom-end">
<DropdownTrigger> <DropdownTrigger>
<Button <Button
isIconOnly
className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]" className="rounded-full w-10 h-10 min-w-0 bg-primary text-white font-bold text-sm shadow-[0_4px_12px_rgba(0,122,255,0.3)]"
isIconOnly
variant="solid" variant="solid"
> >
{username.slice(0, 2).toUpperCase()} {username.slice(0, 2).toUpperCase()}
</Button> </Button>
</DropdownTrigger> </DropdownTrigger>
<DropdownMenu <DropdownMenu aria-label="用户菜单" className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]">
aria-label="用户菜单"
className="bg-white/80 dark:bg-zinc-900/80 backdrop-blur-xl border border-white/50 dark:border-white/10 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)]"
>
<DropdownItem <DropdownItem
key="profile" key="profile"
startContent={ startContent={
<svg <svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" /></svg>
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
/>
</svg>
} }
onPress={() => navigate("/profile")} onPress={() => navigate("/profile")}
> >
@@ -699,17 +680,7 @@ export default function DashboardPage() {
className="text-danger" className="text-danger"
color="danger" color="danger"
startContent={ startContent={
<svg <svg className="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fillRule="evenodd" d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z" clipRule="evenodd" /></svg>
className="w-4 h-4"
fill="currentColor"
viewBox="0 0 20 20"
>
<path
clipRule="evenodd"
d="M3 3a1 1 0 00-1 1v12a1 1 0 102 0V4a1 1 0 00-1-1zm10.293 9.293a1 1 0 001.414 1.414l3-3a1 1 0 000-1.414l-3-3a1 1 0 10-1.414 1.414L14.586 9H7a1 1 0 100 2h7.586l-1.293 1.293z"
fillRule="evenodd"
/>
</svg>
} }
onPress={handleLogout} onPress={handleLogout}
> >
@@ -1096,7 +1067,7 @@ export default function DashboardPage() {
{group.forwards.map((forward) => ( {group.forwards.map((forward) => (
<div <div
key={forward.id} key={forward.id}
className="bg-white/10 dark:bg-black/10 backdrop-blur-md border border-white/20 dark:border-white/10 rounded-lg p-3 shadow-sm hover:shadow-md transition-all duration-300" className="bg-white dark:bg-default-100/50 border border-gray-200 dark:border-default-200 rounded-lg p-3 hover:shadow-md transition-shadow"
> >
<div className="space-y-3"> <div className="space-y-3">
<div> <div>
@@ -1105,7 +1076,7 @@ export default function DashboardPage() {
</h4> </h4>
<div className="space-y-1"> <div className="space-y-1">
<button <button
className={`block px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded-lg border border-white/20 dark:border-white/10 font-mono text-xs truncate text-foreground transition-all duration-300 ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-white/30 dark:hover:bg-white/10" : ""}`} className={`block px-2 py-1 bg-default-50/50 dark:bg-default-100/20 backdrop-blur-md rounded-lg border border-default-200/50 dark:border-default-700/50 font-mono text-xs truncate text-foreground transition-colors duration-200 ${hasMultipleIps(forward.inIp) ? "cursor-pointer hover:bg-default-100 dark:hover:bg-default-200/50" : ""}`}
disabled={!hasMultipleIps(forward.inIp)} disabled={!hasMultipleIps(forward.inIp)}
title={formatInAddress( title={formatInAddress(
forward.inIp, forward.inIp,
@@ -1126,7 +1097,7 @@ export default function DashboardPage() {
↓ ↓
</div> </div>
<button <button
className={`block px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded-lg border border-white/20 dark:border-white/10 font-mono text-xs truncate text-foreground transition-all duration-300 ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-white/30 dark:hover:bg-white/10" : ""}`} className={`block px-2 py-1 bg-default-50/50 dark:bg-default-100/20 backdrop-blur-md rounded-lg border border-default-200/50 dark:border-default-700/50 font-mono text-xs truncate text-foreground transition-colors duration-200 ${hasMultipleRemoteAddresses(forward.remoteAddr) ? "cursor-pointer hover:bg-default-100 dark:hover:bg-default-200/50" : ""}`}
disabled={ disabled={
!hasMultipleRemoteAddresses( !hasMultipleRemoteAddresses(
forward.remoteAddr, forward.remoteAddr,
@@ -1205,7 +1176,7 @@ export default function DashboardPage() {
</Button> </Button>
</div> </div>
<div className="space-y-2 max-h-60 overflow-y-auto scrollbar-hide"> <div className="space-y-2 max-h-60 overflow-y-auto">
{addressList.map((item) => ( {addressList.map((item) => (
<div <div
key={item.id} key={item.id}
@@ -1,8 +1,4 @@
import type { AnnouncementData } from "@/api"; import type { AnnouncementData } from "@/api";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { import {
Modal, Modal,
@@ -11,6 +7,8 @@ import {
ModalFooter, ModalFooter,
ModalHeader, ModalHeader,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
interface AnnouncementModalProps { interface AnnouncementModalProps {
announcement: AnnouncementData; announcement: AnnouncementData;
@@ -26,11 +24,7 @@ export const AnnouncementModal = ({
onDontShowAgain, onDontShowAgain,
}: AnnouncementModalProps) => { }: AnnouncementModalProps) => {
return ( return (
<Modal <Modal isOpen={isOpen} onOpenChange={(open) => !open && onClose()} size="2xl">
isOpen={isOpen}
size="2xl"
onOpenChange={(open) => !open && onClose()}
>
<ModalContent> <ModalContent>
<ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader> <ModalHeader className="flex flex-col gap-1">平台公告</ModalHeader>
<ModalBody> <ModalBody>
@@ -50,10 +50,7 @@ export const FlowChartCard = ({
) : ( ) : (
<div className="h-64 lg:h-80 w-full"> <div className="h-64 lg:h-80 w-full">
<ResponsiveContainer height="100%" width="100%"> <ResponsiveContainer height="100%" width="100%">
<LineChart <LineChart data={chartData} margin={{ top: 20, right: 0, left: -20, bottom: 0 }}>
data={chartData}
margin={{ top: 20, right: 0, left: -20, bottom: 0 }}
>
<XAxis <XAxis
axisLine={false} axisLine={false}
dataKey="time" dataKey="time"
@@ -98,7 +95,7 @@ export const FlowChartCard = ({
: 0; : 0;
return ( return (
<div className="bg-white/20 dark:bg-black/20 backdrop-blur-3xl border border-white/50 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-4"> <div className="bg-white/70 dark:bg-zinc-900/70 backdrop-blur-xl border border-white/50 rounded-2xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-4">
<p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p> <p className="font-medium text-foreground">{`时间: ${label ?? ""}`}</p>
<p className="text-primary font-semibold mt-1">{`流量: ${formatFlow(flowValue)}`}</p> <p className="text-primary font-semibold mt-1">{`流量: ${formatFlow(flowValue)}`}</p>
</div> </div>
@@ -109,7 +106,11 @@ export const FlowChartCard = ({
}} }}
cursor={{ fill: "rgba(0, 122, 255, 0.05)" }} cursor={{ fill: "rgba(0, 122, 255, 0.05)" }}
/> />
<Line dataKey="flow" stroke="#007aff" type="monotone" /> <Line
dataKey="flow"
stroke="#007aff"
type="monotone"
/>
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -25,7 +25,9 @@ export const MetricCard = ({
<p className="text-sm font-medium text-default-600 truncate"> <p className="text-sm font-medium text-default-600 truncate">
{title} {title}
</p> </p>
<div className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}> <div
className={`p-2 rounded-xl flex-shrink-0 ${iconClassName}`}
>
{icon} {icon}
</div> </div>
</div> </div>
@@ -33,7 +35,9 @@ export const MetricCard = ({
{value} {value}
</p> </p>
</div> </div>
<div className="mt-auto pt-4">{bottomContent}</div> <div className="mt-auto pt-4">
{bottomContent}
</div>
</CardBody> </CardBody>
</Card> </Card>
); );
@@ -349,22 +349,17 @@ export const useDashboardData = (): DashboardDataState => {
if (res.code === 0 && res.data && res.data.enabled === 1) { if (res.code === 0 && res.data && res.data.enabled === 1) {
setAnnouncement(res.data); setAnnouncement(res.data);
try { try {
const storedTimeStr = localStorage.getItem( const storedTimeStr = localStorage.getItem("flvx_announcement_seen_time");
"flvx_announcement_seen_time",
);
const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0; const storedTime = storedTimeStr ? parseInt(storedTimeStr, 10) : 0;
const updateTime = res.data.update_time || 0; const updateTime = res.data.update_time || 0;
if (updateTime > storedTime) { if (updateTime > storedTime) {
setIsAnnouncementModalOpen(true); setIsAnnouncementModalOpen(true);
} }
} catch (err) { } catch (err) {
console.warn( console.warn("Failed to read localStorage for announcement state", err);
"Failed to read localStorage for announcement state",
err,
);
setIsAnnouncementModalOpen(true); setIsAnnouncementModalOpen(true);
} }
} else { } else {
@@ -381,10 +376,7 @@ export const useDashboardData = (): DashboardDataState => {
setIsAnnouncementModalOpen(false); setIsAnnouncementModalOpen(false);
if (announcement && announcement.update_time) { if (announcement && announcement.update_time) {
try { try {
localStorage.setItem( localStorage.setItem("flvx_announcement_seen_time", announcement.update_time.toString());
"flvx_announcement_seen_time",
announcement.update_time.toString(),
);
} catch (err) { } catch (err) {
console.warn("Failed to set localStorage for announcement state", err); console.warn("Failed to set localStorage for announcement state", err);
} }
+54 -88
View File
@@ -54,7 +54,6 @@ import { Switch } from "@/shadcn-bridge/heroui/switch";
import { Alert } from "@/shadcn-bridge/heroui/alert"; import { Alert } from "@/shadcn-bridge/heroui/alert";
import { Progress } from "@/shadcn-bridge/heroui/progress"; import { Progress } from "@/shadcn-bridge/heroui/progress";
import { Checkbox } from "@/shadcn-bridge/heroui/checkbox"; import { Checkbox } from "@/shadcn-bridge/heroui/checkbox";
import { Accordion, AccordionItem } from "@/shadcn-bridge/heroui/accordion";
import { import {
createForward, createForward,
getForwardList, getForwardList,
@@ -119,7 +118,6 @@ interface Forward {
outFlow: number; outFlow: number;
serviceRunning: boolean; serviceRunning: boolean;
federationShareFlow?: number; federationShareFlow?: number;
maxConn?: number;
createdTime: string; createdTime: string;
userName?: string; userName?: string;
userId?: number; userId?: number;
@@ -159,7 +157,6 @@ interface ForwardForm {
interfaceName?: string; interfaceName?: string;
strategy: string; strategy: string;
speedId: number | null; speedId: number | null;
maxConn?: number;
} }
interface ForwardUserGroup { interface ForwardUserGroup {
@@ -1309,7 +1306,6 @@ export default function ForwardPage() {
interfaceName: "", interfaceName: "",
strategy: "fifo", strategy: "fifo",
speedId: null, speedId: null,
maxConn: 0,
}); });
const [inIpTouched, setInIpTouched] = useState(false); const [inIpTouched, setInIpTouched] = useState(false);
@@ -2117,7 +2113,6 @@ export default function ForwardPage() {
interfaceName: forward.interfaceName || "", interfaceName: forward.interfaceName || "",
strategy: forward.strategy || "fifo", strategy: forward.strategy || "fifo",
speedId: normalizeSpeedId(forward.speedId), speedId: normalizeSpeedId(forward.speedId),
maxConn: forward.maxConn || 0,
}); });
setErrors({}); setErrors({});
setModalOpen(true); setModalOpen(true);
@@ -2246,7 +2241,6 @@ export default function ForwardPage() {
remoteAddr: processedRemoteAddr, remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo", strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizedSpeedId, speedId: normalizedSpeedId,
maxConn: form.maxConn,
}; };
res = await updateForward(updateData); res = await updateForward(updateData);
@@ -2259,7 +2253,6 @@ export default function ForwardPage() {
remoteAddr: processedRemoteAddr, remoteAddr: processedRemoteAddr,
strategy: addressCount > 1 ? form.strategy : "fifo", strategy: addressCount > 1 ? form.strategy : "fifo",
speedId: normalizedSpeedId, speedId: normalizedSpeedId,
maxConn: form.maxConn,
}; };
res = await createForward(createData); res = await createForward(createData);
@@ -3696,7 +3689,7 @@ export default function ForwardPage() {
return ( return (
<Card <Card
key={forward.id} key={forward.id}
className="group h-full flex flex-col overflow-hidden bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]" className="group h-full flex flex-col overflow-hidden"
> >
<CardHeader className="pb-2 md:pb-2"> <CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full"> <div className="flex justify-between items-start w-full">
@@ -3753,7 +3746,7 @@ export default function ForwardPage() {
{/* 地址信息 */} {/* 地址信息 */}
<div className="space-y-1"> <div className="space-y-1">
<button <button
className={`cursor-pointer px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${ className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.inIp) hasMultipleAddresses(forward.inIp)
? "hover:bg-default-100 dark:hover:bg-default-200/50" ? "hover:bg-default-100 dark:hover:bg-default-200/50"
: "" : ""
@@ -3793,7 +3786,7 @@ export default function ForwardPage() {
</button> </button>
<button <button
className={`cursor-pointer px-2 py-1 bg-white/20 dark:bg-black/20 backdrop-blur-md rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${ className={`cursor-pointer px-2 py-1 bg-default-50 dark:bg-default-100/50 rounded border border-default-200 dark:border-default-300 transition-colors duration-200 ${
hasMultipleAddresses(forward.remoteAddr) hasMultipleAddresses(forward.remoteAddr)
? "hover:bg-default-100 dark:hover:bg-default-200/50" ? "hover:bg-default-100 dark:hover:bg-default-200/50"
: "" : ""
@@ -4183,7 +4176,7 @@ export default function ForwardPage() {
{sortedForwards.length} 条规则 {sortedForwards.length} 条规则
</span> </span>
</div> </div>
<Card className="rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"> <Card className="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]">
<DndContext <DndContext
collisionDetection={pointerWithin} collisionDetection={pointerWithin}
sensors={sensors} sensors={sensors}
@@ -4195,10 +4188,8 @@ export default function ForwardPage() {
> >
<Table <Table
aria-label="全部规则列表" aria-label="全部规则列表"
className="table-fixed min-w-[1160px]"
classNames={{ classNames={{
wrapper: wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]",
th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", th: "bg-default-100/50 text-default-600 font-semibold text-sm border-b border-divider py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors group", tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors group",
@@ -4265,7 +4256,7 @@ export default function ForwardPage() {
</Card> </Card>
</> </>
) : ( ) : (
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]"> <Card>
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]"> <CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2"> <h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无规则配置 暂无规则配置
@@ -4311,7 +4302,7 @@ export default function ForwardPage() {
</DndContext> </DndContext>
</> </>
) : ( ) : (
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]"> <Card>
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]"> <CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2"> <h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无规则配置 暂无规则配置
@@ -4336,7 +4327,7 @@ export default function ForwardPage() {
return ( return (
<div <div
key={`grouped-table-${group.userId}-${group.userName}`} key={`grouped-table-${group.userId}-${group.userName}`}
className="rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]" className="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
> >
<div className="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-5 py-4"> <div className="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-5 py-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -4427,7 +4418,7 @@ export default function ForwardPage() {
headerClassName="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-4 py-2.5" headerClassName="flex items-center justify-between border-b border-white/20 dark:border-white/10 bg-white/20 dark:bg-black/20 backdrop-blur-3xl px-4 py-2.5"
titleClassName="truncate text-sm font-semibold text-default-700" titleClassName="truncate text-sm font-semibold text-default-700"
tunnel={tunnel} tunnel={tunnel}
wrapperClassName="rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]" wrapperClassName="overflow-hidden rounded-2xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_15px_35px_rgba(0,0,0,0.1)]"
onToggleCollapsed={() => onToggleCollapsed={() =>
toggleTunnelGroupCollapsed( toggleTunnelGroupCollapsed(
group.userId, group.userId,
@@ -4448,8 +4439,7 @@ export default function ForwardPage() {
aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`} aria-label={`${group.userName}-${tunnel.tunnelName}规则列表`}
className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`} className={`table-fixed ${FORWARD_GROUPED_TABLE_MIN_WIDTH_CLASS}`}
classNames={{ classNames={{
wrapper: wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors", tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
@@ -4572,7 +4562,7 @@ export default function ForwardPage() {
})} })}
</div> </div>
) : ( ) : (
<Card className="bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl border border-white/80 dark:border-white/10 shadow-[0_15px_35px_rgba(0,0,0,0.1)]"> <Card>
<CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]"> <CardBody className="text-center py-20 flex flex-col items-center justify-center min-h-[240px]">
<h3 className="text-xl font-medium text-foreground tracking-tight mb-2"> <h3 className="text-xl font-medium text-foreground tracking-tight mb-2">
暂无规则配置 暂无规则配置
@@ -4742,7 +4732,37 @@ export default function ForwardPage() {
} }
/> />
{isAdmin && (
<Select
label="规则限速"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setForm((prev) => ({
...prev,
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
)}
<Select <Select
description={ description={
@@ -4871,61 +4891,7 @@ export default function ForwardPage() {
<SelectItem key="hash">哈希模式 - IP哈希</SelectItem> <SelectItem key="hash">哈希模式 - IP哈希</SelectItem>
</Select> </Select>
)} )}
<Accordion className="px-0" variant="light"> </div>
<AccordionItem
key="advanced"
aria-label="高级设置"
title={<span className="text-small text-default-500 font-medium">高级设置</span>}
>
<div className="space-y-4 pb-2">
<Input
label="最大连接数"
placeholder="0 或空表示不限制"
type="number"
min="0"
value={form.maxConn === 0 ? "" : String(form.maxConn || "")}
onChange={(e) => {
const value = Math.max(Number(e.target.value) || 0, 0);
setForm((prev) => ({ ...prev, maxConn: value }));
}}
description="此设置优先于用户的全局连接数限制。0 表示不限制。"
variant="bordered"
/>
{isAdmin && (
<Select
label="规则限速"
placeholder="不限速"
selectedKeys={
selectedSpeedId !== null
? [selectedSpeedId.toString()]
: []
}
variant="bordered"
onSelectionChange={(keys) => {
const selectedKey = Array.from(keys)[0] as
| string
| undefined;
setForm((prev) => ({
...prev,
speedId: selectedKey ? Number(selectedKey) : null,
}));
}}
>
{availableSpeedLimits.map((speedLimit) => (
<SelectItem
key={speedLimit.id.toString()}
textValue={speedLimit.name}
>
{speedLimit.name}
</SelectItem>
))}
</Select>
)}
</div>
</AccordionItem>
</Accordion>
</div>
</ModalBody> </ModalBody>
<ModalFooter> <ModalFooter>
<Button variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
@@ -5010,7 +4976,7 @@ export default function ForwardPage() {
</Button> </Button>
</div> </div>
<div className="space-y-2 max-h-60 overflow-y-auto scrollbar-hide"> <div className="space-y-2 max-h-60 overflow-y-auto">
{addressList.map((item) => ( {addressList.map((item) => (
<div <div
key={item.id} key={item.id}
@@ -5315,7 +5281,7 @@ export default function ForwardPage() {
</div> </div>
<div <div
className="max-h-40 overflow-y-auto space-y-1 scrollbar-hide" className="max-h-40 overflow-y-auto space-y-1"
style={{ style={{
scrollbarWidth: "thin", scrollbarWidth: "thin",
scrollbarColor: "rgb(156 163 175) transparent", scrollbarColor: "rgb(156 163 175) transparent",
@@ -5414,7 +5380,7 @@ export default function ForwardPage() {
<Modal <Modal
backdrop="blur" backdrop="blur"
classNames={{ classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden [&>div]:bg-content1 [&>div]:dark:bg-content1", base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}} }}
isOpen={diagnosisModalOpen} isOpen={diagnosisModalOpen}
placement="center" placement="center"
@@ -5432,7 +5398,7 @@ export default function ForwardPage() {
<ModalContent> <ModalContent>
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader className="flex flex-col gap-1 border-b border-divider"> <ModalHeader className="flex flex-col gap-1 bg-content1 border-b border-divider">
<h2 className="text-xl font-bold">规则诊断结果</h2> <h2 className="text-xl font-bold">规则诊断结果</h2>
{currentDiagnosisForward && ( {currentDiagnosisForward && (
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
@@ -5481,7 +5447,7 @@ export default function ForwardPage() {
{/* 统计摘要 */} {/* 统计摘要 */}
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-3 gap-3">
<div className="text-center p-3 bg-default-100 rounded-lg border border-divider"> <div className="text-center p-3 bg-default-100 dark:bg-gray-800 rounded-lg border border-divider">
<div className="text-2xl font-bold text-foreground"> <div className="text-2xl font-bold text-foreground">
{diagnosisProgress.total > 0 {diagnosisProgress.total > 0
? diagnosisProgress.total ? diagnosisProgress.total
@@ -5553,7 +5519,7 @@ export default function ForwardPage() {
return ( return (
<div <div
key={title} key={title}
className="border border-divider rounded-lg overflow-hidden" className="border border-divider rounded-lg overflow-hidden bg-white dark:bg-gray-800"
> >
<div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider"> <div className="bg-primary/10 dark:bg-primary/20 px-3 py-2 border-b border-divider">
<h3 className="text-sm font-semibold text-primary"> <h3 className="text-sm font-semibold text-primary">
@@ -5561,7 +5527,7 @@ export default function ForwardPage() {
</h3> </h3>
</div> </div>
<table className="w-full text-sm"> <table className="w-full text-sm">
<thead className="bg-default-100"> <thead className="bg-default-100 dark:bg-gray-700">
<tr> <tr>
<th className="px-3 py-2 text-left font-semibold text-xs"> <th className="px-3 py-2 text-left font-semibold text-xs">
路径 路径
@@ -5580,7 +5546,7 @@ export default function ForwardPage() {
</th> </th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-divider bg-content1"> <tbody className="divide-y divide-divider bg-white dark:bg-gray-800">
{results.map((result, index) => { {results.map((result, index) => {
const isDiagnosing = Boolean( const isDiagnosing = Boolean(
result.diagnosing, result.diagnosing,
@@ -5599,7 +5565,7 @@ export default function ForwardPage() {
isDiagnosing isDiagnosing
? "bg-warning-50 dark:bg-warning-900/20" ? "bg-warning-50 dark:bg-warning-900/20"
: isSuccess : isSuccess
? "bg-content1" ? "bg-white dark:bg-gray-800"
: "bg-danger-50 dark:bg-danger-900/30" : "bg-danger-50 dark:bg-danger-900/30"
}`} }`}
> >
@@ -5786,7 +5752,7 @@ export default function ForwardPage() {
isDiagnosing isDiagnosing
? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20" ? "border-warning-200 dark:border-warning-300/30 bg-warning-50 dark:bg-warning-900/20"
: isSuccess : isSuccess
? "border-divider bg-content1" ? "border-divider bg-white dark:bg-gray-800"
: "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30" : "border-danger-200 dark:border-danger-300/30 bg-danger-50 dark:bg-danger-900/30"
}`} }`}
> >
@@ -5974,7 +5940,7 @@ export default function ForwardPage() {
</div> </div>
)} )}
</ModalBody> </ModalBody>
<ModalFooter className="border-t border-divider"> <ModalFooter className="bg-transparent border-t border-white/20 dark:border-white/10">
<Button variant="light" onPress={onClose}> <Button variant="light" onPress={onClose}>
关闭 关闭
</Button> </Button>
+454 -458
View File
@@ -1,8 +1,6 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { AnimatedPage } from "@/components/animated-page";
import { PageLoadingState } from "@/components/page-state";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Input } from "@/shadcn-bridge/heroui/input"; import { Input } from "@/shadcn-bridge/heroui/input";
@@ -24,6 +22,7 @@ import {
TableRow, TableRow,
} from "@/shadcn-bridge/heroui/table"; } from "@/shadcn-bridge/heroui/table";
import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { Spinner } from "@/shadcn-bridge/heroui/spinner";
import { import {
assignGroupPermission, assignGroupPermission,
assignTunnelsToGroup, assignTunnelsToGroup,
@@ -470,462 +469,459 @@ export default function GroupPage() {
} }
return ( return (
<AnimatedPage className="px-3 lg:px-6 py-8 space-y-6"> <div className="px-3 lg:px-6 py-8 space-y-6">
{loading ? ( {loading && (
<PageLoadingState message="正在加载..." /> <div className="flex justify-center py-10">
) : ( <Spinner size="lg" />
<> </div>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="隧道分组列表"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.tunnelNames.length > 0
? item.tunnelNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignTunnels(item)}
>
分配隧道
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditTunnelGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteTunnelGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="用户分组列表"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无用户分组" items={userGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.userNames.length > 0
? item.userNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignUsers(item)}
>
分配用户
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditUserGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteUserGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
<Select
items={userGroups}
label="用户分组"
selectedKeys={
selectedUserGroupId ? [String(selectedUserGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedUserGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Select
items={tunnelGroups}
label="隧道分组"
selectedKeys={
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedTunnelGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
color="primary"
isLoading={savingPermission}
size="sm"
onPress={handleAssignPermission}
>
分配
</Button>
</div>
<Table
aria-label="分组权限列表"
classNames={{
wrapper:
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
<TableColumn>隧道分组</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>
{item.userGroupName || item.userGroupId}
</TableCell>
<TableCell>
{item.tunnelGroupName || item.tunnelGroupId}
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleRemovePermission(item.id)}
>
回收
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveTunnelGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveUserGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedTunnelKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedTunnelSummary}`}
>
当前已选:{selectedTunnelSummary}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignTunnels}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedUserKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedUserSummary}`}
>
当前已选:{selectedUserSummary}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignUsers}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
)} )}
</AnimatedPage>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">隧道分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateTunnelGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="隧道分组列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>隧道</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无隧道分组" items={tunnelGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.tunnelNames.length > 0
? item.tunnelNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignTunnels(item)}
>
分配隧道
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditTunnelGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteTunnelGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader className="flex flex-row items-center gap-3 pb-2">
<h3 className="text-lg font-semibold">用户分组</h3>
<Button
className="h-7 px-3 text-xs font-medium min-w-0 shadow-sm"
color="primary"
size="sm"
onPress={openCreateUserGroup}
>
新建
</Button>
</CardHeader>
<CardBody>
<Table
aria-label="用户分组列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>名称</TableColumn>
<TableColumn>用户</TableColumn>
<TableColumn>状态</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无用户分组" items={userGroups}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.name}</TableCell>
<TableCell>
{item.userNames.length > 0
? item.userNames.join("、")
: "-"}
</TableCell>
<TableCell>
<Chip
color={item.status === 1 ? "success" : "danger"}
size="sm"
>
{item.status === 1 ? "启用" : "停用"}
</Chip>
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
size="sm"
variant="flat"
onPress={() => openAssignUsers(item)}
>
分配用户
</Button>
<Button
size="sm"
variant="light"
onPress={() => openEditUserGroup(item)}
>
编辑
</Button>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeleteUserGroup(item.id)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Card>
<CardHeader>
<h3 className="text-lg font-semibold">权限分配</h3>
</CardHeader>
<CardBody className="space-y-4">
<div className="grid grid-cols-1 gap-3 md:grid-cols-3 md:items-end">
<Select
items={userGroups}
label="用户分组"
selectedKeys={
selectedUserGroupId ? [String(selectedUserGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedUserGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Select
items={tunnelGroups}
label="隧道分组"
selectedKeys={
selectedTunnelGroupId ? [String(selectedTunnelGroupId)] : []
}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
setSelectedTunnelGroupId(key ? Number(key) : null);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<Button
className="md:self-end md:justify-self-start whitespace-nowrap px-4"
color="primary"
isLoading={savingPermission}
size="sm"
onPress={handleAssignPermission}
>
分配
</Button>
</div>
<Table
aria-label="分组权限列表"
classNames={{
wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
}}
>
<TableHeader>
<TableColumn>ID</TableColumn>
<TableColumn>用户分组</TableColumn>
<TableColumn>隧道分组</TableColumn>
<TableColumn>创建时间</TableColumn>
<TableColumn>操作</TableColumn>
</TableHeader>
<TableBody emptyContent="暂无权限分配记录" items={permissions}>
{(item) => (
<TableRow key={item.id}>
<TableCell>{item.id}</TableCell>
<TableCell>
{item.userGroupName || item.userGroupId}
</TableCell>
<TableCell>
{item.tunnelGroupName || item.tunnelGroupId}
</TableCell>
<TableCell>{formatDate(item.createdTime)}</TableCell>
<TableCell>
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleRemovePermission(item.id)}
>
回收
</Button>
</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</CardBody>
</Card>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelGroupModalOpen}
onOpenChange={onTunnelGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingTunnelGroup ? "编辑隧道分组" : "新建隧道分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveTunnelGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userGroupModalOpen}
onOpenChange={onUserGroupModalChange}
>
<ModalContent>
<ModalHeader>
{editingUserGroup ? "编辑用户分组" : "新建用户分组"}
</ModalHeader>
<ModalBody className="space-y-3">
<Input
label="分组名称"
value={groupName}
onChange={(e) => setGroupName(e.target.value)}
/>
<Select
label="状态"
selectedKeys={[groupStatus]}
onSelectionChange={(keys) => {
const key = Array.from(keys as Set<React.Key>)[0];
if (key) {
setGroupStatus(String(key));
}
}}
>
<SelectItem key="1">启用</SelectItem>
<SelectItem key="0">停用</SelectItem>
</Select>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserGroupModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingGroup}
onPress={saveUserGroup}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={tunnelAssignModalOpen}
onOpenChange={onTunnelAssignModalChange}
>
<ModalContent>
<ModalHeader>分配隧道 - {assignTunnelGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={tunnels}
label="选择隧道"
selectedKeys={selectedTunnelKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedTunnelKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.name}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedTunnelSummary}`}
>
当前已选:{selectedTunnelSummary}
</p>
<p className="text-xs text-default-500">
不选择任何隧道并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onTunnelAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignTunnels}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<Modal
backdrop="blur"
classNames={{
base: "!w-[calc(100%-32px)] !mx-auto sm:!w-full rounded-2xl overflow-hidden",
}}
isOpen={userAssignModalOpen}
onOpenChange={onUserAssignModalChange}
>
<ModalContent>
<ModalHeader>分配用户 - {assignUserGroup?.name}</ModalHeader>
<ModalBody className="min-w-0">
<Select
className="min-w-0"
classNames={{ trigger: "max-w-full" }}
items={users}
label="选择用户"
selectedKeys={selectedUserKeys}
selectionMode="multiple"
onSelectionChange={(keys) => {
setSelectedUserKeys(
new Set(Array.from(keys as Set<React.Key>).map(String)),
);
}}
>
{(item) => <SelectItem key={item.id}>{item.user}</SelectItem>}
</Select>
<p
className="w-full min-w-0 max-w-full text-xs text-default-500 truncate"
title={`当前已选:${selectedUserSummary}`}
>
当前已选:{selectedUserSummary}
</p>
<p className="text-xs text-default-500">
不选择任何用户并保存将清空该分组成员。
</p>
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onUserAssignModalClose}>
取消
</Button>
<Button
color="primary"
isLoading={savingAssign}
onPress={saveAssignUsers}
>
保存
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</div>
); );
} }
+7 -14
View File
@@ -9,7 +9,6 @@ import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { siteConfig } from "@/config/site"; import { siteConfig } from "@/config/site";
import { VersionFooter } from "@/components/version-footer"; import { VersionFooter } from "@/components/version-footer";
import { BrandLogo } from "@/components/brand-logo";
import { login, LoginData, checkCaptcha, getConfigByName } from "@/api"; import { login, LoginData, checkCaptcha, getConfigByName } from "@/api";
import { writeLoginSession } from "@/utils/session"; import { writeLoginSession } from "@/utils/session";
import { useWebViewMode } from "@/hooks/useWebViewMode"; import { useWebViewMode } from "@/hooks/useWebViewMode";
@@ -161,10 +160,10 @@ export default function IndexPage() {
> >
<Card className="w-full bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.15)] border-white/80 dark:border-white/10 rounded-[32px] p-2 sm:p-4"> <Card className="w-full bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_20px_40px_rgba(0,0,0,0.15)] border-white/80 dark:border-white/10 rounded-[32px] p-2 sm:p-4">
<CardHeader className="pb-0 pt-6 px-6 flex-col items-center"> <CardHeader className="pb-0 pt-6 px-6 flex-col items-center">
<BrandLogo className="w-14 h-14 rounded-2xl mb-4" size={56} /> <div className="w-14 h-14 bg-primary rounded-2xl flex items-center justify-center mb-4 shadow-sm text-white">
<h1 className="text-2xl font-bold tracking-tight text-foreground"> <svg className="w-8 h-8" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 10V3L4 14h7v7l9-11h-7z" /></svg>
{siteConfig.name} </div>
</h1> <h1 className="text-2xl font-bold tracking-tight text-foreground">Welcome to Flvx</h1>
<p className="text-sm text-default-500 mt-2 font-medium"> <p className="text-sm text-default-500 mt-2 font-medium">
Sign in to manage your networks Sign in to manage your networks
</p> </p>
@@ -173,8 +172,7 @@ export default function IndexPage() {
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<Input <Input
classNames={{ classNames={{
inputWrapper: inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
"bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/30 dark:border-white/10 h-12 shadow-sm rounded-xl",
input: "text-base font-medium", input: "text-base font-medium",
}} }}
errorMessage={errors.username} errorMessage={errors.username}
@@ -192,8 +190,7 @@ export default function IndexPage() {
<Input <Input
classNames={{ classNames={{
inputWrapper: inputWrapper: "bg-white/50 dark:bg-black/50 backdrop-blur-md border border-white/60 dark:border-white/10 h-12 shadow-sm rounded-xl",
"bg-white/10 dark:bg-white/5 backdrop-blur-md border border-white/30 dark:border-white/10 h-12 shadow-sm rounded-xl",
input: "text-base font-medium tracking-wider", input: "text-base font-medium tracking-wider",
}} }}
isDisabled={loading} isDisabled={loading}
@@ -215,11 +212,7 @@ export default function IndexPage() {
isLoading={loading} isLoading={loading}
onPress={handleLogin} onPress={handleLogin}
> >
{loading {loading ? (showCaptcha ? "Verifying..." : "Signing in...") : "Sign In"}
? showCaptcha
? "Verifying..."
: "Signing in..."
: "Sign In"}
</Button> </Button>
</div> </div>
</CardBody> </CardBody>
+133 -138
View File
@@ -1,6 +1,5 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { LayoutGrid, List } from "lucide-react";
import { import {
AnimatedPage, AnimatedPage,
@@ -19,6 +18,7 @@ import {
ModalFooter, ModalFooter,
} from "@/shadcn-bridge/heroui/modal"; } from "@/shadcn-bridge/heroui/modal";
import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
import { LayoutGrid, List } from "lucide-react";
import { import {
Table, Table,
TableHeader, TableHeader,
@@ -251,11 +251,7 @@ export default function LimitPage() {
variant="flat" variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")} onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
> >
{viewMode === "list" ? ( {viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button> </Button>
<Button color="primary" size="sm" variant="flat" onPress={handleAdd}> <Button color="primary" size="sm" variant="flat" onPress={handleAdd}>
新增 新增
@@ -267,154 +263,153 @@ export default function LimitPage() {
{filteredRules.length > 0 ? ( {filteredRules.length > 0 ? (
viewMode === "list" ? ( viewMode === "list" ? (
<Card> <Card>
<Table <Table
aria-label="限速规则列表" aria-label="限速规则列表"
className="overflow-x-auto min-w-full" className="overflow-x-auto min-w-full"
classNames={{ classNames={{
wrapper: wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-[24px]",
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-[24px]", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors", }}
}} >
> <TableHeader>
<TableHeader> <TableColumn>规则名称</TableColumn>
<TableColumn>规则名称</TableColumn> <TableColumn>速度限制</TableColumn>
<TableColumn>速度限制</TableColumn> <TableColumn>操作</TableColumn>
<TableColumn>操作</TableColumn> </TableHeader>
</TableHeader> <TableBody items={filteredRules}>
<TableBody items={filteredRules}> {(rule) => (
{(rule) => ( <TableRow key={rule.id}>
<TableRow key={rule.id}> <TableCell>
<TableCell> <div className="flex items-center gap-2">
<div className="flex items-center gap-2"> <div
<div className={`shrink-0 w-2 h-2 rounded-full ${
className={`shrink-0 w-2 h-2 rounded-full ${ rule.status === 1 ? "bg-success" : "bg-danger"
rule.status === 1 ? "bg-success" : "bg-danger" }`}
}`} />
/> <span className="font-medium text-foreground text-sm">
<span className="font-medium text-foreground text-sm"> {rule.name}
{rule.name} </span>
</span>
</div>
</TableCell>
<TableCell>
<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
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)}
>
编辑
</Button>
<Button
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)}
>
删除
</Button>
</div>
</TableCell>
</TableRow>
)}
</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) => (
<StaggerItem key={rule.id}>
<Card className="shadow-sm overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div> </div>
</CardHeader> </TableCell>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3"> <TableCell>
<div className="space-y-3"> <span className="text-sm font-mono text-default-600">
<div className="flex justify-between items-center"> {rule.speed} Mbps
<span className="text-small text-default-600"> </span>
速度限制 </TableCell>
</span> <TableCell>
<Chip color="secondary" size="sm" variant="flat"> <div className="flex flex-wrap items-center gap-1.5 min-w-max">
{rule.speed} Mbps
</Chip>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button <Button
className="flex-1" 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"
color="primary"
size="sm" size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
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>
}
variant="flat" variant="flat"
onPress={() => handleEdit(rule)} onPress={() => handleEdit(rule)}
> >
编辑 编辑
</Button> </Button>
<Button <Button
className="flex-1" 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"
color="danger"
size="sm" size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
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>
}
variant="flat" variant="flat"
onPress={() => handleDelete(rule)} onPress={() => handleDelete(rule)}
> >
删除 删除
</Button> </Button>
</div> </div>
</CardBody> </TableCell>
</Card> </TableRow>
</StaggerItem> )}
))} </TableBody>
</StaggerList> </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) => (
<StaggerItem key={rule.id}>
<Card className="shadow-sm overflow-hidden h-full">
<CardHeader className="pb-2 md:pb-2">
<div className="flex justify-between items-start w-full">
<div>
<h3 className="font-semibold text-foreground">
{rule.name}
</h3>
</div>
<Chip
color={rule.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{rule.status === 1 ? "运行" : "异常"}
</Chip>
</div>
</CardHeader>
<CardBody className="pt-0 pb-3 md:pt-0 md:pb-3">
<div className="space-y-3">
<div className="flex justify-between items-center">
<span className="text-small text-default-600">
速度限制
</span>
<Chip color="secondary" size="sm" variant="flat">
{rule.speed} Mbps
</Chip>
</div>
</div>
<div className="flex gap-2 mt-4">
<Button
className="flex-1"
color="primary"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
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>
}
variant="flat"
onPress={() => handleEdit(rule)}
>
编辑
</Button>
<Button
className="flex-1"
color="danger"
size="sm"
startContent={
<svg
aria-hidden="true"
className="w-4 h-4"
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>
}
variant="flat"
onPress={() => handleDelete(rule)}
>
删除
</Button>
</div>
</CardBody>
</Card>
</StaggerItem>
))}
</StaggerList>
) )
) : ( ) : (
/* 空状态 */ /* 空状态 */
+12 -47
View File
@@ -2,13 +2,7 @@ import type { MonitorNodeApiItem } from "@/api/types";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import toast from "react-hot-toast"; import toast from "react-hot-toast";
import { import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react";
RefreshCw,
LayoutGrid,
List,
Server,
ArrowRightLeft,
} from "lucide-react";
import { AnimatedPage } from "@/components/animated-page"; import { AnimatedPage } from "@/components/animated-page";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
@@ -44,16 +38,11 @@ export default function MonitorPage() {
const [viewMode, setViewMode] = useState<"list" | "grid">("list"); const [viewMode, setViewMode] = useState<"list" | "grid">("list");
const [activeTab, setActiveTab] = useState<MonitorTab>("nodes"); const [activeTab, setActiveTab] = useState<MonitorTab>("nodes");
const [realtimeNodeStatus, setRealtimeNodeStatus] = useState< const [realtimeNodeStatus, setRealtimeNodeStatus] = useState<Record<number, "online" | "offline">>({});
Record<number, "online" | "offline"> const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<Record<number, any>>({});
>({});
const [realtimeNodeMetrics, setRealtimeNodeMetrics] = useState<
Record<number, any>
>({});
const handleRealtimeMessage = useCallback((message: any) => { const handleRealtimeMessage = useCallback((message: any) => {
const nodeId = Number(message?.id ?? 0); const nodeId = Number(message?.id ?? 0);
if (!nodeId || Number.isNaN(nodeId)) return; if (!nodeId || Number.isNaN(nodeId)) return;
const type = String(message?.type ?? ""); const type = String(message?.type ?? "");
@@ -61,18 +50,15 @@ export default function MonitorPage() {
if (type === "status") { if (type === "status") {
const status = Number(payload); const status = Number(payload);
setRealtimeNodeStatus((prev) => ({ setRealtimeNodeStatus((prev) => ({
...prev, ...prev,
[nodeId]: status === 1 ? "online" : "offline", [nodeId]: status === 1 ? "online" : "offline",
})); }));
return; return;
} }
if (type === "metric") { if (type === "metric") {
let raw = payload; let raw = payload;
if (typeof raw === "string") { if (typeof raw === "string") {
try { try {
raw = JSON.parse(raw); raw = JSON.parse(raw);
@@ -91,7 +77,7 @@ export default function MonitorPage() {
netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0), netOutSpeed: Number(metric.netOutSpeed ?? metric.net_out_speed ?? 0),
tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0), tcpConns: Number(metric.tcpConns ?? metric.tcp_conns ?? 0),
udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0), udpConns: Number(metric.udpConns ?? metric.udp_conns ?? 0),
}, }
})); }));
} }
}, []); }, []);
@@ -103,7 +89,6 @@ export default function MonitorPage() {
const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const loadNodes = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
if (!silent) setNodesLoading(true); if (!silent) setNodesLoading(true);
try { try {
const response = await getMonitorNodes(); const response = await getMonitorNodes();
@@ -148,9 +133,7 @@ export default function MonitorPage() {
.map((n) => ({ .map((n) => ({
id: Number(n.id), id: Number(n.id),
name: String(n.name ?? ""), name: String(n.name ?? ""),
connectionStatus: connectionStatus: realtimeNodeStatus[Number(n.id)] || (n.status === 1 ? "online" : "offline"),
realtimeNodeStatus[Number(n.id)] ||
(n.status === 1 ? "online" : "offline"),
version: n.version, version: n.version,
})); }));
@@ -168,7 +151,6 @@ export default function MonitorPage() {
if (node.connectionStatus === "online") { if (node.connectionStatus === "online") {
onlineCount++; onlineCount++;
const metric = realtimeNodeMetrics[node.id]; const metric = realtimeNodeMetrics[node.id];
if (metric) { if (metric) {
totalCpu += metric.cpuUsage; totalCpu += metric.cpuUsage;
totalConns += metric.tcpConns + metric.udpConns; totalConns += metric.tcpConns + metric.udpConns;
@@ -179,7 +161,6 @@ export default function MonitorPage() {
}); });
const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0; const avgCpu = onlineCount > 0 ? totalCpu / onlineCount : 0;
return { return {
avgCpu, avgCpu,
totalConns, totalConns,
@@ -193,14 +174,10 @@ export default function MonitorPage() {
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4 mb-8">
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40"> <div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative"> <div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm"> <span className="text-default-600 font-medium text-sm">System Load</span>
System Load
</span>
</div> </div>
<div className="z-10 relative"> <div className="z-10 relative">
<span className="text-4xl font-bold text-foreground"> <span className="text-4xl font-bold text-foreground">{aggregateMetrics.avgCpu.toFixed(1)}%</span>
{aggregateMetrics.avgCpu.toFixed(1)}%
</span>
</div> </div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0"> <div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-primary/40 h-2 rounded-t-sm" /> <div className="w-full bg-primary/40 h-2 rounded-t-sm" />
@@ -214,14 +191,10 @@ export default function MonitorPage() {
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40"> <div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative"> <div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm"> <span className="text-default-600 font-medium text-sm">Active Connections</span>
Active Connections
</span>
</div> </div>
<div className="z-10 relative"> <div className="z-10 relative">
<span className="text-4xl font-bold text-foreground"> <span className="text-4xl font-bold text-foreground">{aggregateMetrics.totalConns}</span>
{aggregateMetrics.totalConns}
</span>
</div> </div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0"> <div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-success/40 h-3.5 rounded-t-sm" /> <div className="w-full bg-success/40 h-3.5 rounded-t-sm" />
@@ -235,14 +208,10 @@ export default function MonitorPage() {
<div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40"> <div className="rounded-3xl border border-white/80 dark:border-white/10 bg-white/20 dark:bg-zinc-900/20 backdrop-blur-3xl shadow-[0_10px_30px_rgba(0,0,0,0.1)] p-6 relative overflow-hidden flex flex-col justify-between h-40">
<div className="flex justify-between items-center z-10 relative"> <div className="flex justify-between items-center z-10 relative">
<span className="text-default-600 font-medium text-sm"> <span className="text-default-600 font-medium text-sm">Bandwidth</span>
Bandwidth
</span>
</div> </div>
<div className="z-10 relative"> <div className="z-10 relative">
<span className="text-4xl font-bold text-foreground"> <span className="text-4xl font-bold text-foreground">{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}</span>
{formatBytesPerSecond(aggregateMetrics.totalBandwidth)}
</span>
</div> </div>
<div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0"> <div className="absolute bottom-0 left-0 right-0 h-12 flex items-end gap-1 px-6 pb-4 opacity-50 z-0">
<div className="w-full bg-secondary/40 h-2 rounded-t-sm" /> <div className="w-full bg-secondary/40 h-2 rounded-t-sm" />
@@ -267,11 +236,7 @@ export default function MonitorPage() {
variant="flat" variant="flat"
onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")} onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
> >
{viewMode === "list" ? ( {viewMode === "list" ? <LayoutGrid className="w-4 h-4" /> : <List className="w-4 h-4" />}
<LayoutGrid className="w-4 h-4" />
) : (
<List className="w-4 h-4" />
)}
</Button> </Button>
{activeTab === "nodes" && ( {activeTab === "nodes" && (
<Button <Button
+22 -37
View File
@@ -17,10 +17,10 @@ import {
useSortable, useSortable,
} from "@dnd-kit/sortable"; } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities"; import { CSS } from "@dnd-kit/utilities";
import { LayoutGrid, List } from "lucide-react";
import { SearchBar } from "@/components/search-bar"; import { SearchBar } from "@/components/search-bar";
import { AnimatedPage } from "@/components/animated-page"; import { AnimatedPage } from "@/components/animated-page";
import { LayoutGrid, List } from "lucide-react";
import { import {
Table, Table,
TableHeader, TableHeader,
@@ -288,7 +288,7 @@ const SortableItem = ({
export default function NodePage() { export default function NodePage() {
const [nodeList, setNodeList] = useState<Node[]>([]); const [nodeList, setNodeList] = useState<Node[]>([]);
const [nodeOrder, setNodeOrder] = useState<number[]>([]); const [nodeOrder, setNodeOrder] = useState<number[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState( const [localSearchKeyword, setLocalSearchKeyword] = useLocalStorageState(
"node-search-keyword-local", "node-search-keyword-local",
"", "",
@@ -699,6 +699,7 @@ export default function NodePage() {
// 格式化流量 // 格式化流量
const formatFlow = (bytes: number): string => { const formatFlow = (bytes: number): string => {
if (!Number.isFinite(bytes) || bytes <= 0) { if (!Number.isFinite(bytes) || bytes <= 0) {
return "0 B"; return "0 B";
@@ -725,6 +726,7 @@ export default function NodePage() {
return "未知链路"; return "未知链路";
}; };
// IPv4/IPv6 格式验证(仅用于判定地址族) // IPv4/IPv6 格式验证(仅用于判定地址族)
const ipv4Regex = const ipv4Regex =
/^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/; /^(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)$/;
@@ -1648,12 +1650,10 @@ export default function NodePage() {
{/* 视图切换按钮 */} {/* 视图切换按钮 */}
<Button <Button
isIconOnly isIconOnly
className="text-default-600 hidden sm:flex"
size="sm" size="sm"
variant="flat" variant="flat"
onPress={() => className="text-default-600 hidden sm:flex"
setViewMode(viewMode === "list" ? "grid" : "list") onPress={() => setViewMode(viewMode === "list" ? "grid" : "list")}
}
> >
{viewMode === "list" ? ( {viewMode === "list" ? (
<LayoutGrid className="w-4 h-4" /> <LayoutGrid className="w-4 h-4" />
@@ -1767,8 +1767,7 @@ export default function NodePage() {
aria-label="节点列表" aria-label="节点列表"
className="overflow-x-auto min-w-full" className="overflow-x-auto min-w-full"
classNames={{ classNames={{
wrapper: wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors", tr: "hover:bg-white/10 dark:hover:bg-white/5 transition-colors",
@@ -1777,11 +1776,7 @@ export default function NodePage() {
<TableHeader> <TableHeader>
<TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden"> <TableColumn className="w-12 px-4 whitespace-nowrap overflow-hidden">
<Checkbox <Checkbox
isSelected={ isSelected={selectMode && selectedIds.size === displayNodes.length && displayNodes.length > 0}
selectMode &&
selectedIds.size === displayNodes.length &&
displayNodes.length > 0
}
onValueChange={(checked) => { onValueChange={(checked) => {
if (checked) { if (checked) {
selectAll(); selectAll();
@@ -1811,16 +1806,12 @@ export default function NodePage() {
onValueChange={(checked) => { onValueChange={(checked) => {
if (checked) { if (checked) {
setSelectMode(true); setSelectMode(true);
setSelectedIds( setSelectedIds((prev) => new Set([...prev, node.id]));
(prev) => new Set([...prev, node.id]),
);
} else { } else {
setSelectedIds((prev) => { setSelectedIds((prev) => {
const next = new Set(prev); const next = new Set(prev);
next.delete(node.id); next.delete(node.id);
if (next.size === 0) setSelectMode(false); if (next.size === 0) setSelectMode(false);
return next; return next;
}); });
} }
@@ -1842,11 +1833,7 @@ export default function NodePage() {
{node.name} {node.name}
</span> </span>
{hasRemark && ( {hasRemark && (
<Chip <Chip size="sm" variant="flat" className="h-4 px-1 text-[10px]">
className="h-4 px-1 text-[10px]"
size="sm"
variant="flat"
>
{node.remark} {node.remark}
</Chip> </Chip>
)} )}
@@ -1854,9 +1841,7 @@ export default function NodePage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="text-sm font-mono text-default-600"> <span className="text-sm font-mono text-default-600">
{isRemoteNode {isRemoteNode ? new URL(node.remoteUrl || "").hostname : node.serverIp}
? new URL(node.remoteUrl || "").hostname
: node.serverIp}
</span> </span>
</TableCell> </TableCell>
<TableCell> <TableCell>
@@ -1868,10 +1853,10 @@ export default function NodePage() {
<div className="flex flex-wrap items-center gap-1.5 min-w-max"> <div className="flex flex-wrap items-center gap-1.5 min-w-max">
{!isRemoteNode && ( {!isRemoteNode && (
<Button <Button
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
isLoading={node.copyLoading}
size="sm" size="sm"
variant="flat" variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
isLoading={node.copyLoading}
onPress={() => openInstallSelector(node)} onPress={() => openInstallSelector(node)}
> >
安装 安装
@@ -1879,11 +1864,11 @@ export default function NodePage() {
)} )}
{!isRemoteNode && ( {!isRemoteNode && (
<Button <Button
size="sm"
variant="flat"
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" 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"
isDisabled={node.connectionStatus !== "online"} isDisabled={node.connectionStatus !== "online"}
isLoading={node.upgradeLoading} isLoading={node.upgradeLoading}
size="sm"
variant="flat"
onPress={() => { onPress={() => {
setUpgradeTargetNodeId(node.id); setUpgradeTargetNodeId(node.id);
openUpgradeModal("single"); openUpgradeModal("single");
@@ -1894,11 +1879,11 @@ export default function NodePage() {
)} )}
{!isRemoteNode && ( {!isRemoteNode && (
<Button <Button
size="sm"
variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400" className="h-6 px-2 min-w-0 text-xs bg-blue-50 text-blue-600 hover:bg-blue-100 dark:bg-blue-950/30 dark:text-blue-400"
isDisabled={node.connectionStatus !== "online"} isDisabled={node.connectionStatus !== "online"}
isLoading={node.rollbackLoading} isLoading={node.rollbackLoading}
size="sm"
variant="flat"
onPress={() => handleRollbackNode(node)} onPress={() => handleRollbackNode(node)}
> >
回退 回退
@@ -1906,18 +1891,18 @@ export default function NodePage() {
)} )}
{!isRemoteNode && ( {!isRemoteNode && (
<Button <Button
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" size="sm"
variant="flat" variant="flat"
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"
onPress={() => handleEdit(node)} onPress={() => handleEdit(node)}
> >
编辑 编辑
</Button> </Button>
)} )}
<Button <Button
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" size="sm"
variant="flat" variant="flat"
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"
onPress={() => handleDelete(node)} onPress={() => handleDelete(node)}
> >
删除 删除
@@ -2069,9 +2054,7 @@ export default function NodePage() {
type="button" type="button"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
handleDismissExpiryReminder( handleDismissExpiryReminder(node.id);
node.id,
);
}} }}
> >
关闭提醒 关闭提醒
@@ -2344,6 +2327,8 @@ export default function NodePage() {
</div> </div>
)} )}
<div className="mt-auto space-y-3"> <div className="mt-auto space-y-3">
{/* 操作按钮 */} {/* 操作按钮 */}
<div className="space-y-1.5"> <div className="space-y-1.5">
File diff suppressed because it is too large Load Diff
@@ -5,13 +5,7 @@ import type {
TunnelQualityHopApiItem, TunnelQualityHopApiItem,
} from "@/api/types"; } from "@/api/types";
import React, { import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { import {
LineChart, LineChart,
Line, Line,
@@ -41,6 +35,7 @@ import {
getMonitorTunnelQualityHistory, getMonitorTunnelQualityHistory,
getConfigByName, getConfigByName,
} from "@/api"; } from "@/api";
import { Button } from "@/shadcn-bridge/heroui/button"; import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
import { Chip } from "@/shadcn-bridge/heroui/chip"; import { Chip } from "@/shadcn-bridge/heroui/chip";
@@ -59,8 +54,7 @@ interface TunnelMonitorViewProps {
} }
const QUALITY_POLL_INTERVAL = 1_000; // 1 second const QUALITY_POLL_INTERVAL = 1_000; // 1 second
const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = const MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY = "monitor_tunnel_quality_enabled";
"monitor_tunnel_quality_enabled";
const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT = const MONITOR_TUNNEL_QUALITY_ENABLED_EVENT =
"monitorTunnelQualityEnabledChanged"; "monitorTunnelQualityEnabledChanged";
@@ -84,13 +78,7 @@ const formatTimestamp = (ts: number, rangeMs?: number): string => {
}); });
}; };
function LatencyDisplay({ function LatencyDisplay({ value, loading }: { value?: number; loading?: boolean }) {
value,
loading,
}: {
value?: number;
loading?: boolean;
}) {
if (loading) { if (loading) {
return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />; return <RefreshCw className="w-3 h-3 animate-spin inline text-primary" />;
} }
@@ -99,16 +87,11 @@ function LatencyDisplay({
} }
const ms = value.toFixed(0); const ms = value.toFixed(0);
let colorClass = "text-success"; let colorClass = "text-success";
if (value > 200) colorClass = "text-danger"; if (value > 200) colorClass = "text-danger";
else if (value > 100) colorClass = "text-warning"; else if (value > 100) colorClass = "text-warning";
else if (value > 50) colorClass = "text-primary"; else if (value > 50) colorClass = "text-primary";
return ( return <span className={`font-mono text-xs font-semibold ${colorClass}`}>{ms}ms</span>;
<span className={`font-mono text-xs font-semibold ${colorClass}`}>
{ms}ms
</span>
);
} }
function LiveDot() { function LiveDot() {
@@ -133,11 +116,9 @@ function UptimeHistoryBar({
}) { }) {
const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => { const bars = [...Array(UPTIME_KUMA_BARS_COUNT)].map((_, i) => {
const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i; const historyIndex = history.length - UPTIME_KUMA_BARS_COUNT + i;
if (historyIndex >= 0 && historyIndex < history.length) { if (historyIndex >= 0 && historyIndex < history.length) {
return history[historyIndex]; return history[historyIndex];
} }
return null; return null;
}); });
@@ -202,20 +183,13 @@ const TIME_RANGE_OPTIONS = [
{ key: String(24 * 60 * 60 * 1000), label: "24小时" }, { key: String(24 * 60 * 60 * 1000), label: "24小时" },
]; ];
function TimeRangeSelect({ function TimeRangeSelect({ value, onChange }: { value: number; onChange: (v: number) => void }) {
value,
onChange,
}: {
value: number;
onChange: (v: number) => void;
}) {
return ( return (
<Select <Select
className="w-36" className="w-36"
selectedKeys={[String(value)]} selectedKeys={[String(value)]}
onSelectionChange={(keys) => { onSelectionChange={(keys) => {
const v = Number(Array.from(keys)[0]); const v = Number(Array.from(keys)[0]);
if (v > 0) onChange(v); if (v > 0) onChange(v);
}} }}
> >
@@ -231,23 +205,13 @@ interface QualityChartCardProps {
onRangeChange: (v: number) => void; onRangeChange: (v: number) => void;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
data: Array<{ data: Array<{ time: string; entryToExit: number | null; exitToBing: number | null }>;
time: string;
entryToExit: number | null;
exitToBing: number | null;
}>;
tunnelId: number; tunnelId: number;
onRefresh: (id: number) => void; onRefresh: (id: number) => void;
} }
const QualityChartCard = React.memo(function QualityChartCard({ const QualityChartCard = React.memo(function QualityChartCard({
rangeMs, rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
onRangeChange,
loading,
error,
data,
tunnelId,
onRefresh,
}: QualityChartCardProps) { }: QualityChartCardProps) {
return ( return (
<Card> <Card>
@@ -255,12 +219,7 @@ const QualityChartCard = React.memo(function QualityChartCard({
<h3 className="text-lg font-semibold">质量趋势</h3> <h3 className="text-lg font-semibold">质量趋势</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} /> <TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button <Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
isLoading={loading}
size="sm"
variant="flat"
onPress={() => onRefresh(tunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" /> <RefreshCw className="w-4 h-4 mr-1" />
刷新 刷新
</Button> </Button>
@@ -268,75 +227,38 @@ const QualityChartCard = React.memo(function QualityChartCard({
</CardHeader> </CardHeader>
<CardBody> <CardBody>
{loading ? ( {loading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
<RefreshCw className="w-6 h-6 animate-spin" />
</div>
) : error ? ( ) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div> <div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? ( ) : data.length > 0 ? (
<div className="h-64"> <div className="h-64">
<ResponsiveContainer height="100%" width="100%"> <ResponsiveContainer height="100%" width="100%">
<LineChart data={data}> <LineChart data={data}>
<CartesianGrid opacity={0.3} strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} /> <XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis <YAxis
fontSize={11} fontSize={11}
label={{
value: "延迟 (ms)",
angle: -90,
position: "insideLeft",
style: { fontSize: 11, fill: "#888" },
}}
tick={{ fill: "#888" }} tick={{ fill: "#888" }}
tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`} tickFormatter={(v: any) => `${Number(v).toFixed(0)}ms`}
label={{ value: "延迟 (ms)", angle: -90, position: "insideLeft", style: { fontSize: 11, fill: "#888" } }}
/> />
<Tooltip <Tooltip
contentStyle={{ contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
backgroundColor: "rgba(0,0,0,0.85)", labelStyle={{ color: "#fff" }}
border: "none",
borderRadius: "8px",
fontSize: 12,
}}
formatter={(value: unknown, name: string) => { formatter={(value: unknown, name: string) => {
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n)) return "-"; if (!Number.isFinite(n)) return "-";
const label = const label = name === "entryToExit" ? "入口→出口" : name === "exitToBing" ? "出口→Bing" : name;
name === "entryToExit"
? "入口→出口"
: name === "exitToBing"
? "出口→Bing"
: name;
return [`${n.toFixed(1)}ms`, label]; return [`${n.toFixed(1)}ms`, label];
}} }}
labelStyle={{ color: "#fff" }}
/>
<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"
/> />
<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> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
) : ( ) : (
<div className="text-center py-8 text-default-500"> <div className="text-center py-8 text-default-500">暂无质量历史数据</div>
暂无质量历史数据
</div>
)} )}
</CardBody> </CardBody>
</Card> </Card>
@@ -348,33 +270,20 @@ interface TrafficChartCardProps {
onRangeChange: (v: number) => void; onRangeChange: (v: number) => void;
loading: boolean; loading: boolean;
error: string | null; error: string | null;
data: Array<{ data: Array<{ time: string; bytesIn: number; bytesOut: number; connections: number }>;
time: string;
bytesIn: number;
bytesOut: number;
connections: number;
}>;
tunnelId: number; tunnelId: number;
onRefresh: (id: number) => void; onRefresh: (id: number) => void;
} }
const TrafficChartCard = React.memo(function TrafficChartCard({ const TrafficChartCard = React.memo(function TrafficChartCard({
rangeMs, rangeMs, onRangeChange, loading, error, data, tunnelId, onRefresh,
onRangeChange,
loading,
error,
data,
tunnelId,
onRefresh,
}: TrafficChartCardProps) { }: TrafficChartCardProps) {
const yFormatter = (value: unknown) => { const yFormatter = (value: unknown) => {
const n = Number(value); const n = Number(value);
if (!Number.isFinite(n) || n <= 0) return "0 B"; if (!Number.isFinite(n) || n <= 0) return "0 B";
const k = 1024; const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"]; const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(n) / Math.log(k)); const i = Math.floor(Math.log(n) / Math.log(k));
return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; return `${parseFloat((n / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}; };
@@ -384,12 +293,7 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
<h3 className="text-lg font-semibold">流量趋势</h3> <h3 className="text-lg font-semibold">流量趋势</h3>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<TimeRangeSelect value={rangeMs} onChange={onRangeChange} /> <TimeRangeSelect value={rangeMs} onChange={onRangeChange} />
<Button <Button isLoading={loading} size="sm" variant="flat" onPress={() => onRefresh(tunnelId)}>
isLoading={loading}
size="sm"
variant="flat"
onPress={() => onRefresh(tunnelId)}
>
<RefreshCw className="w-4 h-4 mr-1" /> <RefreshCw className="w-4 h-4 mr-1" />
刷新 刷新
</Button> </Button>
@@ -397,48 +301,23 @@ const TrafficChartCard = React.memo(function TrafficChartCard({
</CardHeader> </CardHeader>
<CardBody> <CardBody>
{loading ? ( {loading ? (
<div className="flex justify-center py-8"> <div className="flex justify-center py-8"><RefreshCw className="w-6 h-6 animate-spin" /></div>
<RefreshCw className="w-6 h-6 animate-spin" />
</div>
) : error ? ( ) : error ? (
<div className="text-center py-8 text-danger text-sm">{error}</div> <div className="text-center py-8 text-danger text-sm">{error}</div>
) : data.length > 0 ? ( ) : data.length > 0 ? (
<div className="h-64"> <div className="h-64">
<ResponsiveContainer height="100%" width="100%"> <ResponsiveContainer height="100%" width="100%">
<LineChart data={data}> <LineChart data={data}>
<CartesianGrid opacity={0.3} strokeDasharray="3 3" /> <CartesianGrid strokeDasharray="3 3" opacity={0.3} />
<XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} /> <XAxis dataKey="time" fontSize={11} tick={{ fill: "#888" }} />
<YAxis <YAxis fontSize={11} tick={{ fill: "#888" }} tickFormatter={yFormatter} />
fontSize={11}
tick={{ fill: "#888" }}
tickFormatter={yFormatter}
/>
<Tooltip <Tooltip
contentStyle={{ contentStyle={{ backgroundColor: "rgba(0,0,0,0.85)", border: "none", borderRadius: "8px", fontSize: 12 }}
backgroundColor: "rgba(0,0,0,0.85)",
border: "none",
borderRadius: "8px",
fontSize: 12,
}}
formatter={yFormatter}
labelStyle={{ color: "#fff" }} labelStyle={{ color: "#fff" }}
formatter={yFormatter}
/> />
<Line <Line dataKey="bytesIn" dot={false} name="入站流量" stroke="#10b981" strokeWidth={2} type="monotone" />
dataKey="bytesIn" <Line dataKey="bytesOut" dot={false} name="出站流量" stroke="#ef4444" strokeWidth={2} type="monotone" />
dot={false}
name="入站流量"
stroke="#10b981"
strokeWidth={2}
type="monotone"
/>
<Line
dataKey="bytesOut"
dot={false}
name="出站流量"
stroke="#ef4444"
strokeWidth={2}
type="monotone"
/>
</LineChart> </LineChart>
</ResponsiveContainer> </ResponsiveContainer>
</div> </div>
@@ -454,7 +333,6 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
if (!hopsStr) return null; if (!hopsStr) return null;
let hops: TunnelQualityHopApiItem[] = []; let hops: TunnelQualityHopApiItem[] = [];
try { try {
hops = JSON.parse(hopsStr); hops = JSON.parse(hopsStr);
} catch { } catch {
@@ -475,49 +353,28 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
<div className="flex items-center overflow-x-auto pb-2 py-2"> <div className="flex items-center overflow-x-auto pb-2 py-2">
{hops.map((hop, index) => { {hops.map((hop, index) => {
const hasError = hop.latency < 0 || hop.loss > 0; const hasError = hop.latency < 0 || hop.loss > 0;
const colorClass = const colorClass = hop.latency < 0 ? "text-danger" : (hop.loss > 0 ? "text-warning" : "text-success");
hop.latency < 0
? "text-danger"
: hop.loss > 0
? "text-warning"
: "text-success";
const borderColor = hasError ? "border-danger" : ""; const borderColor = hasError ? "border-danger" : "";
return ( return (
<React.Fragment key={index}> <React.Fragment key={index}>
{index === 0 && ( {index === 0 && (
<Chip <Chip size="sm" variant="flat" className="shrink-0 font-mono shadow-sm">
className="shrink-0 font-mono shadow-sm"
size="sm"
variant="flat"
>
{hop.fromNodeName} {hop.fromNodeName}
</Chip> </Chip>
)} )}
<div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative"> <div className="flex flex-col items-center justify-center min-w-[70px] mx-1 shrink-0 relative">
<span <span className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}>
className={`text-[10px] font-mono leading-none mb-1 ${colorClass}`}
>
{hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"} {hop.latency >= 0 ? `${hop.latency.toFixed(0)}ms` : "超时"}
</span> </span>
<div <div className={`h-[2px] w-full relative flex items-center justify-end bg-default-200 ${hop.latency < 0 ? "!bg-danger" : ""}`}>
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`} />
>
<ArrowRight
className={`w-3.5 h-3.5 absolute -right-2 ${colorClass} bg-background rounded-full p-[1px] z-10`}
/>
</div> </div>
<span <span className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}>
className={`text-[10px] font-mono leading-none mt-1.5 ${hop.loss > 0 ? "text-warning" : "text-default-400"}`}
>
{hop.loss.toFixed(0)}% 丢包 {hop.loss.toFixed(0)}% 丢包
</span> </span>
</div> </div>
<Chip <Chip size="sm" variant="flat" className={`shrink-0 font-mono shadow-sm ${borderColor}`}>
className={`shrink-0 font-mono shadow-sm ${borderColor}`}
size="sm"
variant="flat"
>
{hop.toNodeName} {hop.toNodeName}
</Chip> </Chip>
</React.Fragment> </React.Fragment>
@@ -529,21 +386,15 @@ function ForwardingChainTopology({ hopsStr }: { hopsStr?: string }) {
); );
} }
export function TunnelMonitorView({ export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) {
viewMode = "grid",
}: TunnelMonitorViewProps) {
const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]); const [tunnels, setTunnels] = useState<MonitorTunnelApiItem[]>([]);
const [tunnelsLoading, setTunnelsLoading] = useState(false); const [tunnelsLoading, setTunnelsLoading] = useState(false);
const [tunnelsError, setTunnelsError] = useState<string | null>(null); const [tunnelsError, setTunnelsError] = useState<string | null>(null);
const [accessDenied, setAccessDenied] = useState<string | null>(null); const [accessDenied, setAccessDenied] = useState<string | null>(null);
// Quality data from backend periodic probing (latest per tunnel) // Quality data from backend periodic probing (latest per tunnel)
const [qualityMap, setQualityMap] = useState< const [qualityMap, setQualityMap] = useState<Record<number, TunnelQualityApiItem>>({});
Record<number, TunnelQualityApiItem> const [qualityHistoryMap, setQualityHistoryMap] = useState<Record<number, TunnelQualityApiItem[]>>({});
>({});
const [qualityHistoryMap, setQualityHistoryMap] = useState<
Record<number, TunnelQualityApiItem[]>
>({});
const initialHistoryFetched = useRef(false); const initialHistoryFetched = useRef(false);
const [qualityLoading, setQualityLoading] = useState(false); const [qualityLoading, setQualityLoading] = useState(false);
const qualityTimerRef = useRef<number | null>(null); const qualityTimerRef = useRef<number | null>(null);
@@ -554,27 +405,20 @@ export function TunnelMonitorView({
const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null); const [detailTunnelId, setDetailTunnelId] = useState<number | null>(null);
// Quality history for chart (mirrors service monitor results) // Quality history for chart (mirrors service monitor results)
const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>( const [qualityHistory, setQualityHistory] = useState<TunnelQualityApiItem[]>([]);
[],
);
const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false); const [qualityHistoryLoading, setQualityHistoryLoading] = useState(false);
const [qualityHistoryError, setQualityHistoryError] = useState<string | null>( const [qualityHistoryError, setQualityHistoryError] = useState<string | null>(null);
null,
);
const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000); const [qualityRangeMs, setQualityRangeMs] = useState(60 * 60 * 1000);
// Tunnel traffic metrics for chart // Tunnel traffic metrics for chart
const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]); const [tunnelMetrics, setTunnelMetrics] = useState<TunnelMetricApiItem[]>([]);
const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false);
const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>( const [tunnelMetricsError, setTunnelMetricsError] = useState<string | null>(null);
null,
);
const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000);
// --- Load tunnel list --- // --- Load tunnel list ---
const loadTunnels = useCallback(async (options?: { silent?: boolean }) => { const loadTunnels = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
if (!silent) setTunnelsLoading(true); if (!silent) setTunnelsLoading(true);
try { try {
const response = await getMonitorTunnels(); const response = await getMonitorTunnels();
@@ -583,14 +427,12 @@ export function TunnelMonitorView({
setAccessDenied(null); setAccessDenied(null);
setTunnelsError(null); setTunnelsError(null);
setTunnels(response.data); setTunnels(response.data);
return; return;
} }
if (response.code === 403) { if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权");
setTunnelsError(null); setTunnelsError(null);
setTunnels([]); setTunnels([]);
return; return;
} }
setTunnelsError(response.msg || "加载隧道列表失败"); setTunnelsError(response.msg || "加载隧道列表失败");
@@ -610,7 +452,6 @@ export function TunnelMonitorView({
const response = await getConfigByName( const response = await getConfigByName(
MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY, MONITOR_TUNNEL_QUALITY_ENABLED_CONFIG_KEY,
); );
setMonitorTunnelQualityEnabled( setMonitorTunnelQualityEnabled(
typeof response.data?.value === "string" typeof response.data?.value === "string"
? response.data.value === "true" ? response.data.value === "true"
@@ -636,9 +477,7 @@ export function TunnelMonitorView({
useEffect(() => { useEffect(() => {
const handleMonitorTunnelQualityEnabledChanged = (event: Event) => { const handleMonitorTunnelQualityEnabledChanged = (event: Event) => {
const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail const enabled = (event as CustomEvent<{ enabled?: boolean }>).detail?.enabled;
?.enabled;
if (typeof enabled === "boolean") { if (typeof enabled === "boolean") {
setMonitorTunnelQualityEnabled(enabled); setMonitorTunnelQualityEnabled(enabled);
if (!enabled) { if (!enabled) {
@@ -671,44 +510,31 @@ export function TunnelMonitorView({
const start = end - 5 * 60 * 1000; const start = end - 5 * 60 * 1000;
const promises = tunnels.map((t) => const promises = tunnels.map((t) =>
getMonitorTunnelQualityHistory(t.id, start, end) getMonitorTunnelQualityHistory(t.id, start, end).then(res => {
.then((res) => { if (res.code === 0 && res.data) {
if (res.code === 0 && res.data) { return { id: t.id, data: res.data };
return { id: t.id, data: res.data }; }
} return { id: t.id, data: [] };
}).catch(() => ({ id: t.id, data: [] }))
return { id: t.id, data: [] };
})
.catch(() => ({ id: t.id, data: [] })),
); );
const results = await Promise.all(promises); const results = await Promise.all(promises);
setQualityHistoryMap(prev => {
setQualityHistoryMap((prev) => {
const nextMap = { ...prev }; const nextMap = { ...prev };
results.forEach(r => {
results.forEach((r) => {
const existing = nextMap[r.id] || []; const existing = nextMap[r.id] || [];
const merged = [...r.data, ...existing].sort( const merged = [...r.data, ...existing].sort((a,b) => a.timestamp - b.timestamp);
(a, b) => a.timestamp - b.timestamp,
);
const unique: TunnelQualityApiItem[] = []; const unique: TunnelQualityApiItem[] = [];
for (const item of merged) { for (const item of merged) {
if ( if (unique.length === 0 || unique[unique.length-1].timestamp !== item.timestamp) {
unique.length === 0 || unique.push(item);
unique[unique.length - 1].timestamp !== item.timestamp }
) {
unique.push(item);
}
} }
nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT); nextMap[r.id] = unique.slice(-UPTIME_KUMA_BARS_COUNT);
}); });
return nextMap; return nextMap;
}); });
}; };
void fetchHistory(); void fetchHistory();
} }
}, [tunnels]); }, [tunnels]);
@@ -716,36 +542,27 @@ export function TunnelMonitorView({
// --- Load quality snapshots (auto-polling every 10s) --- // --- Load quality snapshots (auto-polling every 10s) ---
const loadQuality = useCallback(async (options?: { silent?: boolean }) => { const loadQuality = useCallback(async (options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
if (!silent) setQualityLoading(true); if (!silent) setQualityLoading(true);
try { try {
const response = await getMonitorTunnelQuality(); const response = await getMonitorTunnelQuality();
if (response.code === 0 && Array.isArray(response.data)) { if (response.code === 0 && Array.isArray(response.data)) {
const map: Record<number, TunnelQualityApiItem> = {}; const map: Record<number, TunnelQualityApiItem> = {};
for (const q of response.data) { for (const q of response.data) {
map[q.tunnelId] = q; map[q.tunnelId] = q;
} }
setQualityMap(map); setQualityMap(map);
// Accumulate locally // Accumulate locally
setQualityHistoryMap((prev) => { setQualityHistoryMap(prev => {
const nextMap = { ...prev }; const nextMap = { ...prev };
for (const q of response.data) {
for (const q of response.data) { const currentArr = nextMap[q.tunnelId] || [];
const currentArr = nextMap[q.tunnelId] || []; const lastItem = currentArr.length > 0 ? currentArr[currentArr.length - 1] : null;
const lastItem = if (!lastItem || lastItem.timestamp !== q.timestamp) {
currentArr.length > 0 ? currentArr[currentArr.length - 1] : null; nextMap[q.tunnelId] = [...currentArr, q].slice(-UPTIME_KUMA_BARS_COUNT);
}
if (!lastItem || lastItem.timestamp !== q.timestamp) { }
nextMap[q.tunnelId] = [...currentArr, q].slice( return nextMap;
-UPTIME_KUMA_BARS_COUNT,
);
}
}
return nextMap;
}); });
} }
} catch { } catch {
@@ -769,7 +586,6 @@ export function TunnelMonitorView({
window.clearInterval(qualityTimerRef.current); window.clearInterval(qualityTimerRef.current);
qualityTimerRef.current = null; qualityTimerRef.current = null;
} }
return; return;
} }
@@ -789,26 +605,19 @@ export function TunnelMonitorView({
const loadQualityHistory = useCallback( const loadQualityHistory = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => { async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
if (!silent) setQualityHistoryLoading(true); if (!silent) setQualityHistoryLoading(true);
try { try {
const end = Date.now(); const end = Date.now();
const start = end - qualityRangeMs; const start = end - qualityRangeMs;
const response = await getMonitorTunnelQualityHistory( const response = await getMonitorTunnelQualityHistory(tunnelId, start, end);
tunnelId,
start,
end,
);
if (response.code === 0 && Array.isArray(response.data)) { if (response.code === 0 && Array.isArray(response.data)) {
setQualityHistoryError(null); setQualityHistoryError(null);
setQualityHistory(response.data); setQualityHistory(response.data);
return; return;
} }
if (response.code === 403) { if (response.code === 403) {
setAccessDenied(response.msg || "暂无监控权限"); setAccessDenied(response.msg || "暂无监控权限");
return; return;
} }
setQualityHistoryError(response.msg || "加载质量历史失败"); setQualityHistoryError(response.msg || "加载质量历史失败");
@@ -828,7 +637,6 @@ export function TunnelMonitorView({
const loadTunnelMetrics = useCallback( const loadTunnelMetrics = useCallback(
async (tunnelId: number, options?: { silent?: boolean }) => { async (tunnelId: number, options?: { silent?: boolean }) => {
const silent = options?.silent ?? false; const silent = options?.silent ?? false;
if (!silent) setTunnelMetricsLoading(true); if (!silent) setTunnelMetricsLoading(true);
try { try {
const end = Date.now(); const end = Date.now();
@@ -840,9 +648,7 @@ export function TunnelMonitorView({
const ordered = [...response.data].sort( const ordered = [...response.data].sort(
(a, b) => a.timestamp - b.timestamp, (a, b) => a.timestamp - b.timestamp,
); );
setTunnelMetrics(ordered); setTunnelMetrics(ordered);
return; return;
} }
setTunnelMetricsError(response.msg || "加载流量数据失败"); setTunnelMetricsError(response.msg || "加载流量数据失败");
@@ -889,32 +695,29 @@ export function TunnelMonitorView({
// Memoize chart data so React.memo sub-components see stable references // Memoize chart data so React.memo sub-components see stable references
const qualityChartData = useMemo( const qualityChartData = useMemo(
() => () => qualityHistory.map((q) => ({
qualityHistory.map((q) => ({ time: formatTimestamp(q.timestamp, qualityRangeMs),
time: formatTimestamp(q.timestamp, qualityRangeMs), entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null,
entryToExit: q.entryToExitLatency >= 0 ? q.entryToExitLatency : null, exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null,
exitToBing: q.exitToBingLatency >= 0 ? q.exitToBingLatency : null, entryToExitLoss: q.entryToExitLoss,
entryToExitLoss: q.entryToExitLoss, exitToBingLoss: q.exitToBingLoss,
exitToBingLoss: q.exitToBingLoss, })),
})),
[qualityHistory, qualityRangeMs], [qualityHistory, qualityRangeMs],
); );
const tunnelChartData = useMemo( const tunnelChartData = useMemo(
() => () => tunnelMetrics.map((m) => ({
tunnelMetrics.map((m) => ({ time: formatTimestamp(m.timestamp, tunnelRangeMs),
time: formatTimestamp(m.timestamp, tunnelRangeMs), bytesIn: m.bytesIn,
bytesIn: m.bytesIn, bytesOut: m.bytesOut,
bytesOut: m.bytesOut, connections: m.connections,
connections: m.connections, })),
})),
[tunnelMetrics, tunnelRangeMs], [tunnelMetrics, tunnelRangeMs],
); );
const detailTunnel = const detailTunnel = detailTunnelId != null
detailTunnelId != null ? tunnels.find((t) => t.id === detailTunnelId)
? tunnels.find((t) => t.id === detailTunnelId) : null;
: null;
// Aggregate stats // Aggregate stats
const tunnelStats = useMemo(() => { const tunnelStats = useMemo(() => {
@@ -926,11 +729,9 @@ export function TunnelMonitorView({
// Last quality update timestamp // Last quality update timestamp
const lastQualityUpdate = useMemo(() => { const lastQualityUpdate = useMemo(() => {
let latest = 0; let latest = 0;
for (const q of Object.values(qualityMap)) { for (const q of Object.values(qualityMap)) {
if (q.timestamp > latest) latest = q.timestamp; if (q.timestamp > latest) latest = q.timestamp;
} }
return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null; return latest > 0 ? new Date(latest).toLocaleTimeString("zh-CN") : null;
}, [qualityMap]); }, [qualityMap]);
@@ -958,28 +759,18 @@ export function TunnelMonitorView({
<div className="space-y-6"> <div className="space-y-6">
{/* Header */} {/* Header */}
<div className="flex items-center gap-3 flex-wrap"> <div className="flex items-center gap-3 flex-wrap">
<Button <Button size="sm" variant="flat" onPress={() => {
size="sm" setDetailTunnelId(null);
variant="flat" setQualityHistory([]);
onPress={() => { setTunnelMetrics([]);
setDetailTunnelId(null); }}>
setQualityHistory([]);
setTunnelMetrics([]);
}}
>
<ArrowLeft className="w-4 h-4 mr-1" /> <ArrowLeft className="w-4 h-4 mr-1" />
返回隧道列表 返回隧道列表
</Button> </Button>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<ArrowRightLeft <ArrowRightLeft className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`} />
className={`w-5 h-5 ${detailTunnel.status === 1 ? "text-success" : "text-default-400"}`}
/>
<h3 className="text-lg font-semibold">{detailTunnel.name}</h3> <h3 className="text-lg font-semibold">{detailTunnel.name}</h3>
<Chip <Chip size="sm" color={detailTunnel.status === 1 ? "success" : "danger"} variant="flat">
color={detailTunnel.status === 1 ? "success" : "danger"}
size="sm"
variant="flat"
>
{detailTunnel.status === 1 ? "启用" : "禁用"} {detailTunnel.status === 1 ? "启用" : "禁用"}
</Chip> </Chip>
</div> </div>
@@ -993,10 +784,7 @@ export function TunnelMonitorView({
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
入口 → 出口 延迟 入口 → 出口 延迟
</span> </span>
<LatencyDisplay <LatencyDisplay value={quality?.entryToExitLatency} loading={qualityLoading} />
loading={qualityLoading}
value={quality?.entryToExitLatency}
/>
</CardBody> </CardBody>
</Card> </Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50"> <Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
@@ -1005,37 +793,22 @@ export function TunnelMonitorView({
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
出口 → Bing 延迟 出口 → Bing 延迟
</span> </span>
<LatencyDisplay <LatencyDisplay value={quality?.exitToBingLatency} loading={qualityLoading} />
loading={qualityLoading}
value={quality?.exitToBingLatency}
/>
</CardBody> </CardBody>
</Card> </Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50"> <Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]"> <CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5"> <span className="text-[11px] text-default-500 mb-1.5">入口 → 出口 丢包</span>
入口 → 出口 丢包 <span className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}>
</span> {quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"}
<span
className={`text-sm font-semibold font-mono ${(quality?.entryToExitLoss ?? 0) > 0 ? "text-warning" : ""}`}
>
{quality?.entryToExitLoss !== undefined
? `${quality.entryToExitLoss.toFixed(1)}%`
: "-"}
</span> </span>
</CardBody> </CardBody>
</Card> </Card>
<Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50"> <Card className="border border-divider/60 shadow-sm hover:shadow-md transition-shadow bg-gradient-to-br from-background to-default-50/50">
<CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]"> <CardBody className="py-3 px-4 flex flex-col items-center justify-center min-h-[5rem]">
<span className="text-[11px] text-default-500 mb-1.5"> <span className="text-[11px] text-default-500 mb-1.5">出口 → Bing 丢包</span>
出口 → Bing 丢包 <span className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}>
</span> {quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"}
<span
className={`text-sm font-semibold font-mono ${(quality?.exitToBingLoss ?? 0) > 0 ? "text-warning" : ""}`}
>
{quality?.exitToBingLoss !== undefined
? `${quality.exitToBingLoss.toFixed(1)}%`
: "-"}
</span> </span>
</CardBody> </CardBody>
</Card> </Card>
@@ -1056,8 +829,7 @@ export function TunnelMonitorView({
)} )}
{quality?.timestamp && ( {quality?.timestamp && (
<span className="text-default-400"> <span className="text-default-400">
· 最近更新:{" "} · 最近更新: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
{new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
</span> </span>
)} )}
{quality?.errorMessage && ( {quality?.errorMessage && (
@@ -1072,23 +844,23 @@ export function TunnelMonitorView({
{/* ====== Quality History Chart — isolated with React.memo ====== */} {/* ====== Quality History Chart — isolated with React.memo ====== */}
<QualityChartCard <QualityChartCard
data={qualityChartData}
error={qualityHistoryError}
loading={qualityHistoryLoading}
rangeMs={qualityRangeMs} rangeMs={qualityRangeMs}
tunnelId={detailTunnelId}
onRangeChange={setQualityRangeMs} onRangeChange={setQualityRangeMs}
loading={qualityHistoryLoading}
error={qualityHistoryError}
data={qualityChartData}
tunnelId={detailTunnelId}
onRefresh={loadQualityHistory} onRefresh={loadQualityHistory}
/> />
{/* ====== Traffic Chart — isolated with React.memo ====== */} {/* ====== Traffic Chart — isolated with React.memo ====== */}
<TrafficChartCard <TrafficChartCard
data={tunnelChartData}
error={tunnelMetricsError}
loading={tunnelMetricsLoading}
rangeMs={tunnelRangeMs} rangeMs={tunnelRangeMs}
tunnelId={detailTunnelId}
onRangeChange={setTunnelRangeMs} onRangeChange={setTunnelRangeMs}
loading={tunnelMetricsLoading}
error={tunnelMetricsError}
data={tunnelChartData}
tunnelId={detailTunnelId}
onRefresh={loadTunnelMetrics} onRefresh={loadTunnelMetrics}
/> />
</div> </div>
@@ -1099,9 +871,7 @@ export function TunnelMonitorView({
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="flex flex-wrap items-center gap-3 mb-1"> <div className="flex flex-wrap items-center gap-3 mb-1">
<Chip color="primary" size="sm" variant="flat"> <Chip color="primary" size="sm" variant="flat">隧道 {tunnelStats.enabled}/{tunnelStats.total}</Chip>
隧道 {tunnelStats.enabled}/{tunnelStats.total}
</Chip>
{lastQualityUpdate ? ( {lastQualityUpdate ? (
<div className="flex items-center gap-1.5 text-xs text-default-500"> <div className="flex items-center gap-1.5 text-xs text-default-500">
{monitorTunnelQualityEnabled ? ( {monitorTunnelQualityEnabled ? (
@@ -1123,12 +893,7 @@ export function TunnelMonitorView({
</div> </div>
) : null} ) : null}
<div className="ml-auto"> <div className="ml-auto">
<Button <Button isLoading={tunnelsLoading} size="sm" variant="flat" onPress={() => loadTunnels()}>
isLoading={tunnelsLoading}
size="sm"
variant="flat"
onPress={() => loadTunnels()}
>
<RefreshCw className="w-4 h-4 mr-1" /> <RefreshCw className="w-4 h-4 mr-1" />
刷新 刷新
</Button> </Button>
@@ -1155,33 +920,21 @@ export function TunnelMonitorView({
className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background" className="group relative overflow-hidden shadow-sm border border-divider dark:border-default-100 hover:-translate-y-1 hover:shadow-lg transition-all duration-300 h-full flex flex-col cursor-pointer bg-background"
onClick={() => setDetailTunnelId(tunnel.id)} onClick={() => setDetailTunnelId(tunnel.id)}
> >
<div <div className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} />
className={`absolute top-0 left-0 right-0 h-1 ${isEnabled ? "bg-success" : "bg-danger"}`} <div className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`} />
/>
<div
className={`absolute -right-8 -top-8 w-24 h-24 rounded-full blur-2xl opacity-10 transition-opacity group-hover:opacity-20 ${isEnabled ? "bg-success" : "bg-danger"}`}
/>
<CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4"> <CardHeader className="pb-2 pt-5 px-5 flex flex-row justify-between items-start gap-4">
<div className="flex items-center gap-3 min-w-0"> <div className="flex items-center gap-3 min-w-0">
<div className="relative flex-shrink-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"> <div className="w-10 h-10 rounded-xl bg-default-100 dark:bg-default-50/10 flex items-center justify-center border border-divider">
<ArrowRightLeft <ArrowRightLeft className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`} />
className={`w-5 h-5 ${isEnabled ? "text-success" : "text-danger"}`}
/>
</div> </div>
<span <span className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`} />
className={`absolute -bottom-0.5 -right-0.5 w-3 h-3 rounded-full border-2 border-background ${isEnabled ? "bg-success" : "bg-danger"}`}
/>
</div> </div>
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<h3 className="font-semibold text-foreground text-sm truncate"> <h3 className="font-semibold text-foreground text-sm truncate">{tunnel.name}</h3>
{tunnel.name}
</h3>
<div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5"> <div className="flex items-center gap-1.5 text-[11px] text-default-500 mt-0.5">
<span className="font-mono"> <span className="font-mono">{isEnabled ? "启用" : "禁用"}</span>
{isEnabled ? "启用" : "禁用"}
</span>
</div> </div>
</div> </div>
</div> </div>
@@ -1194,30 +947,20 @@ export function TunnelMonitorView({
<Zap className="w-3 h-3" /> <Zap className="w-3 h-3" />
入口→出口 入口→出口
</div> </div>
<UptimeHistoryBar <UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.entryToExitLatency}
type="entryToExit"
/>
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<div className="text-[10px] text-default-500 flex items-center gap-1"> <div className="text-[10px] text-default-500 flex items-center gap-1">
<Globe className="w-3 h-3" /> <Globe className="w-3 h-3" />
出口→Bing 出口→Bing
</div> </div>
<UptimeHistoryBar <UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.exitToBingLatency}
type="exitToBing"
/>
</div> </div>
</div> </div>
<div className="flex justify-between items-center pt-2 border-t border-divider/50"> <div className="flex justify-between items-center pt-2 border-t border-divider/50">
{quality?.errorMessage ? ( {quality?.errorMessage ? (
<span className="text-[11px] text-danger truncate"> <span className="text-[11px] text-danger truncate">{quality.errorMessage}</span>
{quality.errorMessage}
</span>
) : quality?.timestamp ? ( ) : quality?.timestamp ? (
<span className="text-[11px] text-default-500 flex items-center gap-1"> <span className="text-[11px] text-default-500 flex items-center gap-1">
{monitorTunnelQualityEnabled ? ( {monitorTunnelQualityEnabled ? (
@@ -1225,15 +968,11 @@ export function TunnelMonitorView({
) : ( ) : (
<WifiOff className="w-3 h-3 text-warning" /> <WifiOff className="w-3 h-3 text-warning" />
)} )}
{new Date(quality.timestamp).toLocaleTimeString( {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
"zh-CN",
)}
</span> </span>
) : ( ) : (
<span className="text-[11px] text-default-400"> <span className="text-[11px] text-default-400">
{monitorTunnelQualityEnabled {monitorTunnelQualityEnabled ? "等待探测..." : "实时检测已关闭"}
? "等待探测..."
: "实时检测已关闭"}
</span> </span>
)} )}
</div> </div>
@@ -1248,8 +987,7 @@ export function TunnelMonitorView({
aria-label="隧道列表" aria-label="隧道列表"
className="overflow-x-auto min-w-full" className="overflow-x-auto min-w-full"
classNames={{ classNames={{
wrapper: wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors", tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
@@ -1268,11 +1006,7 @@ export function TunnelMonitorView({
const isEnabled = tunnel.status === 1; const isEnabled = tunnel.status === 1;
return ( return (
<TableRow <TableRow key={tunnel.id} className="cursor-pointer" onClick={() => setDetailTunnelId(tunnel.id)}>
key={tunnel.id}
className="cursor-pointer"
onClick={() => setDetailTunnelId(tunnel.id)}
>
<TableCell> <TableCell>
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
{isEnabled ? ( {isEnabled ? (
@@ -1283,23 +1017,13 @@ export function TunnelMonitorView({
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<span className="font-semibold text-sm whitespace-nowrap"> <span className="font-semibold text-sm whitespace-nowrap">{tunnel.name}</span>
{tunnel.name}
</span>
</TableCell> </TableCell>
<TableCell> <TableCell>
<UptimeHistoryBar <UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="entryToExit" latestValue={quality?.entryToExitLatency} />
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.entryToExitLatency}
type="entryToExit"
/>
</TableCell> </TableCell>
<TableCell> <TableCell>
<UptimeHistoryBar <UptimeHistoryBar history={qualityHistoryMap[tunnel.id]} type="exitToBing" latestValue={quality?.exitToBingLatency} />
history={qualityHistoryMap[tunnel.id]}
latestValue={quality?.exitToBingLatency}
type="exitToBing"
/>
</TableCell> </TableCell>
<TableCell> <TableCell>
{quality?.timestamp ? ( {quality?.timestamp ? (
@@ -1309,9 +1033,7 @@ export function TunnelMonitorView({
) : ( ) : (
<WifiOff className="w-3.5 h-3.5 text-warning" /> <WifiOff className="w-3.5 h-3.5 text-warning" />
)} )}
{new Date(quality.timestamp).toLocaleTimeString( {new Date(quality.timestamp).toLocaleTimeString("zh-CN")}
"zh-CN",
)}
</span> </span>
) : ( ) : (
<span className="text-xs text-default-400"> <span className="text-xs text-default-400">
+9 -3
View File
@@ -89,7 +89,7 @@ export default function PanelSharingPage() {
[], [],
); );
const [nodes, setNodes] = useState<Node[]>([]); const [nodes, setNodes] = useState<Node[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [remoteUsageLoading, setRemoteUsageLoading] = useState(false); const [remoteUsageLoading, setRemoteUsageLoading] = useState(false);
// Modals // Modals
@@ -457,7 +457,10 @@ export default function PanelSharingPage() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{shares.map((share) => ( {shares.map((share) => (
<Card key={share.id} className="overflow-hidden"> <Card
key={share.id}
className="overflow-hidden"
>
<CardHeader className="flex justify-between pb-2 md:pb-2"> <CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{share.name}</h3> <h3 className="font-bold">{share.name}</h3>
<div className="flex gap-2"> <div className="flex gap-2">
@@ -601,7 +604,10 @@ export default function PanelSharingPage() {
) : ( ) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{remoteUsageNodes.map((node) => ( {remoteUsageNodes.map((node) => (
<Card key={node.nodeId} className="overflow-hidden"> <Card
key={node.nodeId}
className="overflow-hidden"
>
<CardHeader className="flex justify-between pb-2 md:pb-2"> <CardHeader className="flex justify-between pb-2 md:pb-2">
<h3 className="font-bold">{node.nodeName}</h3> <h3 className="font-bold">{node.nodeName}</h3>
<span className="text-xs text-default-500"> <span className="text-xs text-default-500">
+341
View File
@@ -0,0 +1,341 @@
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
import { Input } from "@/shadcn-bridge/heroui/input";
import { Button } from "@/shadcn-bridge/heroui/button";
import { Card, CardBody } from "@/shadcn-bridge/heroui/card";
import { Select, SelectItem } from "@/shadcn-bridge/heroui/select";
import { Switch } from "@/shadcn-bridge/heroui/switch";
import { reinitializeBaseURL } from "@/api/network";
import { getConfigByName, updateConfig } from "@/api";
import { BackIcon } from "@/components/icons";
import {
type UpdateReleaseChannel,
getUpdateReleaseChannel,
setUpdateReleaseChannel,
} from "@/utils/version-update";
import { isAdmin } from "@/utils/auth";
import {
getPanelAddresses,
savePanelAddress,
setCurrentPanelAddress,
deletePanelAddress,
validatePanelAddress,
} from "@/utils/panel";
interface PanelAddress {
name: string;
address: string;
inx: boolean;
}
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[]>([]);
const [newName, setNewName] = useState("");
const [newAddress, setNewAddress] = useState("");
const [updateChannel, setUpdateChannel] = useState<UpdateReleaseChannel>(
getUpdateReleaseChannel(),
);
const [forwardCompactMode, setForwardCompactMode] = useState(false);
const [forwardCompactModeSaving, setForwardCompactModeSaving] =
useState(false);
const admin = isAdmin();
const setPanelAddressesFunc = (newAddress: PanelAddress[]) => {
setPanelAddresses(newAddress);
};
useEffect(() => {
(window as any).setPanelAddresses = setPanelAddressesFunc;
return () => {
delete (window as any).setPanelAddresses;
};
}, []);
// 加载面板地址列表
const loadPanelAddresses = async () => {
getPanelAddresses();
};
// 添加新面板地址
const addPanelAddress = async () => {
if (!newName.trim() || !newAddress.trim()) {
toast.error("请输入名称和地址");
return;
}
// 验证地址格式
if (!validatePanelAddress(newAddress.trim())) {
toast.error(
"地址格式不正确,请检查:\n• 必须是完整的URL格式\n• 必须以 http:// 或 https:// 开头\n• 支持域名、IPv4、IPv6 地址\n• 端口号范围:1-65535\n• 示例:http://192.168.1.100:3000",
);
return;
}
savePanelAddress(newName.trim(), newAddress.trim());
setNewName("");
setNewAddress("");
toast.success("添加成功");
};
// 设置当前面板地址
const setCurrentPanel = async (name: string) => {
setCurrentPanelAddress(name);
reinitializeBaseURL();
};
// 删除面板地址
const handleDeletePanelAddress = async (name: string) => {
deletePanelAddress(name);
reinitializeBaseURL();
toast.success("删除成功");
};
// 页面加载时获取数据
useEffect(() => {
loadPanelAddresses();
loadForwardCompactMode();
}, []);
const loadForwardCompactMode = async () => {
try {
const res = await getConfigByName(FORWARD_COMPACT_MODE_CONFIG_KEY);
setForwardCompactMode(parseBooleanConfig(res.data?.value, false));
} catch {
setForwardCompactMode(false);
}
};
const handleForwardCompactModeChange = async (enabled: boolean) => {
if (!admin || forwardCompactModeSaving) {
return;
}
const previous = forwardCompactMode;
setForwardCompactMode(enabled);
setForwardCompactModeSaving(true);
try {
const response = await updateConfig(
FORWARD_COMPACT_MODE_CONFIG_KEY,
enabled ? "true" : "false",
);
if (response.code === 0) {
toast.success(`规则页面精简模式已${enabled ? "开启" : "关闭"}`);
window.dispatchEvent(
new CustomEvent("forwardCompactModeChanged", {
detail: { enabled },
}),
);
} else {
setForwardCompactMode(previous);
toast.error(response.msg || "保存精简模式失败");
}
} catch {
setForwardCompactMode(previous);
toast.error("保存精简模式失败");
} finally {
setForwardCompactModeSaving(false);
}
};
const handleUpdateChannelChange = (channel: UpdateReleaseChannel) => {
setUpdateChannel(channel);
setUpdateReleaseChannel(channel);
toast.success(
`更新通道已切换为${channel === "stable" ? "稳定版" : "开发版"}`,
);
};
return (
<div className="min-h-screen bg-gray-50 dark:bg-black">
{/* 顶部导航 */}
<div className="bg-white dark:bg-black border-b border-gray-200 dark:border-gray-700">
<div className="max-w-4xl mx-auto px-4 py-4">
<div className="flex items-center gap-3">
<Button
isIconOnly
aria-label="返回上一页"
className="text-gray-600 dark:text-gray-300"
variant="light"
onPress={() => navigate(-1)}
>
<BackIcon className="w-5 h-5" />
</Button>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
面板设置
</h1>
</div>
</div>
</div>
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
更新设置
</h2>
<div className="space-y-2">
<Select
label="更新通道"
selectedKeys={[updateChannel]}
onSelectionChange={(keys) => {
const selected =
(Array.from(keys)[0] as UpdateReleaseChannel) || "stable";
handleUpdateChannelChange(selected);
}}
>
<SelectItem key="stable" textValue="stable">
稳定版(纯数字版本,如 2.1.4)
</SelectItem>
<SelectItem key="dev" textValue="dev">
开发版(含 alpha / beta / rc)
</SelectItem>
</Select>
<p className="text-xs text-gray-500 dark:text-gray-400">
版本提示会根据该通道检查最新版本。
</p>
</div>
</CardBody>
</Card>
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
显示设置
</h2>
<div className="space-y-3">
<div className="rounded-lg border border-gray-200 dark:border-gray-700 px-4 py-3">
<div className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium text-gray-900 dark:text-white">
规则页面精简模式
</p>
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
开启后,规则页面列表使用 2.1.6-alpha8 样式。{" "}
</p>
</div>
<Switch
color="primary"
isDisabled={!admin || forwardCompactModeSaving}
isSelected={forwardCompactMode}
onValueChange={handleForwardCompactModeChange}
/>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</div>
</CardBody>
</Card>
{/* 添加新地址 */}
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
添加新面板地址
</h2>
<div className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input
label="名称"
placeholder="请输入面板名称"
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<Input
label="地址"
placeholder="http://192.168.1.100:3000"
value={newAddress}
onChange={(e) => setNewAddress(e.target.value)}
/>
</div>
<Button color="primary" onPress={addPanelAddress}>
添加
</Button>
</div>
</CardBody>
</Card>
{/* 地址列表 */}
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
已保存的面板地址
</h2>
{panelAddresses.length === 0 ? (
<p className="text-gray-500 dark:text-gray-400 text-center py-8">
暂无保存的面板地址
</p>
) : (
<div className="space-y-3">
{panelAddresses.map((panel, index) => (
<div
key={index}
className="border border-gray-200 dark:border-gray-600 rounded-lg p-4"
>
<div className="flex items-center justify-between">
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{panel.name}
</span>
{panel.inx && (
<span className="px-2 py-1 bg-green-100 dark:bg-green-500/20 text-green-700 dark:text-green-300 text-xs rounded">
当前
</span>
)}
</div>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
{panel.address}
</p>
</div>
<div className="flex items-center gap-2">
{!panel.inx && (
<Button
color="primary"
size="sm"
variant="flat"
onPress={() => setCurrentPanel(panel.name)}
>
设为当前
</Button>
)}
<Button
color="danger"
size="sm"
variant="light"
onPress={() => handleDeletePanelAddress(panel.name)}
>
删除
</Button>
</div>
</div>
</div>
))}
</div>
)}
</CardBody>
</Card>
</div>
</div>
</div>
);
};
File diff suppressed because it is too large Load Diff
+28 -57
View File
@@ -185,7 +185,7 @@ const normalizeUserTunnelItem = (item: Partial<UserTunnel>): UserTunnel => {
export default function UserPage() { export default function UserPage() {
// 状态管理 // 状态管理
const [users, setUsers] = useState<User[]>([]); const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(false);
const [searchKeyword, setSearchKeyword] = useLocalStorageState( const [searchKeyword, setSearchKeyword] = useLocalStorageState(
"user-search-keyword", "user-search-keyword",
"", "",
@@ -219,7 +219,6 @@ export default function UserPage() {
num: 10, num: 10,
expTime: null, expTime: null,
flowResetTime: 0, flowResetTime: 0,
maxConn: 0,
}); });
const [userFormLoading, setUserFormLoading] = useState(false); const [userFormLoading, setUserFormLoading] = useState(false);
const [quotaResetLoading, setQuotaResetLoading] = useState(false); const [quotaResetLoading, setQuotaResetLoading] = useState(false);
@@ -467,6 +466,10 @@ export default function UserPage() {
void loadMonitorPermissions(); void loadMonitorPermissions();
}, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]); }, [loadMonitorPermissions, loadSpeedLimits, loadTunnels, loadUserGroups]);
useEffect(() => {
void loadUsers();
}, [loadUsers]);
useEffect(() => { useEffect(() => {
if (searchDebounceRef.current) { if (searchDebounceRef.current) {
clearTimeout(searchDebounceRef.current); clearTimeout(searchDebounceRef.current);
@@ -515,7 +518,6 @@ export default function UserPage() {
num: 10, num: 10,
expTime: null, expTime: null,
flowResetTime: 0, flowResetTime: 0,
maxConn: 0,
groupIds: [], groupIds: [],
}); });
onUserModalOpen(); onUserModalOpen();
@@ -1082,8 +1084,7 @@ export default function UserPage() {
aria-label="用户列表" aria-label="用户列表"
className="overflow-x-auto min-w-full" className="overflow-x-auto min-w-full"
classNames={{ classNames={{
wrapper: wrapper: "bg-transparent p-0 shadow-none border-none overflow-hidden rounded-2xl",
"bg-transparent p-0 shadow-none border-none overflow-auto rounded-2xl",
th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]", th: "bg-transparent text-default-600 font-semibold text-sm border-b border-white/20 dark:border-white/10 py-3 uppercase tracking-wider first:rounded-tl-[24px] last:rounded-tr-[24px]",
td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0", td: "py-3 border-b border-divider/50 group-data-[last=true]:border-b-0",
tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors", tr: "hover:bg-white/40 dark:hover:bg-white/10 transition-colors",
@@ -1111,9 +1112,7 @@ export default function UserPage() {
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative"> <div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()} {(user.name || user.user).slice(0, 2).toUpperCase()}
<span <span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
/>
</div> </div>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium text-foreground text-sm"> <span className="font-medium text-foreground text-sm">
@@ -1124,12 +1123,7 @@ export default function UserPage() {
</span> </span>
</div> </div>
{user.disabledByQuota && ( {user.disabledByQuota && (
<Chip <Chip className="text-[10px] h-4 px-1 ml-1" color="danger" size="sm" variant="flat">
className="text-[10px] h-4 px-1 ml-1"
color="danger"
size="sm"
variant="flat"
>
超额 超额
</Chip> </Chip>
)} )}
@@ -1153,24 +1147,17 @@ export default function UserPage() {
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]"> <div className="flex flex-col gap-0.5 whitespace-nowrap text-[11px]">
{(user.dailyQuotaGB ?? 0) > 0 || {((user.dailyQuotaGB ?? 0) > 0 || (user.monthlyQuotaGB ?? 0) > 0) ? (
(user.monthlyQuotaGB ?? 0) > 0 ? ( <>
<> <div className="flex gap-1 justify-between">
<div className="flex gap-1 justify-between"> <span className="text-default-500">日配额:</span>
<span className="text-default-500">日配额:</span> <span>{formatFlow(user.dailyUsedBytes ?? 0)} / {formatQuotaLimit(user.dailyQuotaGB)}</span>
<span> </div>
{formatFlow(user.dailyUsedBytes ?? 0)} /{" "} <div className="flex gap-1 justify-between">
{formatQuotaLimit(user.dailyQuotaGB)} <span className="text-default-500">月配额:</span>
</span> <span>{formatFlow(user.monthlyUsedBytes ?? 0)} / {formatQuotaLimit(user.monthlyQuotaGB)}</span>
</div> </div>
<div className="flex gap-1 justify-between"> </>
<span className="text-default-500">月配额:</span>
<span>
{formatFlow(user.monthlyUsedBytes ?? 0)} /{" "}
{formatQuotaLimit(user.monthlyQuotaGB)}
</span>
</div>
</>
) : ( ) : (
<span className="text-default-400">无配额</span> <span className="text-default-400">无配额</span>
)} )}
@@ -1188,53 +1175,51 @@ export default function UserPage() {
</span> </span>
{expStatus && ( {expStatus && (
<Chip <Chip
className="h-5 px-1 text-[10px]"
color={expStatus.color} color={expStatus.color}
size="sm" size="sm"
variant="flat" variant="flat"
className="h-5 px-1 text-[10px]"
> >
{expStatus.text} {expStatus.text}
</Chip> </Chip>
)} )}
</> </>
) : ( ) : (
<span className="text-default-400 text-sm"> <span className="text-default-400 text-sm">无限期</span>
无限期
</span>
)} )}
</div> </div>
</TableCell> </TableCell>
<TableCell> <TableCell>
<div className="flex flex-wrap items-center gap-1.5 min-w-max"> <div className="flex flex-wrap items-center gap-1.5 min-w-max">
<Button <Button
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
size="sm" size="sm"
variant="flat" variant="flat"
className="h-6 px-2 min-w-0 text-xs bg-emerald-50 text-emerald-600 hover:bg-emerald-100 dark:bg-emerald-950/30 dark:text-emerald-400"
onPress={() => handleManageTunnels(user)} onPress={() => handleManageTunnels(user)}
> >
权限 权限
</Button> </Button>
<Button <Button
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" size="sm"
title="重置流量"
variant="flat" variant="flat"
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"
title="重置流量"
onPress={() => handleResetFlow(user)} onPress={() => handleResetFlow(user)}
> >
重置 重置
</Button> </Button>
<Button <Button
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" size="sm"
variant="flat" variant="flat"
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"
onPress={() => handleEdit(user)} onPress={() => handleEdit(user)}
> >
编辑 编辑
</Button> </Button>
<Button <Button
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" size="sm"
variant="flat" variant="flat"
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"
onPress={() => handleDelete(user)} onPress={() => handleDelete(user)}
> >
删除 删除
@@ -1271,9 +1256,7 @@ export default function UserPage() {
<div className="flex items-center gap-3 flex-1 min-w-0"> <div className="flex items-center gap-3 flex-1 min-w-0">
<div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative"> <div className="flex items-center justify-center shrink-0 w-10 h-10 rounded-full bg-primary text-white font-bold text-sm relative">
{(user.name || user.user).slice(0, 2).toUpperCase()} {(user.name || user.user).slice(0, 2).toUpperCase()}
<span <span className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`} />
className={`absolute bottom-0 right-0 w-3 h-3 border-2 border-white dark:border-zinc-900 rounded-full ${userStatus.color === "success" ? "bg-success" : "bg-danger"}`}
/>
</div> </div>
<div className="flex flex-col min-w-0"> <div className="flex flex-col min-w-0">
<h3 className="font-semibold text-foreground truncate text-sm"> <h3 className="font-semibold text-foreground truncate text-sm">
@@ -1518,17 +1501,6 @@ export default function UserPage() {
setUserForm((prev) => ({ ...prev, flow: value })); setUserForm((prev) => ({ ...prev, flow: value }));
}} }}
/> />
<Input
label="最大连接数"
placeholder="0 或空表示不限制"
type="number"
min="0"
value={userForm.maxConn === 0 ? "" : String(userForm.maxConn || "")}
onChange={(e) => {
const value = Math.max(Number(e.target.value) || 0, 0);
setUserForm((prev) => ({ ...prev, maxConn: value }));
}}
/>
<Input <Input
isRequired isRequired
label="规则数量" label="规则数量"
@@ -1780,7 +1752,7 @@ export default function UserPage() {
流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置 流量限制、规则数量、到期时间、流量重置时间将自动继承用户设置
</div> </div>
<div className="grid gap-2 max-h-72 overflow-y-auto pr-1 scrollbar-hide"> <div className="grid gap-2 max-h-72 overflow-y-auto pr-1">
{tunnels.map((tunnel) => { {tunnels.map((tunnel) => {
const isAssigned = isTunnelAssigned(tunnel.id); const isAssigned = isTunnelAssigned(tunnel.id);
const isSelected = batchTunnelSelections.has(tunnel.id); const isSelected = batchTunnelSelections.has(tunnel.id);
@@ -1939,7 +1911,6 @@ export default function UserPage() {
<TableColumn>规则数量</TableColumn> <TableColumn>规则数量</TableColumn>
<TableColumn>状态</TableColumn> <TableColumn>状态</TableColumn>
<TableColumn>限速规则</TableColumn> <TableColumn>限速规则</TableColumn>
<TableColumn>最大连接</TableColumn>
<TableColumn>重置时间</TableColumn> <TableColumn>重置时间</TableColumn>
<TableColumn>到期时间</TableColumn> <TableColumn>到期时间</TableColumn>
<TableColumn>操作</TableColumn> <TableColumn>操作</TableColumn>
@@ -19,5 +19,10 @@ export function CardHeader({
} }
export function CardBody({ className, ...props }: React.ComponentProps<"div">) { export function CardBody({ className, ...props }: React.ComponentProps<"div">) {
return <CardContent className={cn("p-4 md:p-6", className)} {...props} />; return (
<CardContent
className={cn("p-4 md:p-6", className)}
{...props}
/>
);
} }
@@ -51,11 +51,7 @@ export interface DropdownMenuProps {
export function DropdownMenu({ children, className }: DropdownMenuProps) { export function DropdownMenu({ children, className }: DropdownMenuProps) {
const { align } = React.useContext(DropdownContext); const { align } = React.useContext(DropdownContext);
return ( return <DropdownMenuContent align={align} className={className}>{children}</DropdownMenuContent>;
<DropdownMenuContent align={align} className={className}>
{children}
</DropdownMenuContent>
);
} }
export interface DropdownItemProps { export interface DropdownItemProps {
@@ -3,7 +3,6 @@ import * as React from "react";
import { import {
Dialog, Dialog,
DialogContent as BaseDialogContent, DialogContent as BaseDialogContent,
DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
@@ -160,7 +159,6 @@ export function ModalContent({
showCloseButton={false} showCloseButton={false}
{...props} {...props}
> >
<DialogTitle className="sr-only">Modal Dialog</DialogTitle>
{renderedChildren} {renderedChildren}
</BaseDialogContent> </BaseDialogContent>
); );
@@ -296,7 +296,7 @@ export function Select<T>({
<div <div
ref={listboxRef} ref={listboxRef}
className={cn( className={cn(
"absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56 [&::-webkit-scrollbar]:hidden [-ms-overflow-style:none] [scrollbar-width:none]", "absolute left-0 z-50 w-full space-y-1 overflow-y-auto rounded-md border border-divider bg-background p-2 shadow-md max-h-56",
placementClasses, placementClasses,
)} )}
id={`${generatedId}-listbox`} id={`${generatedId}-listbox`}
@@ -414,8 +414,8 @@ export function Select<T>({
<option value="">{placeholder ?? "请选择"}</option> <option value="">{placeholder ?? "请选择"}</option>
{options.map((option) => ( {options.map((option) => (
<option <option
key={option.key}
className="bg-background text-foreground" className="bg-background text-foreground"
key={option.key}
disabled={disabled.has(option.key)} disabled={disabled.has(option.key)}
value={option.key} value={option.key}
> >
+3 -3
View File
@@ -241,16 +241,16 @@ body {
bottom: 0; bottom: 0;
z-index: -1; z-index: -1;
pointer-events: none; pointer-events: none;
background: url('/images/bg-light.jpg') no-repeat center center; background: url('https://images.unsplash.com/photo-1618005182384-a83a8bd57fbe?q=80&w=2564&auto=format&fit=crop') no-repeat center center;
background-size: cover; background-size: cover;
} }
.dark .bg-mesh-gradient { :root.dark .bg-mesh-gradient {
background-color: #0b1020; background-color: #0b1020;
} }
.dark .bg-mesh-gradient::before { .dark .bg-mesh-gradient::before {
background: url('/images/bg-dark.jpg') no-repeat center center; background: url('https://images.unsplash.com/photo-1557682250-33bd709cbe85?q=80&w=2564&auto=format&fit=crop') no-repeat center center;
background-size: cover; background-size: cover;
} }
+24 -44
View File
@@ -5,8 +5,6 @@
* re-renders when the active theme changes. * re-renders when the active theme changes.
*/ */
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import React, { import React, {
createContext, createContext,
useContext, useContext,
@@ -16,6 +14,7 @@ import React, {
useMemo, useMemo,
} from "react"; } from "react";
import type { ThemePackage, ComponentKey, LayoutKey, PageKey } from "./types";
import { import {
subscribe, subscribe,
getActiveTheme, getActiveTheme,
@@ -60,20 +59,11 @@ interface ThemeContextValue {
unregister: (id: string) => void; unregister: (id: string) => void;
/** Resolve a component (returns themed override or fallback). */ /** Resolve a component (returns themed override or fallback). */
component: <P = any>( component: <P = any>(key: ComponentKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
key: ComponentKey,
fallback: React.ComponentType<P>,
) => React.ComponentType<P>;
/** Resolve a layout. */ /** Resolve a layout. */
layout: ( layout: (key: LayoutKey, fallback: React.ComponentType<{ children: React.ReactNode }>) => React.ComponentType<{ children: React.ReactNode }>;
key: LayoutKey,
fallback: React.ComponentType<{ children: React.ReactNode }>,
) => React.ComponentType<{ children: React.ReactNode }>;
/** Resolve a page. */ /** Resolve a page. */
page: <P = any>( page: <P = any>(key: PageKey, fallback: React.ComponentType<P>) => React.ComponentType<P>;
key: PageKey,
fallback: React.ComponentType<P>,
) => React.ComponentType<P>;
} }
const ThemeContext = createContext<ThemeContextValue | null>(null); const ThemeContext = createContext<ThemeContextValue | null>(null);
@@ -83,7 +73,6 @@ const ThemeContext = createContext<ThemeContextValue | null>(null);
// We use a monotonic counter to create new snapshot references when the // We use a monotonic counter to create new snapshot references when the
// registry notifies. // registry notifies.
let snapshotCounter = 0; let snapshotCounter = 0;
function getSnapshot() { function getSnapshot() {
return snapshotCounter; return snapshotCounter;
} }
@@ -92,7 +81,6 @@ const originalSubscribe = (onStoreChange: () => void) => {
snapshotCounter++; snapshotCounter++;
onStoreChange(); onStoreChange();
}); });
return unsub; return unsub;
}; };
@@ -110,9 +98,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
useEffect(() => { useEffect(() => {
const mq = window.matchMedia("(prefers-color-scheme: dark)"); const mq = window.matchMedia("(prefers-color-scheme: dark)");
const handler = () => reapplyActiveTheme(); const handler = () => reapplyActiveTheme();
mq.addEventListener("change", handler); mq.addEventListener("change", handler);
return () => mq.removeEventListener("change", handler); return () => mq.removeEventListener("change", handler);
}, []); }, []);
@@ -129,27 +115,27 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []); const register = useCallback((pkg: ThemePackage) => registerTheme(pkg), []);
const unregister = useCallback((id: string) => unregisterTheme(id), []); const unregister = useCallback((id: string) => unregisterTheme(id), []);
const value = useMemo<ThemeContextValue>( const value = useMemo<ThemeContextValue>(() => ({
() => ({ activeTheme: getActiveTheme(),
activeTheme: getActiveTheme(), activeThemeId: getActiveThemeId(),
activeThemeId: getActiveThemeId(), themes: getRegisteredThemes(),
themes: getRegisteredThemes(), mode: getSavedMode(),
mode: getSavedMode(), effectiveMode: getEffectiveMode(),
effectiveMode: getEffectiveMode(), switchTheme,
switchTheme, resetTheme,
resetTheme, setMode,
setMode, register,
register, unregister,
unregister, component: resolveComponent,
component: resolveComponent, layout: resolveLayout,
layout: resolveLayout, page: resolvePage,
page: resolvePage, // eslint-disable-next-line react-hooks/exhaustive-deps
}), }), [snapshotCounter, switchTheme, resetTheme, setMode, register, unregister]);
[snapshotCounter, switchTheme, resetTheme, setMode, register, unregister],
);
return ( return (
<ThemeContext.Provider value={value}>{children}</ThemeContext.Provider> <ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
); );
}; };
@@ -158,10 +144,7 @@ export const ThemeProvider: React.FC<ThemeProviderProps> = ({ children }) => {
/** Access the full theme context. */ /** Access the full theme context. */
export function useThemeContext(): ThemeContextValue { export function useThemeContext(): ThemeContextValue {
const ctx = useContext(ThemeContext); const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useThemeContext must be used within <ThemeProvider>");
if (!ctx)
throw new Error("useThemeContext must be used within <ThemeProvider>");
return ctx; return ctx;
} }
@@ -183,7 +166,6 @@ export function useThemedComponent<P = any>(
fallback: React.ComponentType<P>, fallback: React.ComponentType<P>,
): React.ComponentType<P> { ): React.ComponentType<P> {
const ctx = useThemeContext(); const ctx = useThemeContext();
return ctx.component(key, fallback); return ctx.component(key, fallback);
} }
@@ -193,7 +175,6 @@ export function useThemedLayout(
fallback: React.ComponentType<{ children: React.ReactNode }>, fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> { ): React.ComponentType<{ children: React.ReactNode }> {
const ctx = useThemeContext(); const ctx = useThemeContext();
return ctx.layout(key, fallback); return ctx.layout(key, fallback);
} }
@@ -203,6 +184,5 @@ export function useThemedPage<P = any>(
fallback: React.ComponentType<P>, fallback: React.ComponentType<P>,
): React.ComponentType<P> { ): React.ComponentType<P> {
const ctx = useThemeContext(); const ctx = useThemeContext();
return ctx.page(key, fallback); return ctx.page(key, fallback);
} }
@@ -10,11 +10,10 @@
* or you can build a completely new component from scratch. * or you can build a completely new component from scratch.
*/ */
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
import React from "react"; import React from "react";
// Import the original Button's props interface for compatibility // Import the original Button's props interface for compatibility
import type { ButtonProps } from "@/shadcn-bridge/heroui/button";
// Optionally import the original to wrap it // Optionally import the original to wrap it
import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button"; import { Button as OriginalButton } from "@/shadcn-bridge/heroui/button";
@@ -12,7 +12,6 @@
*/ */
import type { ThemePackage } from "../types"; import type { ThemePackage } from "../types";
import { CyberpunkButton } from "./components/button"; import { CyberpunkButton } from "./components/button";
const cyberpunkTheme: ThemePackage = { const cyberpunkTheme: ThemePackage = {
+3 -31
View File
@@ -9,13 +9,7 @@
* integration lives in `./context.tsx`. * integration lives in `./context.tsx`.
*/ */
import type { import type { ThemePackage, ThemeTokens, ComponentKey, LayoutKey, PageKey } from "./types";
ThemePackage,
ThemeTokens,
ComponentKey,
LayoutKey,
PageKey,
} from "./types";
// ─── internal state ────────────────────────────────────────────────────────── // ─── internal state ──────────────────────────────────────────────────────────
@@ -68,7 +62,7 @@ export function getActiveThemeId(): string | null {
/** Get the currently active ThemePackage (or null). */ /** Get the currently active ThemePackage (or null). */
export function getActiveTheme(): ThemePackage | null { export function getActiveTheme(): ThemePackage | null {
return activeId ? (installed.get(activeId) ?? null) : null; return activeId ? installed.get(activeId) ?? null : null;
} }
// ─── theme mode ────────────────────────────────────────────────────────────── // ─── theme mode ──────────────────────────────────────────────────────────────
@@ -77,18 +71,13 @@ export type ThemeMode = "light" | "dark" | "system";
export function resolveSystemMode(): "light" | "dark" { export function resolveSystemMode(): "light" | "dark" {
if (typeof window === "undefined") return "light"; if (typeof window === "undefined") return "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
} }
export function getSavedMode(): ThemeMode { export function getSavedMode(): ThemeMode {
if (typeof window === "undefined") return "system"; if (typeof window === "undefined") return "system";
const raw = localStorage.getItem(MODE_KEY); const raw = localStorage.getItem(MODE_KEY);
if (raw === "light" || raw === "dark" || raw === "system") return raw; if (raw === "light" || raw === "dark" || raw === "system") return raw;
return "system"; return "system";
} }
@@ -99,7 +88,6 @@ export function saveMode(mode: ThemeMode): void {
export function getEffectiveMode(): "light" | "dark" { export function getEffectiveMode(): "light" | "dark" {
const mode = getSavedMode(); const mode = getSavedMode();
return mode === "system" ? resolveSystemMode() : mode; return mode === "system" ? resolveSystemMode() : mode;
} }
@@ -115,10 +103,8 @@ export function getEffectiveMode(): "light" | "dark" {
*/ */
export function activateTheme(id: string): void { export function activateTheme(id: string): void {
const pkg = installed.get(id); const pkg = installed.get(id);
if (!pkg) { if (!pkg) {
console.warn(`[FLVX themes] Theme "${id}" is not registered.`); console.warn(`[FLVX themes] Theme "${id}" is not registered.`);
return; return;
} }
@@ -130,7 +116,6 @@ export function activateTheme(id: string): void {
// Inject tokens // Inject tokens
const mode = getEffectiveMode(); const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light; const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens); if (tokens) injectTokens(tokens);
// Inject custom CSS // Inject custom CSS
@@ -143,7 +128,6 @@ export function activateTheme(id: string): void {
// Update dark class // Update dark class
const root = document.documentElement; const root = document.documentElement;
root.classList.toggle("dark", mode === "dark"); root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode; root.style.colorScheme = mode;
@@ -159,7 +143,6 @@ export function activateTheme(id: string): void {
/** Deactivate the current theme, reverting all overrides. */ /** Deactivate the current theme, reverting all overrides. */
export function deactivateTheme(): void { export function deactivateTheme(): void {
const prev = activeId ? installed.get(activeId) : null; const prev = activeId ? installed.get(activeId) : null;
prev?.onDeactivate?.(); prev?.onDeactivate?.();
// Remove injected tokens // Remove injected tokens
@@ -180,24 +163,20 @@ export function reapplyActiveTheme(): void {
const id = activeId; const id = activeId;
// quick re-inject without full lifecycle // quick re-inject without full lifecycle
const pkg = installed.get(id); const pkg = installed.get(id);
if (!pkg) return; if (!pkg) return;
clearInjectedTokens(); clearInjectedTokens();
const mode = getEffectiveMode(); const mode = getEffectiveMode();
const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light; const tokens = mode === "dark" ? pkg.tokens?.dark : pkg.tokens?.light;
if (tokens) injectTokens(tokens); if (tokens) injectTokens(tokens);
const root = document.documentElement; const root = document.documentElement;
root.classList.toggle("dark", mode === "dark"); root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode; root.style.colorScheme = mode;
} else { } else {
// No theme active — just set dark class based on mode // No theme active — just set dark class based on mode
const mode = getEffectiveMode(); const mode = getEffectiveMode();
const root = document.documentElement; const root = document.documentElement;
root.classList.toggle("dark", mode === "dark"); root.classList.toggle("dark", mode === "dark");
root.style.colorScheme = mode; root.style.colorScheme = mode;
} }
@@ -216,7 +195,6 @@ export function resolveComponent<P = any>(
): React.ComponentType<P> { ): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null; const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.components?.[key]; const override = pkg?.components?.[key];
return (override as React.ComponentType<P>) ?? fallback; return (override as React.ComponentType<P>) ?? fallback;
} }
@@ -226,7 +204,6 @@ export function resolveLayout(
fallback: React.ComponentType<{ children: React.ReactNode }>, fallback: React.ComponentType<{ children: React.ReactNode }>,
): React.ComponentType<{ children: React.ReactNode }> { ): React.ComponentType<{ children: React.ReactNode }> {
const pkg = activeId ? installed.get(activeId) : null; const pkg = activeId ? installed.get(activeId) : null;
return pkg?.layouts?.[key] ?? fallback; return pkg?.layouts?.[key] ?? fallback;
} }
@@ -237,7 +214,6 @@ export function resolvePage<P = any>(
): React.ComponentType<P> { ): React.ComponentType<P> {
const pkg = activeId ? installed.get(activeId) : null; const pkg = activeId ? installed.get(activeId) : null;
const override = pkg?.pages?.[key]; const override = pkg?.pages?.[key];
return (override as React.ComponentType<P>) ?? fallback; return (override as React.ComponentType<P>) ?? fallback;
} }
@@ -245,7 +221,6 @@ export function resolvePage<P = any>(
export function subscribe(listener: ChangeListener): () => void { export function subscribe(listener: ChangeListener): () => void {
changeListeners.add(listener); changeListeners.add(listener);
return () => changeListeners.delete(listener); return () => changeListeners.delete(listener);
} }
@@ -257,7 +232,6 @@ export function subscribe(listener: ChangeListener): () => void {
*/ */
export function initThemeSystem(): void { export function initThemeSystem(): void {
const savedId = localStorage.getItem(STORAGE_KEY); const savedId = localStorage.getItem(STORAGE_KEY);
if (savedId && installed.has(savedId)) { if (savedId && installed.has(savedId)) {
activateTheme(savedId); activateTheme(savedId);
} else { } else {
@@ -272,7 +246,6 @@ const injectedVars: string[] = [];
function injectTokens(tokens: ThemeTokens): void { function injectTokens(tokens: ThemeTokens): void {
const root = document.documentElement; const root = document.documentElement;
for (const [varName, value] of Object.entries(tokens)) { for (const [varName, value] of Object.entries(tokens)) {
if (value !== undefined) { if (value !== undefined) {
root.style.setProperty(varName, value); root.style.setProperty(varName, value);
@@ -283,7 +256,6 @@ function injectTokens(tokens: ThemeTokens): void {
function clearInjectedTokens(): void { function clearInjectedTokens(): void {
const root = document.documentElement; const root = document.documentElement;
for (const varName of injectedVars) { for (const varName of injectedVars) {
root.style.removeProperty(varName); root.style.removeProperty(varName);
} }
+2 -8
View File
@@ -182,11 +182,7 @@ export type ComponentKey =
* All overridable layout keys. * All overridable layout keys.
* Layouts receive `{ children: React.ReactNode }` as props. * Layouts receive `{ children: React.ReactNode }` as props.
*/ */
export type LayoutKey = export type LayoutKey = "AdminLayout" | "H5Layout" | "H5SimpleLayout" | "DefaultLayout";
| "AdminLayout"
| "H5Layout"
| "H5SimpleLayout"
| "DefaultLayout";
/** /**
* All overridable page keys. * All overridable page keys.
@@ -287,9 +283,7 @@ export interface ThemePackage {
/** /**
* Map of layout overrides. Each layout receives `{ children }`. * Map of layout overrides. Each layout receives `{ children }`.
*/ */
layouts?: Partial< layouts?: Partial<Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>>;
Record<LayoutKey, ComponentType<{ children: React.ReactNode }>>
>;
/** /**
* Map of page overrides. Each page is a full route-level component. * Map of page overrides. Each page is a full route-level component.
+1 -4
View File
@@ -44,7 +44,6 @@ export interface UserForm {
num: number; num: number;
expTime: Date | null; expTime: Date | null;
flowResetTime: number; flowResetTime: number;
maxConn?: number;
groupIds?: number[]; groupIds?: number[];
} }
@@ -57,8 +56,7 @@ export interface UserTunnel {
flow: number; // 流量限制(GB) flow: number; // 流量限制(GB)
num: number; // 转发数量 num: number; // 转发数量
expTime: number; // 过期时间戳 expTime: number; // 过期时间戳
flowResetTime: number; flowResetTime: number; // 流量重置日期
maxConn?: number; // 流量重置日期
speedId?: number | null; // 限速规则ID speedId?: number | null; // 限速规则ID
speedLimitName?: string; // 限速规则名称 speedLimitName?: string; // 限速规则名称
inFlow?: number; // 下载流量(字节) inFlow?: number; // 下载流量(字节)
@@ -72,7 +70,6 @@ export interface UserTunnelForm {
num: number; num: number;
expTime: Date | null; expTime: Date | null;
flowResetTime: number; flowResetTime: number;
maxConn?: number;
speedId: number | null; speedId: number | null;
} }

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