From d3ed2e8856772142ce76aebc7a960f051dbc4c3c Mon Sep 17 00:00:00 2001 From: sagitchu Date: Thu, 19 Mar 2026 14:16:07 +0800 Subject: [PATCH 1/2] chore: Delete openspec documentation, AI agent configurations, and development plans. --- .agents/skills/security-scan/SKILL.md | 165 ------------------ .claude/settings.json | 84 --------- .claude/skills/security-scan | 1 - .sisyphus/plans/001-setup-dev-environment.md | 71 -------- 211-custom-ip-selection.md | 33 ---- IMPLEMENTATION_PLAN.md | 148 ---------------- .../document-existing-specs/.openspec.yaml | 2 - .../changes/document-existing-specs/design.md | 29 --- .../document-existing-specs/proposal.md | 28 --- .../specs/agent-management/spec.md | 29 --- .../specs/forwarding-rules/spec.md | 22 --- .../specs/system-config/spec.md | 22 --- .../specs/tunnel-management/spec.md | 22 --- .../specs/user-management/spec.md | 29 --- .../changes/document-existing-specs/tasks.md | 30 ---- openspec/config.yaml | 20 --- openspec/project.md | 52 ------ skills-lock.json | 10 -- 18 files changed, 797 deletions(-) delete mode 100644 .agents/skills/security-scan/SKILL.md delete mode 100644 .claude/settings.json delete mode 120000 .claude/skills/security-scan delete mode 100644 .sisyphus/plans/001-setup-dev-environment.md delete mode 100644 211-custom-ip-selection.md delete mode 100644 IMPLEMENTATION_PLAN.md delete mode 100644 openspec/changes/document-existing-specs/.openspec.yaml delete mode 100644 openspec/changes/document-existing-specs/design.md delete mode 100644 openspec/changes/document-existing-specs/proposal.md delete mode 100644 openspec/changes/document-existing-specs/specs/agent-management/spec.md delete mode 100644 openspec/changes/document-existing-specs/specs/forwarding-rules/spec.md delete mode 100644 openspec/changes/document-existing-specs/specs/system-config/spec.md delete mode 100644 openspec/changes/document-existing-specs/specs/tunnel-management/spec.md delete mode 100644 openspec/changes/document-existing-specs/specs/user-management/spec.md delete mode 100644 openspec/changes/document-existing-specs/tasks.md delete mode 100644 openspec/config.yaml delete mode 100644 openspec/project.md delete mode 100644 skills-lock.json diff --git a/.agents/skills/security-scan/SKILL.md b/.agents/skills/security-scan/SKILL.md deleted file mode 100644 index dba372e..0000000 --- a/.agents/skills/security-scan/SKILL.md +++ /dev/null @@ -1,165 +0,0 @@ ---- -name: security-scan -description: Scan your Claude Code configuration (.claude/ directory) for security vulnerabilities, misconfigurations, and injection risks using AgentShield. Checks CLAUDE.md, settings.json, MCP servers, hooks, and agent definitions. -origin: ECC ---- - -# Security Scan Skill - -Audit your Claude Code configuration for security issues using [AgentShield](https://github.com/affaan-m/agentshield). - -## When to Activate - -- Setting up a new Claude Code project -- After modifying `.claude/settings.json`, `CLAUDE.md`, or MCP configs -- Before committing configuration changes -- When onboarding to a new repository with existing Claude Code configs -- Periodic security hygiene checks - -## What It Scans - -| File | Checks | -|------|--------| -| `CLAUDE.md` | Hardcoded secrets, auto-run instructions, prompt injection patterns | -| `settings.json` | Overly permissive allow lists, missing deny lists, dangerous bypass flags | -| `mcp.json` | Risky MCP servers, hardcoded env secrets, npx supply chain risks | -| `hooks/` | Command injection via interpolation, data exfiltration, silent error suppression | -| `agents/*.md` | Unrestricted tool access, prompt injection surface, missing model specs | - -## Prerequisites - -AgentShield must be installed. Check and install if needed: - -```bash -# Check if installed -npx ecc-agentshield --version - -# Install globally (recommended) -npm install -g ecc-agentshield - -# Or run directly via npx (no install needed) -npx ecc-agentshield scan . -``` - -## Usage - -### Basic Scan - -Run against the current project's `.claude/` directory: - -```bash -# Scan current project -npx ecc-agentshield scan - -# Scan a specific path -npx ecc-agentshield scan --path /path/to/.claude - -# Scan with minimum severity filter -npx ecc-agentshield scan --min-severity medium -``` - -### Output Formats - -```bash -# Terminal output (default) — colored report with grade -npx ecc-agentshield scan - -# JSON — for CI/CD integration -npx ecc-agentshield scan --format json - -# Markdown — for documentation -npx ecc-agentshield scan --format markdown - -# HTML — self-contained dark-theme report -npx ecc-agentshield scan --format html > security-report.html -``` - -### Auto-Fix - -Apply safe fixes automatically (only fixes marked as auto-fixable): - -```bash -npx ecc-agentshield scan --fix -``` - -This will: -- Replace hardcoded secrets with environment variable references -- Tighten wildcard permissions to scoped alternatives -- Never modify manual-only suggestions - -### Opus 4.6 Deep Analysis - -Run the adversarial three-agent pipeline for deeper analysis: - -```bash -# Requires ANTHROPIC_API_KEY -export ANTHROPIC_API_KEY=your-key -npx ecc-agentshield scan --opus --stream -``` - -This runs: -1. **Attacker (Red Team)** — finds attack vectors -2. **Defender (Blue Team)** — recommends hardening -3. **Auditor (Final Verdict)** — synthesizes both perspectives - -### Initialize Secure Config - -Scaffold a new secure `.claude/` configuration from scratch: - -```bash -npx ecc-agentshield init -``` - -Creates: -- `settings.json` with scoped permissions and deny list -- `CLAUDE.md` with security best practices -- `mcp.json` placeholder - -### GitHub Action - -Add to your CI pipeline: - -```yaml -- uses: affaan-m/agentshield@v1 - with: - path: '.' - min-severity: 'medium' - fail-on-findings: true -``` - -## Severity Levels - -| Grade | Score | Meaning | -|-------|-------|---------| -| A | 90-100 | Secure configuration | -| B | 75-89 | Minor issues | -| C | 60-74 | Needs attention | -| D | 40-59 | Significant risks | -| F | 0-39 | Critical vulnerabilities | - -## Interpreting Results - -### Critical Findings (fix immediately) -- Hardcoded API keys or tokens in config files -- `Bash(*)` in the allow list (unrestricted shell access) -- Command injection in hooks via `${file}` interpolation -- Shell-running MCP servers - -### High Findings (fix before production) -- Auto-run instructions in CLAUDE.md (prompt injection vector) -- Missing deny lists in permissions -- Agents with unnecessary Bash access - -### Medium Findings (recommended) -- Silent error suppression in hooks (`2>/dev/null`, `|| true`) -- Missing PreToolUse security hooks -- `npx -y` auto-install in MCP server configs - -### Info Findings (awareness) -- Missing descriptions on MCP servers -- Prohibitive instructions correctly flagged as good practice - -## Links - -- **GitHub**: [github.com/affaan-m/agentshield](https://github.com/affaan-m/agentshield) -- **npm**: [npmjs.com/package/ecc-agentshield](https://www.npmjs.com/package/ecc-agentshield) diff --git a/.claude/settings.json b/.claude/settings.json deleted file mode 100644 index 5cfa585..0000000 --- a/.claude/settings.json +++ /dev/null @@ -1,84 +0,0 @@ -{ - "hooks": { - "PostToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code post-task" - } - ] - }, - { - "matcher": "TodoWrite", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code post-todo" - } - ] - } - ], - "PreToolUse": [ - { - "matcher": "Task", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code pre-task" - } - ] - } - ], - "SessionEnd": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code session-end" - } - ] - } - ], - "SessionStart": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code session-start" - } - ] - } - ], - "Stop": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code stop" - } - ] - } - ], - "UserPromptSubmit": [ - { - "matcher": "", - "hooks": [ - { - "type": "command", - "command": "entire hooks claude-code user-prompt-submit" - } - ] - } - ] - }, - "permissions": { - "deny": [ - "Read(./.entire/metadata/**)" - ] - } -} diff --git a/.claude/skills/security-scan b/.claude/skills/security-scan deleted file mode 120000 index fbee699..0000000 --- a/.claude/skills/security-scan +++ /dev/null @@ -1 +0,0 @@ -../../.agents/skills/security-scan \ No newline at end of file diff --git a/.sisyphus/plans/001-setup-dev-environment.md b/.sisyphus/plans/001-setup-dev-environment.md deleted file mode 100644 index 5e99166..0000000 --- a/.sisyphus/plans/001-setup-dev-environment.md +++ /dev/null @@ -1,71 +0,0 @@ -# Plan: 搭建开发环境 - -## 目标 -为 Flux Panel 项目安装所有缺失的开发依赖,使 3 个子项目都能本地开发和构建。 - -## 当前状态 - -### ✅ 已安装 -| 工具 | 版本 | 用途 | -|------|------|------| -| Node.js | v20.19.2 | vite-frontend | -| npm | 9.2.0 | vite-frontend | -| Go | 1.24.4 | go-gost | -| Docker | 29.1.4 | 容器化部署 | - -### ❌ 缺失 -| 工具 | 需求版本 | 用途 | -|------|----------|------| -| Java | 21 | springboot-backend | -| Maven | 3.x | 构建后端 | -| Docker Compose | v2 | 容器编排 | - ---- - -## 执行任务 - -### Task 1: 安装 Java 21 -```bash -apt-get update && apt-get install -y openjdk-21-jdk -``` -**验证**: `java -version` 应显示 openjdk 21 - -### Task 2: 安装 Maven -```bash -apt-get install -y maven -``` -**验证**: `mvn -v` 应显示 Maven 3.x - -### Task 3: 安装 Docker Compose Plugin -```bash -apt-get install -y docker-compose-plugin -``` -**验证**: `docker compose version` 应显示版本号 - -### Task 4: 安装前端依赖 -```bash -cd /root/flux-panel/vite-frontend && npm install -``` -**验证**: `node_modules/` 目录存在 - -### Task 5: 验证后端可构建 -```bash -cd /root/flux-panel/springboot-backend && mvn clean compile -q -``` -**验证**: 编译成功无错误 - -### Task 6: 验证 Go 模块 -```bash -cd /root/flux-panel/go-gost && go mod download -``` -**验证**: 依赖下载成功 - ---- - -## 完成标准 -- [ ] `java -version` → openjdk 21 -- [ ] `mvn -v` → Maven 3.x -- [ ] `docker compose version` → v2.x -- [ ] 前端: `npm run dev` 可启动 -- [ ] 后端: `mvn compile` 成功 -- [ ] Go: `go build .` 成功 diff --git a/211-custom-ip-selection.md b/211-custom-ip-selection.md deleted file mode 100644 index 1ae1714..0000000 --- a/211-custom-ip-selection.md +++ /dev/null @@ -1,33 +0,0 @@ -# Issue #211: 转发自定义监听IP / 隧道指定连接IP - -## 需求总结 -1. **节点**: 高级配置增加"额外IP地址"字段(逗号分隔) -2. **转发**: 创建/编辑时可指定入口监听IP -3. **隧道**: 配置出口节点时可指定连接IP - ---- - -## 任务清单 - -### 后端 -- [x] 1. 数据模型扩展 - Node/ForwardPort/ChainTunnel 增加字段 -- [x] 2. Repository - CreateNode/UpdateNode 处理 extraIPs -- [x] 3. Repository - resolveForwardIngress 使用 forward_port.in_ip -- [x] 4. Repository - GetNodeAllIPs 辅助函数(返回节点所有可用IP) -- [x] 5. Handler - 转发创建/更新处理 inIp 参数 -- [x] 6. Handler - 隧道出口节点处理 connectIp 参数 -- [x] 7. Handler - 节点API返回 extraIPs 字段 - -### 前端 -- [x] 8. 节点编辑页 - 高级配置增加"额外IP"输入 -- [x] 9. 转发编辑弹窗 - 增加"监听IP"下拉选择 -- [x] 10. 隧道配置页 - 出口节点增加"连接IP"输入 - ---- - -## 完成进度 -- 开始时间: 2026-03-02 -- 完成时间: 2026-03-02 -- 完成任务: 10/10 -- 后端完成: ✅ -- 前端完成: ✅ diff --git a/IMPLEMENTATION_PLAN.md b/IMPLEMENTATION_PLAN.md deleted file mode 100644 index ec1b3ff..0000000 --- a/IMPLEMENTATION_PLAN.md +++ /dev/null @@ -1,148 +0,0 @@ -# 限速功能重构实施计划 - -## 一、需求概述 - -**原始需求**: 限速功能当前绑定到具体隧道,需要改为不绑定隧道,创建限速后可以自由在隧道上限速,也可以在转发上限速。 - -**核心变更**: -1. 限速规则(SpeedLimit)与隧道的绑定关系改为可选 -2. 转发(Forward)支持独立的限速规则 - ---- - -## 二、实施计划清单 - -### 2.0 计划状态(审计更新:2026-02-26) - -- 总体状态:**进行中(未验收通过)** -- 已完成:模型、仓储查询、限速 CRUD、控制面优先级、限速页与类型改造、编译与测试通过 -- 未完成:**Forward 独立限速写入链路**(前端表单 -> API handler -> repository 落库 `forward.speed_id`) - -### 2.1 后端模型层 (Model) - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| M1 | SpeedLimit.TunnelID 改为 sql.NullInt64 (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 | -| M2 | SpeedLimit.TunnelName 改为 sql.NullString (可空) | `go-backend/internal/store/model/model.go` | ✅ 完成 | -| M3 | Forward 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 | -| M4 | ForwardRecord 添加 SpeedID sql.NullInt64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 | -| M5 | SpeedLimitBackup.TunnelID 改为指针类型 | `go-backend/internal/store/model/model.go` | ✅ 完成 | -| M6 | ForwardBackup 添加 SpeedID *int64 字段 | `go-backend/internal/store/model/model.go` | ✅ 完成 | - -### 2.2 后端仓储层 (Repository) - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| R1 | ListSpeedLimits() 返回可空 tunnelId/tunnelName | `go-backend/internal/store/repo/repository.go` | ✅ 完成 | -| R2 | ListForwards() 返回 speedId 字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 | -| R3 | CreateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 | -| R4 | UpdateSpeedLimit() 参数 tunnelID 改为 *int64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 | -| R5 | GetSpeedLimitTunnelID() 返回 sql.NullInt64 | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 | -| R6 | exportSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 | -| R7 | importSpeedLimits() 处理可空字段 | `go-backend/internal/store/repo/repository.go` | ✅ 完成 | -| R8 | GetSpeedLimitSpeed() 新增方法 | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 | -| R9 | ListForwardsByTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_control.go` | ✅ 完成 | -| R10 | ListActiveForwardsByUser() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 | -| R11 | ListActiveForwardsByUserTunnel() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 | -| R12 | GetForwardRecord() 返回 SpeedID | `go-backend/internal/store/repo/repository_flow.go` | ✅ 完成 | - -### 2.3 后端处理器层 (Handler) - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| H1 | speedLimitCreate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 | -| H2 | speedLimitUpdate 处理可选 tunnelId | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 | -| H3 | speedLimitDelete 处理可空 tunnelID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 | - -### 2.4 后端控制平面 (Control Plane) - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| C1 | syncForwardServices 优先使用 Forward.SpeedID | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 | -| C2 | 回退到 UserTunnel 的 speed limit | `go-backend/internal/http/handler/control_plane.go` | ✅ 完成 | - -### 2.5 前端类型定义 (TypeScript Types) - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| T1 | SpeedLimitApiItem.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 | -| T2 | ForwardApiItem 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 | -| T3 | ForwardMutationPayload 添加 speedId 字段 | `vite-frontend/src/api/types.ts` | ✅ 完成 | -| T4 | SpeedLimitMutationPayload.tunnelId 改为可选 | `vite-frontend/src/api/types.ts` | ✅ 完成 | - -### 2.6 前端页面组件 - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| F1 | SpeedLimitRule 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 | -| F2 | SpeedLimitForm 接口更新 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 | -| F3 | validateForm 移除 tunnelId 必填校验 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 | -| F4 | Select 组件改为可选 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 | -| F5 | 显示"未绑定"状态 | `vite-frontend/src/pages/limit.tsx` | ✅ 完成 | - -### 2.7 编译验证 - -| 序号 | 任务 | 状态 | -|------|------|------| -| B1 | Go 后端编译通过 | ✅ 完成 | -| B2 | TypeScript 类型检查通过 | ✅ 完成 | -| B3 | `go test ./...` 全量通过 | ✅ 完成 | -| B4 | `go test ./tests/contract/... -run SpeedLimit` 通过 | ✅ 完成 | - -### 2.8 Forward 独立限速写入链路补全(新增) - -| 序号 | 任务 | 文件 | 状态 | -|------|------|------|------| -| N1 | forwardCreate 支持接收并校验可选 speedId,写入 Forward.SpeedID | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 | -| N2 | forwardUpdate 支持更新/清空 speedId,并触发服务重下发 | `go-backend/internal/http/handler/mutations.go` | ✅ 完成 | -| N3 | CreateForwardTx 支持落库 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 | -| N4 | UpdateForward 支持更新 speed_id | `go-backend/internal/store/repo/repository_mutations.go` | ✅ 完成 | -| N5 | Forward 页面新增限速选择并透传 speedId | `vite-frontend/src/pages/forward.tsx` | ✅ 完成 | -| N6 | Forward 相关契约测试补充 speedId 写入/清空断言 | `go-backend/tests/contract/forward_contract_test.go` | ✅ 完成 | - ---- - -## 三、优先级说明 - -限速规则应用优先级: -1. **Forward.SpeedID** - 转发级别的限速 (最高优先) -2. **UserTunnel.SpeedID** - 用户隧道权限级别的限速 (回退) - ---- - -## 四、数据库兼容性 - -- SpeedLimit 表: `tunnel_id` 和 `tunnel_name` 字段改为可空 (GORM AutoMigrate 自动处理) -- Forward 表: 新增 `speed_id` 可空字段 (GORM AutoMigrate 自动处理) - ---- - -## 五、验证检查项 - -### 5.1 功能验证(审计后) - -- [x] 创建不限速规则的限速 (不绑定隧道) -- [x] 创建绑定隧道的限速 (兼容旧逻辑) -- [x] 编辑限速规则,切换隧道绑定状态 -- [ ] 删除限速规则 -- [ ] 转发列表正确显示 speedId - -### 5.2 API 验证(审计后) - -- [x] GET /api/speed-limit/list 返回可选 tunnelId -- [x] POST /api/speed-limit/create 接受可选 tunnelId -- [x] POST /api/speed-limit/update 接受可选 tunnelId -- [ ] GET /api/forward/list 返回 speedId - -### 5.3 兼容性验证(审计后) - -- [x] 现有绑定隧道的限速规则继续正常工作 -- [ ] 现有 UserTunnel 的限速继续正常工作 -- [ ] 备份/恢复功能正常 - -### 5.4 Forward 独立限速闭环验证(新增) - -- [x] POST /api/forward/create 接受 speedId 并写入 `forward.speed_id` -- [x] POST /api/forward/update 可更新/清空 speedId -- [x] Forward 表单可选择限速并提交 speedId -- [ ] `syncForwardServices` 实际使用 Forward.SpeedID 而非仅回退 UserTunnel.SpeedID diff --git a/openspec/changes/document-existing-specs/.openspec.yaml b/openspec/changes/document-existing-specs/.openspec.yaml deleted file mode 100644 index c8d3976..0000000 --- a/openspec/changes/document-existing-specs/.openspec.yaml +++ /dev/null @@ -1,2 +0,0 @@ -schema: spec-driven -created: 2026-02-17 diff --git a/openspec/changes/document-existing-specs/design.md b/openspec/changes/document-existing-specs/design.md deleted file mode 100644 index 872fdff..0000000 --- a/openspec/changes/document-existing-specs/design.md +++ /dev/null @@ -1,29 +0,0 @@ -## Context - -FLVX is a distributed system consisting of a central management panel (Backend + Frontend) and multiple forwarding agents (Nodes). The backend manages configuration, users, and billing, while agents handle the actual traffic forwarding using a modified GOST v3 stack. Communication between the panel and agents is secured and synchronized. - -## Goals / Non-Goals - -**Goals:** -- Document the high-level architecture of the system. -- Describe the data model for users, tunnels, and nodes. -- Explain the communication protocol between Panel and Agent. -- Detail the authentication and authorization mechanisms. - -**Non-Goals:** -- Refactoring the existing architecture. -- Detailed code-level documentation of every function. -- Changing the database schema. - -## Decisions - -- **Architecture**: The system follows a client-server model where the Panel acts as the server and Agents act as clients that pull configuration and push status. -- **Data Model**: Core entities are Users, Nodes (Agents), Tunnels (Groups of rules), and Forwarding Rules. -- **Communication**: Agents use a heartbeat mechanism to report status and fetch configuration updates. The protocol uses AES encryption with a pre-shared key (Node Secret). -- **Authentication**: JWT for Frontend-Backend communication; API Key (Node Secret) for Agent-Backend communication. - -## Risks / Trade-offs - -- **Security**: The security of the agent communication relies heavily on the secrecy of the Node Secret. -- **Scalability**: Centralized management might become a bottleneck with a very large number of agents. -- **Complexity**: Synchronizing state across distributed agents introduces complexity in handling failures and inconsistencies. diff --git a/openspec/changes/document-existing-specs/proposal.md b/openspec/changes/document-existing-specs/proposal.md deleted file mode 100644 index c90d106..0000000 --- a/openspec/changes/document-existing-specs/proposal.md +++ /dev/null @@ -1,28 +0,0 @@ -## Why - -The current system lacks formal specification documents describing its capabilities. This makes it difficult for new developers to understand the intended behavior and for existing developers to ensure consistency when adding new features. Documenting the existing functionality will serve as a baseline for future changes and help in identifying gaps or inconsistencies. - -## What Changes - -- Create formal specification documents for core system capabilities. -- Document user management features (roles, limits). -- Document tunnel and forwarding management (protocols, rules). -- Document agent interactions and management. -- Document system-level configurations. - -## Capabilities - -### New Capabilities -- `user-management`: Authentication, user roles, and resource limits. -- `tunnel-management`: Creation and management of traffic tunnels (TCP/UDP). -- `forwarding-rules`: Configuration of port forwarding and tunnel forwarding rules, including rate limiting. -- `agent-management`: Management of forwarding agents, including installation and configuration synchronization. -- `system-config`: Global system settings and configurations. - -### Modified Capabilities - - -## Impact - -- **Documentation**: New spec files in `openspec/specs/`. -- **No Code Changes**: This change is purely documentation-focused. diff --git a/openspec/changes/document-existing-specs/specs/agent-management/spec.md b/openspec/changes/document-existing-specs/specs/agent-management/spec.md deleted file mode 100644 index ac9594b..0000000 --- a/openspec/changes/document-existing-specs/specs/agent-management/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## ADDED Requirements - -### Requirement: Agent Registration -The system SHALL require new agents (Nodes) to register using a unique node key/secret. - -#### Scenario: Node Connection -- **WHEN** a new agent starts up with a valid configuration -- **THEN** it connects to the backend and is registered as active. - -### Requirement: Heartbeat Monitoring -The system SHALL monitor the status of all registered agents using periodic heartbeats. - -#### Scenario: Agent Status -- **WHEN** an agent sends periodic heartbeats -- **THEN** the system updates its last-seen timestamp and marks it as online. - -### Requirement: Configuration Sync -The system MUST synchronize configuration changes (tunnels, rules) to agents securely and reliably. - -#### Scenario: Push Config -- **WHEN** a configuration change is made in the panel -- **THEN** the agent receives the updated configuration via the next heartbeat or push mechanism. - -### Requirement: Version Management -The system SHOULD track the version of the agent software running on each node. - -#### Scenario: Version Reporting -- **WHEN** an agent connects -- **THEN** it reports its version number to the backend for tracking. diff --git a/openspec/changes/document-existing-specs/specs/forwarding-rules/spec.md b/openspec/changes/document-existing-specs/specs/forwarding-rules/spec.md deleted file mode 100644 index b350b4a..0000000 --- a/openspec/changes/document-existing-specs/specs/forwarding-rules/spec.md +++ /dev/null @@ -1,22 +0,0 @@ -## ADDED Requirements - -### Requirement: Port Forwarding Rules -The system SHALL support configuring port forwarding rules, defining the listening port on the node and the destination IP/port. - -#### Scenario: Rule Configuration -- **WHEN** an admin creates a port forwarding rule -- **THEN** the rule is stored and synchronized to the assigned node. - -### Requirement: Rate Limiting -The system SHALL support configuring bandwidth rate limits for tunnels and users. - -#### Scenario: Bandwidth Restriction -- **WHEN** a rate limit is applied to a user -- **THEN** their total bandwidth usage does not exceed the specified limit across all their tunnels. - -### Requirement: Traffic Accounting -The system MUST track incoming and outgoing traffic volume for each tunnel and user for billing and quota enforcement. - -#### Scenario: Traffic Calculation -- **WHEN** traffic flows through a tunnel -- **THEN** the system increments the user's traffic usage counter accurately. diff --git a/openspec/changes/document-existing-specs/specs/system-config/spec.md b/openspec/changes/document-existing-specs/specs/system-config/spec.md deleted file mode 100644 index 5ed2617..0000000 --- a/openspec/changes/document-existing-specs/specs/system-config/spec.md +++ /dev/null @@ -1,22 +0,0 @@ -## ADDED Requirements - -### Requirement: Site Settings -The system SHALL allow customization of the site title, logo, and other branding elements. - -#### Scenario: Update Branding -- **WHEN** an administrator changes the site logo -- **THEN** the new logo is displayed across the interface. - -### Requirement: Notification Settings -The system SHALL support configuring notifications for user registration, traffic limits, and other events. - -#### Scenario: User Limit Alert -- **WHEN** a user approaches their traffic quota -- **THEN** a notification is sent to the user/admin. - -### Requirement: Backup & Restore -The system SHOULD provide a mechanism to backup and restore database configurations. - -#### Scenario: Restore Database -- **WHEN** initiating a restore operation -- **THEN** the system accepts a valid backup file and overwrites the current database state. diff --git a/openspec/changes/document-existing-specs/specs/tunnel-management/spec.md b/openspec/changes/document-existing-specs/specs/tunnel-management/spec.md deleted file mode 100644 index f55658f..0000000 --- a/openspec/changes/document-existing-specs/specs/tunnel-management/spec.md +++ /dev/null @@ -1,22 +0,0 @@ -## ADDED Requirements - -### Requirement: Tunnel Creation -The system SHALL allow administrators to create tunnels, specifying protocols (TCP, UDP), listening ports, and destination endpoints. - -#### Scenario: Create TCP Tunnel -- **WHEN** an admin creates a new TCP tunnel configuration -- **THEN** the backend stores the tunnel definition and assigns it to a node. - -### Requirement: Tunnel Forwarding Configuration -The system SHALL support both standard port forwarding (listening on a port and forwarding to a destination) and tunnel forwarding modes. - -#### Scenario: Configure Port Forwarding -- **WHEN** configuring a tunnel for port forwarding -- **THEN** traffic arriving at the specified port is forwarded to the destination IP:port. - -### Requirement: Tunnel Assignment -The system SHALL allow tunnels to be assigned to specific users, tracking their usage against the user's quota. - -#### Scenario: User Tunnel Usage -- **WHEN** a user is assigned a tunnel -- **THEN** traffic passing through that tunnel is accounted for under the user's usage. diff --git a/openspec/changes/document-existing-specs/specs/user-management/spec.md b/openspec/changes/document-existing-specs/specs/user-management/spec.md deleted file mode 100644 index 02eeeda..0000000 --- a/openspec/changes/document-existing-specs/specs/user-management/spec.md +++ /dev/null @@ -1,29 +0,0 @@ -## ADDED Requirements - -### Requirement: User Registration -The system SHALL allow new users to register an account with a username and password. - -#### Scenario: Successful Registration -- **WHEN** a user submits valid registration details -- **THEN** a new user account is created and the user can log in. - -### Requirement: User Authentication -The system MUST authenticate users using JWT tokens. The `Authorization` header MUST contain the raw token without a `Bearer` prefix. - -#### Scenario: Valid Login -- **WHEN** a user provides correct credentials -- **THEN** the system returns a valid JWT token. - -### Requirement: Role Management -The system SHALL support different user roles, specifically Administrator and Regular User, with distinct permissions. - -#### Scenario: Admin Access -- **WHEN** an administrator logs in -- **THEN** they have access to system-wide settings and all user management functions. - -### Requirement: Resource Quotas -The system SHALL allow administrators to set traffic limits and connection limits for individual users. - -#### Scenario: Traffic Limit Enforcement -- **WHEN** a user exceeds their traffic quota -- **THEN** the system prevents further traffic forwarding for that user. diff --git a/openspec/changes/document-existing-specs/tasks.md b/openspec/changes/document-existing-specs/tasks.md deleted file mode 100644 index a014a23..0000000 --- a/openspec/changes/document-existing-specs/tasks.md +++ /dev/null @@ -1,30 +0,0 @@ -## 1. User Management Verification - -- [ ] 1.1 Verify User Registration logic in backend -- [ ] 1.2 Verify JWT Authentication implementation -- [ ] 1.3 Verify Role Management checks -- [ ] 1.4 Verify Quota Enforcement logic - -## 2. Tunnel Management Verification - -- [ ] 2.1 Verify Tunnel Creation API -- [ ] 2.2 Verify Forwarding Configuration parsing -- [ ] 2.3 Verify Tunnel Assignment logic - -## 3. Forwarding Rules Verification - -- [ ] 3.1 Verify Port Forwarding rule processing -- [ ] 3.2 Verify Rate Limiting implementation (token bucket/leaky bucket?) -- [ ] 3.3 Verify Traffic Accounting mechanisms - -## 4. Agent Management Verification - -- [ ] 4.1 Verify Agent Registration handshake -- [ ] 4.2 Verify Heartbeat processing -- [ ] 4.3 Verify Config Sync protocol - -## 5. System Config Verification - -- [ ] 5.1 Verify Site Settings API -- [ ] 5.2 Verify Notification triggers -- [ ] 5.3 Verify Backup/Restore functionality diff --git a/openspec/config.yaml b/openspec/config.yaml deleted file mode 100644 index 392946c..0000000 --- a/openspec/config.yaml +++ /dev/null @@ -1,20 +0,0 @@ -schema: spec-driven - -# Project context (optional) -# This is shown to AI when creating artifacts. -# Add your tech stack, conventions, style guides, domain knowledge, etc. -# Example: -# context: | -# Tech stack: TypeScript, React, Node.js -# We use conventional commits -# Domain: e-commerce platform - -# Per-artifact rules (optional) -# Add custom rules for specific artifacts. -# Example: -# rules: -# proposal: -# - Keep proposals under 500 words -# - Always include a "Non-goals" section -# tasks: -# - Break tasks into chunks of max 2 hours diff --git a/openspec/project.md b/openspec/project.md deleted file mode 100644 index bafcf51..0000000 --- a/openspec/project.md +++ /dev/null @@ -1,52 +0,0 @@ -# Project Overview - -**Name**: FLVX (Flux Panel) -**Description**: Traffic forwarding management system built on a forked GOST v3 stack. It provides a web-based panel for managing traffic tunnels, users, and forwarding rules. -**Repository**: Monorepo containing Admin API, Web UI, and Forwarding Agent. - -## Tech Stack - -### Backend (`go-backend/`) -- **Language**: Go -- **Database**: SQLite (default), PostgreSQL (supported) -- **Framework**: Standard library `net/http` (no heavy framework) -- **ORM**: None (Raw SQL via `database/sql`) - -### Frontend (`vite-frontend/`) -- **Framework**: React -- **Build Tool**: Vite (using `rolldown-vite` experimental bundler) -- **UI Library**: HeroUI -- **Styling**: Tailwind CSS -- **Mode**: Hybrid (Desktop + Mobile WebView support) - -### Agent (`go-gost/`) -- **Language**: Go -- **Base**: Fork of `gost` v3 -- **Extensions**: Custom extensions in `go-gost/x/` - -### Infrastructure -- **Containerization**: Docker, Docker Compose (v4/v6) -- **CI/CD**: GitHub Actions -- **Installers**: Shell scripts (`panel_install.sh`, `install.sh`) - -## Architecture - -- **Panel**: Central management server (Go Backend + React Frontend). -- **Agent**: Forwarding node running on remote servers. -- **Communication**: - - Frontend -> Backend: REST API (JWT Auth, raw token in header). - - Agent -> Backend: AES-encrypted heartbeat/config sync. - -## Conventions - -- **Authentication**: `Authorization` header expects raw JWT token (do NOT add `Bearer ` prefix). -- **API Response**: Standard envelope `{code, msg, data, ts}` (code 0 = success). -- **Database**: Backend uses raw SQL queries. Do not introduce an ORM. -- **File Structure**: Flat monorepo with language-prefixed directories (`go-backend`, `go-gost`). -- **Protobuf**: Do not edit generated `.pb.go` files manually. - -## Development - -- **Backend Build**: `cd go-backend && make build` -- **Frontend Dev**: `cd vite-frontend && npm run dev` -- **Agent Run**: `cd go-gost && go run .` diff --git a/skills-lock.json b/skills-lock.json deleted file mode 100644 index 01e0ff2..0000000 --- a/skills-lock.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "version": 1, - "skills": { - "security-scan": { - "source": "affaan-m/everything-claude-code", - "sourceType": "github", - "computedHash": "92cdcaddc554e318402f066ccc073c2e3dbcfda8c2730ec62ec373f805c41a57" - } - } -} From bc75ed745d8a6444d35c5ce4fc11272dbb7aeba3 Mon Sep 17 00:00:00 2001 From: sagitchu Date: Thu, 19 Mar 2026 15:44:25 +0800 Subject: [PATCH 2/2] feat: monitor tunnel top level --- plans/050-monitor-tunnel-top-level.md | 40 + plans/051-sync-main-and-push-tag.md | 15 + vite-frontend/src/pages/monitor.tsx | 62 +- vite-frontend/src/pages/node/monitor-view.tsx | 439 ++++------ .../src/pages/node/tunnel-monitor-view.tsx | 829 ++++++++++++++++++ 5 files changed, 1083 insertions(+), 302 deletions(-) create mode 100644 plans/050-monitor-tunnel-top-level.md create mode 100644 plans/051-sync-main-and-push-tag.md create mode 100644 vite-frontend/src/pages/node/tunnel-monitor-view.tsx diff --git a/plans/050-monitor-tunnel-top-level.md b/plans/050-monitor-tunnel-top-level.md new file mode 100644 index 0000000..604d055 --- /dev/null +++ b/plans/050-monitor-tunnel-top-level.md @@ -0,0 +1,40 @@ +# 050 - 监控页面隧道独立入口 + +## 背景 +当前监控页面的隧道流量监控嵌套在节点详情内,用户需要先点击某个节点才能看到隧道流量。 +需要将隧道监控提升为与节点列表同级的入口,展示隧道质量(延迟)和流量统计。 + +## 设计 + +### 监控页面结构变更 +在 `monitor.tsx` 页面增加 Tab 式结构(或并列区域),新增"隧道监控"入口与节点列表同级: +- **节点** Tab:保持现有节点卡片/列表 + 详情视图 +- **隧道** Tab:新增隧道卡片/列表视图 + - 每个隧道卡片显示:隧道名称、状态、质量指标(入口→出口延迟、出口→Bing TCP延迟) + - 点击隧道进入详情:隧道流量趋势图表(已有的 tunnel metrics chart) + +### 隧道质量指标 +使用已有的隧道诊断 API (`/tunnel/diagnose`) 进行 TCP 延迟测试: +- 入口→出口延迟 +- 出口→Bing TCP 延迟(bing.com:443) + +新增一个轻量级的后端 API 来获取隧道质量快照(定时 TCP 测试延迟), +或者前端在隧道 Tab 里手动触发诊断。 + +### 实现方案(前端为主) +由于后端已有完整的 tunnel metrics 和 diagnosis API,本次改动主要在前端: + +1. `monitor.tsx` 增加 Tab 切换(节点 / 隧道) +2. 新建 `tunnel-monitor-view.tsx` 组件 +3. 隧道列表用卡片展示质量指标 +4. 点击进入隧道详情,展示流量图表 +5. 从 `monitor-view.tsx` 的节点详情中移除隧道流量图表 + +## 任务清单 + +- [x] 1. 修改 `monitor.tsx`,增加节点/隧道 Tab 切换 +- [x] 2. 新建 `tunnel-monitor-view.tsx` 组件,实现隧道列表视图 +- [x] 3. 隧道卡片展示名称、状态、质量指标(支持手动诊断获取延迟) +- [x] 4. 隧道详情视图,展示流量趋势图表(复用已有 tunnel metrics API) +- [x] 5. 从节点详情视图中移除隧道流量图表 +- [x] 6. TypeScript 编译通过验证 diff --git a/plans/051-sync-main-and-push-tag.md b/plans/051-sync-main-and-push-tag.md new file mode 100644 index 0000000..57c591e --- /dev/null +++ b/plans/051-sync-main-and-push-tag.md @@ -0,0 +1,15 @@ +# Plan 051: Sync Main and Push Tag + +## Goal +Submit all changes, push them to the remote repository, create a pull request to main, merge it, and push a new tag. + +## Tasks +- [ ] Determine the next tag version. +- [ ] Add all changes to the staging area. +- [ ] Commit the changes. +- [ ] Push changes to the current branch. +- [ ] Create a pull request via `gh` CLI. +- [ ] Merge the pull request via `gh` CLI. +- [ ] Sync the `main` branch locally. +- [ ] Create a new tag. +- [ ] Push the new tag to the remote repository. diff --git a/vite-frontend/src/pages/monitor.tsx b/vite-frontend/src/pages/monitor.tsx index 093e98e..91add49 100644 --- a/vite-frontend/src/pages/monitor.tsx +++ b/vite-frontend/src/pages/monitor.tsx @@ -2,13 +2,14 @@ import type { MonitorNodeApiItem } from "@/api/types"; import { useCallback, useEffect, useMemo, useState } from "react"; import toast from "react-hot-toast"; -import { RefreshCw, LayoutGrid, List } from "lucide-react"; +import { RefreshCw, LayoutGrid, List, Server, ArrowRightLeft } from "lucide-react"; import { AnimatedPage } from "@/components/animated-page"; import { Button } from "@/shadcn-bridge/heroui/button"; import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; import { getMonitorNodes } from "@/api"; import { MonitorView } from "@/pages/node/monitor-view"; +import { TunnelMonitorView } from "@/pages/node/tunnel-monitor-view"; type MonitorNode = { id: number; @@ -16,11 +17,14 @@ type MonitorNode = { connectionStatus: "online" | "offline"; }; +type MonitorTab = "nodes" | "tunnels"; + export default function MonitorPage() { const [nodes, setNodes] = useState([]); const [nodesLoading, setNodesLoading] = useState(false); const [nodesError, setNodesError] = useState(null); const [viewMode, setViewMode] = useState<"list" | "grid">("list"); + const [activeTab, setActiveTab] = useState("nodes"); const loadNodes = useCallback(async (options?: { silent?: boolean }) => { const silent = options?.silent ?? false; @@ -81,7 +85,7 @@ export default function MonitorPage() {

监控

- 实时节点状态 + 历史指标图表 + 隧道流量 + 服务监控(TCP/ICMP) + 实时节点状态 + 隧道质量检测 + 历史指标图表 + 服务监控(TCP/ICMP)
@@ -93,19 +97,47 @@ export default function MonitorPage() { > {viewMode === "list" ? : } - + {activeTab === "nodes" && ( + + )}
- {nodesError ? ( + {/* Tab Switcher */} +
+ + +
+ + {nodesError && activeTab === "nodes" ? (

节点列表

@@ -117,7 +149,11 @@ export default function MonitorPage() { ) : null} - + {activeTab === "nodes" ? ( + + ) : ( + + )} ); } diff --git a/vite-frontend/src/pages/node/monitor-view.tsx b/vite-frontend/src/pages/node/monitor-view.tsx index e748ff3..af3e288 100644 --- a/vite-frontend/src/pages/node/monitor-view.tsx +++ b/vite-frontend/src/pages/node/monitor-view.tsx @@ -1,7 +1,5 @@ import type { - MonitorTunnelApiItem, NodeMetricApiItem, - TunnelMetricApiItem, ServiceMonitorApiItem, ServiceMonitorResultApiItem, ServiceMonitorMutationPayload, @@ -34,9 +32,7 @@ import { import toast from "react-hot-toast"; import { - getMonitorTunnels, getNodeMetrics, - getTunnelMetrics, getServiceMonitorList, getServiceMonitorLimits, getServiceMonitorResults, @@ -287,17 +283,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { const [metricsRangeMs, setMetricsRangeMs] = useState(60 * 60 * 1000); - const [tunnels, setTunnels] = useState([]); - const [tunnelsLoading, setTunnelsLoading] = useState(false); - const [, setTunnelsError] = useState(null); - const [selectedTunnelId, setSelectedTunnelId] = useState(null); - const [tunnelMetrics, setTunnelMetrics] = useState([]); - const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); - const [, setTunnelMetricsTruncated] = useState(false); - const [tunnelMetricsError, setTunnelMetricsError] = useState( - null, - ); - const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); + const [serviceMonitors, setServiceMonitors] = useState< ServiceMonitorApiItem[] @@ -327,6 +313,8 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { enabled: true, }); const [submitLoading, setSubmitLoading] = useState(false); + const [activeServiceMonitorId, setActiveServiceMonitorId] = useState(null); + const [serviceMonitorRangeLimit, setServiceMonitorRangeLimit] = useState(50); const [accessDenied, setAccessDenied] = useState(null); const [resultsModalOpen, setResultsModalOpen] = useState(false); @@ -446,111 +434,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }, [preferredNodeId, selectedNodeId]); - const loadTunnels = useCallback(async (options?: { silent?: boolean }) => { - const silent = options?.silent ?? false; - if (!silent) setTunnelsLoading(true); - try { - const response = await getMonitorTunnels(); - if (response.code === 0 && response.data) { - setAccessDenied(null); - setTunnelsError(null); - setTunnels(response.data); - - return; - } - if (response.code === 403) { - setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); - setTunnelsError(null); - setTunnels([]); - - return; - } - setTunnelsError(response.msg || "加载隧道列表失败"); - if (!silent) toast.error(response.msg || "加载隧道列表失败"); - } catch { - if (!silent) { - setTunnelsError("加载隧道列表失败"); - toast.error("加载隧道列表失败"); - } - } finally { - if (!silent) setTunnelsLoading(false); - } - }, []); - - useEffect(() => { - void loadTunnels(); - }, [loadTunnels]); - - useEffect(() => { - const timer = window.setInterval(() => { - void loadTunnels({ silent: true }); - }, 60_000); - - return () => window.clearInterval(timer); - }, [loadTunnels]); - - useEffect(() => { - if (!selectedTunnelId && tunnels.length > 0) { - setSelectedTunnelId(tunnels[0].id); - } - }, [selectedTunnelId, tunnels]); - - const loadTunnelMetrics = useCallback( - async (tunnelId: number, options?: { silent?: boolean }) => { - const silent = options?.silent ?? false; - if (!silent) setTunnelMetricsLoading(true); - try { - const end = Date.now(); - const start = end - tunnelRangeMs; - const response = await getTunnelMetrics(tunnelId, start, end); - - if (response.code === 0 && Array.isArray(response.data)) { - setAccessDenied(null); - setTunnelMetricsError(null); - setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS); - const ordered = [...response.data].sort( - (a, b) => a.timestamp - b.timestamp, - ); - - setTunnelMetrics(ordered); - - return; - } - if (response.code === 403) { - setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); - setTunnelMetricsTruncated(false); - setTunnelMetricsError(null); - - return; - } - setTunnelMetricsTruncated(false); - setTunnelMetricsError(response.msg || "加载隧道指标失败"); - if (!silent) toast.error(response.msg || "加载隧道指标失败"); - } catch { - setTunnelMetricsTruncated(false); - if (!silent) setTunnelMetricsError("加载隧道指标失败"); - } finally { - if (!silent) setTunnelMetricsLoading(false); - } - }, - [tunnelRangeMs], - ); - - useEffect(() => { - if (selectedTunnelId) { - void loadTunnelMetrics(selectedTunnelId); - } - }, [selectedTunnelId, loadTunnelMetrics]); - - useEffect(() => { - if (!selectedTunnelId) return; - const timer = window.setInterval(() => { - void loadTunnelMetrics(selectedTunnelId, { silent: true }); - }, 30_000); - - return () => window.clearInterval(timer); - }, [selectedTunnelId, loadTunnelMetrics]); const loadMetrics = useCallback( async (nodeId: number, options?: { silent?: boolean }) => { @@ -757,6 +641,12 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { void loadResultsForModal(); }, [resultsModalOpen, resultsMonitorId, resultsLimit, loadResultsForModal]); + // Reload results for the active service monitor chart when range limit changes + useEffect(() => { + if (!activeServiceMonitorId) return; + void loadMonitorResults(activeServiceMonitorId, serviceMonitorRangeLimit); + }, [activeServiceMonitorId, serviceMonitorRangeLimit, loadMonitorResults]); + const chartData = metrics.map((m) => ({ time: formatTimestamp(m.timestamp, metricsRangeMs), cpu: m.cpuUsage, @@ -816,21 +706,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }; - const tunnelChartData = tunnelMetrics.map((m) => ({ - time: formatTimestamp(m.timestamp, tunnelRangeMs), - bytesIn: m.bytesIn, - bytesOut: m.bytesOut, - })); - const getTunnelChartConfig = () => { - return { - lines: [ - { dataKey: "bytesIn", color: "#10b981", name: "入站流量" }, - { dataKey: "bytesOut", color: "#ef4444", name: "出站流量" }, - ], - yAxisLabel: "流量", - }; - }; const handleOpenEditModal = (monitor?: ServiceMonitorApiItem) => { if (monitor) { @@ -1091,7 +967,6 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { }, [getLatestResult, isResultStale, serviceMonitors]); const chartConfig = getChartConfig(); - const tunnelChartConfig = getTunnelChartConfig(); const nodeYAxisTickFormatter = (value: unknown) => { const n = Number(value); @@ -1131,21 +1006,7 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) { } }; - const tunnelYAxisTickFormatter = (value: unknown) => { - const n = Number(value); - if (!Number.isFinite(n)) return ""; - - return formatBytes(n); - }; - - const tunnelTooltipFormatter = (value: unknown) => { - const n = Number(value); - - if (!Number.isFinite(n)) return "-"; - - return formatBytes(n); - }; const detailNode = detailNodeId != null ? nodes.find((n) => n.id === detailNodeId) : null; const detailRealtimeMetric = detailNodeId != null ? realtimeNodeMetrics[detailNodeId] || null : null; @@ -1440,158 +1301,158 @@ export function MonitorView({ nodeMap, viewMode = "grid" }: MonitorViewProps) {
- {/* Tunnel traffic chart */} - - -

隧道流量趋势

-
- - -
-
- - {selectedTunnelId ? ( - tunnelMetricsLoading ? ( -
- ) : tunnelMetricsError ? ( -
{tunnelMetricsError}
- ) : tunnelMetrics.length > 0 ? ( -
- - - - - - - {tunnelChartConfig.lines.map((line) => ( - - ))} - - + + {/* Service monitors chart – same style as node metrics */} + {(() => { + // Resolve which monitor is active + const resolvedActiveMonitor = + detailServiceMonitors.find((m) => m.id === activeServiceMonitorId) || + detailServiceMonitors[0] || + null; + const resolvedActiveMonitorId = resolvedActiveMonitor?.id ?? null; + const activeLatestResult = resolvedActiveMonitorId != null ? getLatestResult(resolvedActiveMonitorId) : null; + const activeStale = resolvedActiveMonitor ? isResultStale(resolvedActiveMonitor, activeLatestResult) : false; + const activeResults = resolvedActiveMonitorId != null ? (monitorResults[resolvedActiveMonitorId] || []) : []; + const activeLatencyData = [...activeResults] + .slice(0, serviceMonitorRangeLimit) + .reverse() + .map((r) => ({ + time: formatTimestamp(r.timestamp), + latency: r.success === 1 ? r.latencyMs : null, + success: r.success, + })); + + return ( + + +

服务监控图表

+
+ + {resolvedActiveMonitorId != null && ( + + )} +
- ) : ( -
暂无指标数据
- ) - ) : ( -
请选择一个隧道查看指标
- )} - -
+ + + {monitorsLoading ? ( +
+ ) : detailServiceMonitors.length > 0 ? ( + <> + {/* Monitor switcher buttons – like metric type tabs */} +
+ {detailServiceMonitors.map((monitor) => { + const lr = getLatestResult(monitor.id); + const statusColor = + monitor.enabled !== 1 ? "default" : + !lr ? "default" : + lr.success === 1 ? "success" : "danger"; + const isActive = monitor.id === resolvedActiveMonitorId; - {/* Service monitors with latency charts */} - - -

服务监控

- -
- - {monitorsLoading ? ( -
- ) : detailServiceMonitors.length > 0 ? ( -
- {detailServiceMonitors.map((monitor) => { - const latestResult = getLatestResult(monitor.id); - const stale = isResultStale(monitor, latestResult); - const results = monitorResults[monitor.id] || []; - const latencyData = [...results].reverse().map((r) => ({ - time: formatTimestamp(r.timestamp), - latency: r.success === 1 ? r.latencyMs : null, - success: r.success, - })); + return ( + + ); + })} +
- return ( - - -
-
- {monitor.name} - {monitor.type.toUpperCase()} - {stale ? 陈旧 : null} + {/* Active monitor info bar */} + {resolvedActiveMonitor && ( +
+
+ {resolvedActiveMonitor.type.toUpperCase()} + {resolvedActiveMonitor.target} + 间隔 {resolvedActiveMonitor.intervalSec}s + {activeLatestResult && Number.isFinite(activeLatestResult.latencyMs) ? ( + {activeLatestResult.latencyMs.toFixed(0)}ms + ) : null} + {activeStale ? 陈旧 : null} + {resolvedActiveMonitor.enabled !== 1 ? 已禁用 : null}
- } onPress={() => handleRunMonitor(monitor.id)}>立即检查 - } onPress={() => openResultsModal(monitor.id)}>查看记录 - } onPress={() => handleOpenEditModal(monitor)}>编辑 - } onPress={() => handleDeleteMonitor(monitor.id)}>删除 + } onPress={() => resolvedActiveMonitorId != null && handleRunMonitor(resolvedActiveMonitorId)}>立即检查 + } onPress={() => resolvedActiveMonitorId != null && openResultsModal(resolvedActiveMonitorId)}>查看记录 + } onPress={() => resolvedActiveMonitor && handleOpenEditModal(resolvedActiveMonitor)}>编辑 + } onPress={() => resolvedActiveMonitorId != null && handleDeleteMonitor(resolvedActiveMonitorId)}>删除 - - -
- {monitor.target} - 间隔 {monitor.intervalSec}s - {latestResult && Number.isFinite(latestResult.latencyMs) ? ( - {latestResult.latencyMs.toFixed(0)}ms - ) : null} -
- {latencyData.length > 0 ? ( -
- - - - - `${Math.round(v)}ms`} /> - [`${Number(value).toFixed(0)}ms`, "延迟"]} - /> - - - -
- ) : ( -
暂无检查记录
- )} -
- - ); - })} -
- ) : ( -
暂无服务监控,点击"添加监控"创建
- )} - - +
+ )} + + {/* Chart area – same h-64 as node metrics */} + {activeLatencyData.length > 0 ? ( +
+ + + + + `${Math.round(v)}ms`} /> + [`${Number(value).toFixed(0)}ms`, "延迟"]} + /> + + + +
+ ) : ( +
暂无检查记录
+ )} + + ) : ( +
暂无服务监控,点击"添加监控"创建
+ )} + + + ); + })()} )} diff --git a/vite-frontend/src/pages/node/tunnel-monitor-view.tsx b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx new file mode 100644 index 0000000..93135cb --- /dev/null +++ b/vite-frontend/src/pages/node/tunnel-monitor-view.tsx @@ -0,0 +1,829 @@ +import type { + MonitorTunnelApiItem, + TunnelMetricApiItem, + TunnelDiagnosisApiItem, +} from "@/api/types"; + +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { + LineChart, + Line, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import { + RefreshCw, + ArrowLeft, + Activity, + Zap, + Globe, + ArrowRightLeft, + Wifi, + WifiOff, + Stethoscope, +} from "lucide-react"; +import toast from "react-hot-toast"; + +import { + getMonitorTunnels, + getTunnelMetrics, + diagnoseTunnel, +} from "@/api"; +import { diagnoseTunnelStream } from "@/api/diagnosis-stream"; +import { getDiagnosisQualityDisplay } from "@/pages/tunnel/diagnosis"; +import { Button } from "@/shadcn-bridge/heroui/button"; +import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card"; +import { Chip } from "@/shadcn-bridge/heroui/chip"; +import { Select, SelectItem } from "@/shadcn-bridge/heroui/select"; +import { + Table, + TableHeader, + TableColumn, + TableBody, + TableRow, + TableCell, +} from "@/shadcn-bridge/heroui/table"; + +interface TunnelMonitorViewProps { + viewMode?: "list" | "grid"; +} + +const METRICS_MAX_ROWS = 5000; + +interface TunnelQuality { + loading: boolean; + entryToExitLatency?: number; + exitToBingLatency?: number; + entryToExitLoss?: number; + exitToBingLoss?: number; + results?: TunnelDiagnosisApiItem[]; + timestamp?: number; + error?: string; +} + +const formatTimestamp = (ts: number, rangeMs?: number): string => { + const date = new Date(ts); + const includeDate = (rangeMs ?? 0) >= 24 * 60 * 60 * 1000; + + if (includeDate) { + return date.toLocaleString("zh-CN", { + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + } + + return date.toLocaleTimeString("zh-CN", { + hour: "2-digit", + minute: "2-digit", + }); +}; + +const formatBytes = (bytes: number): string => { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + + const k = 1024; + const sizes = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + + return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`; +}; + +export function TunnelMonitorView({ viewMode = "grid" }: TunnelMonitorViewProps) { + const [tunnels, setTunnels] = useState([]); + const [tunnelsLoading, setTunnelsLoading] = useState(false); + const [tunnelsError, setTunnelsError] = useState(null); + const [accessDenied, setAccessDenied] = useState(null); + + // Detail view state + const [detailTunnelId, setDetailTunnelId] = useState(null); + const [tunnelMetrics, setTunnelMetrics] = useState([]); + const [tunnelMetricsLoading, setTunnelMetricsLoading] = useState(false); + const [tunnelMetricsError, setTunnelMetricsError] = useState(null); + const [, setTunnelMetricsTruncated] = useState(false); + const [tunnelRangeMs, setTunnelRangeMs] = useState(60 * 60 * 1000); + + // Tunnel quality (diagnosis) state + const [tunnelQualities, setTunnelQualities] = useState>({}); + const diagnosisAbortRef = useRef>({}); + + // Cleanup abort controllers on unmount + useEffect(() => { + return () => { + Object.values(diagnosisAbortRef.current).forEach((c) => c.abort()); + diagnosisAbortRef.current = {}; + }; + }, []); + + const loadTunnels = useCallback(async (options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!silent) setTunnelsLoading(true); + try { + const response = await getMonitorTunnels(); + + if (response.code === 0 && response.data) { + setAccessDenied(null); + setTunnelsError(null); + setTunnels(response.data); + return; + } + if (response.code === 403) { + setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); + setTunnelsError(null); + setTunnels([]); + return; + } + setTunnelsError(response.msg || "加载隧道列表失败"); + if (!silent) toast.error(response.msg || "加载隧道列表失败"); + } catch { + if (!silent) { + setTunnelsError("加载隧道列表失败"); + toast.error("加载隧道列表失败"); + } + } finally { + if (!silent) setTunnelsLoading(false); + } + }, []); + + useEffect(() => { + void loadTunnels(); + }, [loadTunnels]); + + useEffect(() => { + const timer = window.setInterval(() => { + void loadTunnels({ silent: true }); + }, 60_000); + + return () => window.clearInterval(timer); + }, [loadTunnels]); + + // Load tunnel metrics for detail view + const loadTunnelMetrics = useCallback( + async (tunnelId: number, options?: { silent?: boolean }) => { + const silent = options?.silent ?? false; + if (!silent) setTunnelMetricsLoading(true); + try { + const end = Date.now(); + const start = end - tunnelRangeMs; + const response = await getTunnelMetrics(tunnelId, start, end); + + if (response.code === 0 && Array.isArray(response.data)) { + setAccessDenied(null); + setTunnelMetricsError(null); + setTunnelMetricsTruncated(response.data.length >= METRICS_MAX_ROWS); + const ordered = [...response.data].sort( + (a, b) => a.timestamp - b.timestamp, + ); + setTunnelMetrics(ordered); + return; + } + if (response.code === 403) { + setAccessDenied(response.msg || "暂无监控权限,请联系管理员授权"); + setTunnelMetricsTruncated(false); + setTunnelMetricsError(null); + return; + } + setTunnelMetricsTruncated(false); + setTunnelMetricsError(response.msg || "加载隧道指标失败"); + if (!silent) toast.error(response.msg || "加载隧道指标失败"); + } catch { + setTunnelMetricsTruncated(false); + if (!silent) setTunnelMetricsError("加载隧道指标失败"); + } finally { + if (!silent) setTunnelMetricsLoading(false); + } + }, + [tunnelRangeMs], + ); + + useEffect(() => { + if (detailTunnelId) { + void loadTunnelMetrics(detailTunnelId); + } + }, [detailTunnelId, loadTunnelMetrics]); + + useEffect(() => { + if (!detailTunnelId) return; + const timer = window.setInterval(() => { + void loadTunnelMetrics(detailTunnelId, { silent: true }); + }, 30_000); + + return () => window.clearInterval(timer); + }, [detailTunnelId, loadTunnelMetrics]); + + // Diagnose tunnel quality + const diagnoseTunnelQuality = useCallback(async (tunnelId: number) => { + // Abort if already running + if (diagnosisAbortRef.current[tunnelId]) { + diagnosisAbortRef.current[tunnelId].abort(); + } + const abortController = new AbortController(); + diagnosisAbortRef.current[tunnelId] = abortController; + + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { loading: true }, + })); + + try { + // Try stream first + const results: TunnelDiagnosisApiItem[] = []; + const streamResult = await diagnoseTunnelStream( + tunnelId, + { + onItem: (payload) => { + results.push(payload.result); + }, + onError: (msg) => { + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { loading: false, error: msg }, + })); + }, + }, + abortController.signal, + ); + + if (streamResult.fallback) { + // Fallback to non-stream API + try { + const response = await diagnoseTunnel(tunnelId); + if (response.code === 0 && response.data?.results) { + const apiResults = response.data.results; + const quality = extractQualityFromResults(apiResults); + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { + loading: false, + ...quality, + results: apiResults, + timestamp: Date.now(), + }, + })); + } else { + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { loading: false, error: response.msg || "诊断失败" }, + })); + } + } catch { + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { loading: false, error: "诊断请求失败" }, + })); + } + return; + } + + // Process stream results + if (results.length > 0) { + const quality = extractQualityFromResults(results); + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { + loading: false, + ...quality, + results, + timestamp: Date.now(), + }, + })); + } else { + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { loading: false, error: "未获取到诊断结果" }, + })); + } + } catch { + if (!abortController.signal.aborted) { + setTunnelQualities((prev) => ({ + ...prev, + [tunnelId]: { loading: false, error: "诊断失败" }, + })); + } + } finally { + delete diagnosisAbortRef.current[tunnelId]; + } + }, []); + + const extractQualityFromResults = ( + results: TunnelDiagnosisApiItem[], + ): Pick => { + // The diagnosis results contain hop-by-hop tests + // We look for entry→exit (hop between entry and exit nodes) + // and exit→Bing (the last hop to external target like bing.com) + let entryToExitLatency: number | undefined; + let exitToBingLatency: number | undefined; + let entryToExitLoss: number | undefined; + let exitToBingLoss: number | undefined; + + for (const r of results) { + if (!r.success) continue; + + // Entry to Exit: chainType transitions from 1 (entry) to 3 (exit) + if (r.fromChainType === 1 && r.toChainType === 3) { + entryToExitLatency = r.averageTime; + entryToExitLoss = r.packetLoss; + } + // Or if it's a mid-chain to exit + if (r.fromChainType === 2 && r.toChainType === 3) { + // Use this if no direct entry→exit + if (entryToExitLatency === undefined) { + entryToExitLatency = r.averageTime; + entryToExitLoss = r.packetLoss; + } + } + + // Exit to external target (Bing / external) + if (r.toChainType === undefined || r.toChainType === 0) { + // This typically means it's the exit node testing external + if (r.fromChainType === 3) { + exitToBingLatency = r.averageTime; + exitToBingLoss = r.packetLoss; + } + } + } + + // If no chainType-based matching, use position-based heuristics + if (entryToExitLatency === undefined && exitToBingLatency === undefined) { + const successResults = results.filter((r) => r.success); + if (successResults.length >= 2) { + entryToExitLatency = successResults[0].averageTime; + entryToExitLoss = successResults[0].packetLoss; + exitToBingLatency = successResults[successResults.length - 1].averageTime; + exitToBingLoss = successResults[successResults.length - 1].packetLoss; + } else if (successResults.length === 1) { + entryToExitLatency = successResults[0].averageTime; + entryToExitLoss = successResults[0].packetLoss; + } + } + + return { entryToExitLatency, exitToBingLatency, entryToExitLoss, exitToBingLoss }; + }; + + // Chart data + const tunnelChartData = tunnelMetrics.map((m) => ({ + time: formatTimestamp(m.timestamp, tunnelRangeMs), + bytesIn: m.bytesIn, + bytesOut: m.bytesOut, + connections: m.connections, + })); + + const tunnelYAxisTickFormatter = (value: unknown) => { + const n = Number(value); + if (!Number.isFinite(n)) return ""; + return formatBytes(n); + }; + + const tunnelTooltipFormatter = (value: unknown) => { + const n = Number(value); + if (!Number.isFinite(n)) return "-"; + return formatBytes(n); + }; + + const detailTunnel = detailTunnelId != null + ? tunnels.find((t) => t.id === detailTunnelId) + : null; + + // Aggregate stats + const tunnelStats = useMemo(() => { + const enabled = tunnels.filter((t) => t.status === 1).length; + const disabled = tunnels.length - enabled; + const diagnosed = Object.keys(tunnelQualities).filter((k) => { + const q = tunnelQualities[Number(k)]; + return q && !q.loading && !q.error; + }).length; + + return { total: tunnels.length, enabled, disabled, diagnosed }; + }, [tunnels, tunnelQualities]); + + // ===================== + // RENDER + // ===================== + + if (accessDenied) { + return ( + + + +

监控权限

+
+ +
{accessDenied}
+
+ 如需使用监控功能,请联系管理员在用户页面授予监控权限。 +
+
+
+ ); + } + + // ===== DETAIL VIEW ===== + if (detailTunnelId && detailTunnel) { + const quality = tunnelQualities[detailTunnelId]; + + return ( +
+ {/* Header */} +
+ +
+ +

{detailTunnel.name}

+ + {detailTunnel.status === 1 ? "启用" : "禁用"} + +
+
+ + {/* Quality KPI Cards */} +
+ + + 入口 → 出口 延迟 + + {quality?.loading ? "检测中..." : quality?.entryToExitLatency !== undefined ? `${quality.entryToExitLatency.toFixed(0)}ms` : "-"} + + + + + + 出口 → Bing 延迟 + + {quality?.loading ? "检测中..." : quality?.exitToBingLatency !== undefined ? `${quality.exitToBingLatency.toFixed(0)}ms` : "-"} + + + + + + 入口 → 出口 丢包 + 0 ? "text-warning" : ""}`}> + {quality?.loading ? "检测中..." : quality?.entryToExitLoss !== undefined ? `${quality.entryToExitLoss.toFixed(1)}%` : "-"} + + + + + + 出口 → Bing 丢包 + 0 ? "text-warning" : ""}`}> + {quality?.loading ? "检测中..." : quality?.exitToBingLoss !== undefined ? `${quality.exitToBingLoss.toFixed(1)}%` : "-"} + + + +
+ + {/* Diagnose Button */} +
+ + {quality?.timestamp && ( + + 上次检测: {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} + + )} + {quality?.error && ( + {quality.error} + )} +
+ + {/* Diagnosis Details */} + {quality?.results && quality.results.length > 0 && ( + + +

诊断详情

+
+ + + + 描述 + 节点 + 目标 + 延迟 + 丢包 + 状态 + + + {quality.results.map((r, idx) => ( + + + {r.description || "-"} + + + {r.nodeName || "-"} + + + + {r.targetIp || "-"}{r.targetPort ? `:${r.targetPort}` : ""} + + + + + {r.averageTime !== undefined ? `${r.averageTime.toFixed(0)}ms` : "-"} + + + + + {r.packetLoss !== undefined ? `${r.packetLoss.toFixed(1)}%` : "-"} + + + + + {r.success ? "成功" : "失败"} + + + + ))} + +
+
+
+ )} + + {/* Tunnel traffic chart */} + + +

隧道流量趋势

+
+ + +
+
+ + {tunnelMetricsLoading ? ( +
+ ) : tunnelMetricsError ? ( +
{tunnelMetricsError}
+ ) : tunnelMetrics.length > 0 ? ( +
+ + + + + + + + + + +
+ ) : ( +
暂无指标数据
+ )} +
+
+
+ ); + } + + // ===== LIST/GRID VIEW ===== + return ( +
+
+ 隧道 {tunnelStats.enabled}/{tunnelStats.total} + {tunnelStats.diagnosed > 0 && ( + 已诊断 {tunnelStats.diagnosed} + )} +
+ +
+
+ + {tunnelsError ? ( + + +
{tunnelsError}
+
+
+ ) : null} + + {viewMode === "grid" ? ( +
+ {tunnels.map((tunnel) => { + const quality = tunnelQualities[tunnel.id]; + const isEnabled = tunnel.status === 1; + const overallQuality = quality?.entryToExitLatency !== undefined + ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) + : null; + + return ( + setDetailTunnelId(tunnel.id)} + > + {/* Top gradient bar */} +
+ + {/* Decorative background glow */} +
+ + +
+
+
+ +
+ +
+
+

{tunnel.name}

+
+ {isEnabled ? "启用" : "禁用"} +
+
+
+ {overallQuality && ( + + {overallQuality.text} + + )} +
+ + + {/* Quality metrics */} +
+
+
+ + 入口→出口 +
+ + {quality?.loading ? ( + + ) : quality?.entryToExitLatency !== undefined ? ( + `${quality.entryToExitLatency.toFixed(0)}ms` + ) : "-"} + +
+
+
+ + 出口→Bing +
+ + {quality?.loading ? ( + + ) : quality?.exitToBingLatency !== undefined ? ( + `${quality.exitToBingLatency.toFixed(0)}ms` + ) : "-"} + +
+
+ + {/* Action area */} +
+ {quality?.error ? ( + {quality.error} + ) : quality?.timestamp ? ( + + {new Date(quality.timestamp).toLocaleTimeString("zh-CN")} + + ) : ( + 未检测 + )} + +
+
+ + ); + })} +
+ ) : ( + + + + 状态 + 名称 + 入口→出口 + 出口→Bing + 质量 + 操作 + + + {tunnels.map((tunnel) => { + const quality = tunnelQualities[tunnel.id]; + const isEnabled = tunnel.status === 1; + const overallQuality = quality?.entryToExitLatency !== undefined + ? getDiagnosisQualityDisplay(quality.entryToExitLatency, quality.entryToExitLoss ?? 0) + : null; + + return ( + setDetailTunnelId(tunnel.id)}> + +
+ {isEnabled ? ( + + ) : ( + + )} +
+
+ + {tunnel.name} + + + + {quality?.loading ? ( + + ) : quality?.entryToExitLatency !== undefined ? ( + `${quality.entryToExitLatency.toFixed(0)}ms` + ) : "-"} + + + + + {quality?.loading ? ( + + ) : quality?.exitToBingLatency !== undefined ? ( + `${quality.exitToBingLatency.toFixed(0)}ms` + ) : "-"} + + + + {overallQuality ? ( + + {overallQuality.text} + + ) : ( + - + )} + + +
+ +
+
+
+ ); + })} +
+
+
+ )} +
+ ); +}