mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-11-16 01:24:24 +08:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1e3b38f427 | |||
| 2166936f57 | |||
| 53e1efcdc1 | |||
| 71d5c36ec3 | |||
| be4b1f5f25 | |||
| cd4238274a | |||
| 25dc03c894 | |||
| 4cf9c9174a | |||
| a45bd43e73 | |||
| 364bace87f | |||
| 2caafab937 | |||
| 901b903a70 | |||
| 930cc86614 | |||
| 101fd5805a | |||
| 938e9ff4fd |
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
@@ -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 {
|
||||||
|
|||||||
@@ -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))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
gost
|
|
||||||
gost_*
|
|
||||||
gost-*
|
|
||||||
*.sha256
|
|
||||||
*.exe
|
|
||||||
+34
-18
@@ -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
@@ -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
@@ -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
@@ -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 ./...
|
||||||
|
```
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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=
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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{}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
-9002
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
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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" }
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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" />}
|
||||||
|
|||||||
@@ -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's what'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}
|
||||||
>
|
>
|
||||||
@@ -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"
|
||||||
@@ -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,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);
|
||||||
@@ -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",
|
||||||
@@ -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",
|
||||||
@@ -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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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>
|
||||||
)
|
)
|
||||||
) : (
|
) : (
|
||||||
/* 空状态 */
|
/* 空状态 */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
};
|
||||||
+439
-542
File diff suppressed because it is too large
Load Diff
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
Reference in New Issue
Block a user