mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-10 08:13:59 +02:00
Compare commits
58 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 951d14ef14 | |||
| 0eb22da6e9 | |||
| 5fd9ef0514 | |||
| 9a4f3c7d35 | |||
| ead2ce3ecc | |||
| 8733f3a2d2 | |||
| 8642f3ba31 | |||
| 6a262a7367 | |||
| eb9192ddb3 | |||
| 5587e75628 | |||
| 74bbb453e2 | |||
| 66842f6206 | |||
| dc1779275d | |||
| 10dff937b1 | |||
| d4e1fe3bbe | |||
| 179976ae57 | |||
| 1c758bb98c | |||
| 17c4f38ee3 | |||
| cd7e57d121 | |||
| 0f2c3f65cc | |||
| 7779666e27 | |||
| c74bd4403b | |||
| 04d23ddb43 | |||
| 0874e84393 | |||
| 57f57f30b1 | |||
| f37d613a0c | |||
| 87d0ff9154 | |||
| b3418f39b8 | |||
| f9e1ca0e2d | |||
| 2c45879669 | |||
| 1cdcfa2c2d | |||
| eab5b73846 | |||
| d961ba1ec7 | |||
| 1ba5e57ec6 | |||
| 1216d25f96 | |||
| fde693408e | |||
| 352a81a869 | |||
| b2562b1010 | |||
| 0d8ba51087 | |||
| 0b847fcea3 | |||
| bf2f49fe62 | |||
| 75e64b1a86 | |||
| 2167735022 | |||
| 4ee292cc1f | |||
| 961205940f | |||
| ffe797bd06 | |||
| b6c864547e | |||
| da369c2edc | |||
| 54dc31a616 | |||
| 9e0b985221 | |||
| eb47077082 | |||
| f9a482857d | |||
| 679a68b12f | |||
| 840a26c7ef | |||
| 030e69c02d | |||
| d9683cdb44 | |||
| 60a063dd7d | |||
| 5f0c1805a7 |
@@ -174,9 +174,11 @@ The `run.sh` script will automatically:
|
|||||||
- ✅ Build the project
|
- ✅ Build the project
|
||||||
- ✅ Start the server
|
- ✅ Start the server
|
||||||
|
|
||||||
|
**Networking defaults:** `run.sh` starts the server with **`--https`** and the repo **`config.yaml`** (local self-signed TLS; better for many concurrent streams). Use **`./run.sh --http`** for plain HTTP. In production, set **`server.tls_cert_path`** / **`server.tls_key_path`** in **`config.yaml`** (see comments there). For manual runs, add **`--https`** or **`CYBERSTRIKE_HTTPS=1`**; if **`-config`** is wrong, the binary prints a short usage hint on stderr.
|
||||||
|
|
||||||
**First-Time Configuration:**
|
**First-Time Configuration:**
|
||||||
1. **Configure OpenAI-compatible API** (required before first use)
|
1. **Configure OpenAI-compatible API** (required before first use)
|
||||||
- Open http://localhost:8080 after launch
|
- After launch, open **`https://127.0.0.1:8080/`** (or **`https://localhost:8080/`**; replace **8080** with `server.port` in `config.yaml`) and accept the self-signed certificate warning once. If you used `./run.sh --http`, use **`http://`** instead.
|
||||||
- Go to `Settings` → Fill in your API credentials:
|
- Go to `Settings` → Fill in your API credentials:
|
||||||
```yaml
|
```yaml
|
||||||
openai:
|
openai:
|
||||||
@@ -197,14 +199,16 @@ The `run.sh` script will automatically:
|
|||||||
|
|
||||||
**Alternative Launch Methods:**
|
**Alternative Launch Methods:**
|
||||||
```bash
|
```bash
|
||||||
# Direct Go run (requires manual setup)
|
# Direct Go run (set up env yourself); add --https to match run.sh defaults
|
||||||
go run cmd/server/main.go
|
go run cmd/server/main.go --https
|
||||||
|
|
||||||
# Manual build
|
# Manual build
|
||||||
go build -o cyberstrike-ai cmd/server/main.go
|
go build -o cyberstrike-ai cmd/server/main.go
|
||||||
./cyberstrike-ai
|
./cyberstrike-ai --https
|
||||||
```
|
```
|
||||||
|
|
||||||
|
If server logs show `client sent an HTTP request to an HTTPS server`, a client is still using **`http://`** on a TLS-only port—switch the URL to **`https://`**.
|
||||||
|
|
||||||
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
|
**Note:** The Python virtual environment (`venv/`) is automatically created and managed by `run.sh`. Tools that require Python (like `api-fuzzer`, `http-framework-test`, etc.) will automatically use this environment.
|
||||||
|
|
||||||
### Version Update (No Breaking Changes)
|
### Version Update (No Breaking Changes)
|
||||||
|
|||||||
+8
-4
@@ -173,9 +173,11 @@ chmod +x run.sh && ./run.sh
|
|||||||
- ✅ 编译构建项目
|
- ✅ 编译构建项目
|
||||||
- ✅ 启动服务器
|
- ✅ 启动服务器
|
||||||
|
|
||||||
|
**网络默认:** `run.sh` 会以 **`--https`** 并传入项目根 **`config.yaml`** 启动(本机自签证书,多路流式场景更稳)。只要明文 HTTP 用 **`./run.sh --http`**。生产环境在 **`config.yaml`** 的 **`server.tls_cert_path` / `server.tls_key_path`** 配正式证书(见文件内注释)。手动启动可加 **`--https`** 或环境变量 **`CYBERSTRIKE_HTTPS=1`**;`-config` 写错时程序会在终端提示正确写法。
|
||||||
|
|
||||||
**首次配置:**
|
**首次配置:**
|
||||||
1. **配置 AI 模型 API**(首次使用前必填)
|
1. **配置 AI 模型 API**(首次使用前必填)
|
||||||
- 启动后访问 http://localhost:8080
|
- 启动后在浏览器打开 **`https://127.0.0.1:8080/`**(或 **`https://localhost:8080/`**;端口以 `config.yaml` 中 **`server.port`** 为准,默认 8080),并按提示信任自签证书。若使用 **`./run.sh --http`**,则改用 **`http://`** 访问。
|
||||||
- 进入 `设置` → 填写 API 配置信息:
|
- 进入 `设置` → 填写 API 配置信息:
|
||||||
```yaml
|
```yaml
|
||||||
openai:
|
openai:
|
||||||
@@ -196,14 +198,16 @@ chmod +x run.sh && ./run.sh
|
|||||||
|
|
||||||
**其他启动方式:**
|
**其他启动方式:**
|
||||||
```bash
|
```bash
|
||||||
# 直接运行(需手动配置环境)
|
# 直接运行(需自行配环境);与 run.sh 默认一致可加 --https
|
||||||
go run cmd/server/main.go
|
go run cmd/server/main.go --https
|
||||||
|
|
||||||
# 手动编译
|
# 手动编译
|
||||||
go build -o cyberstrike-ai cmd/server/main.go
|
go build -o cyberstrike-ai cmd/server/main.go
|
||||||
./cyberstrike-ai
|
./cyberstrike-ai --https
|
||||||
```
|
```
|
||||||
|
|
||||||
|
若日志出现 `client sent an HTTP request to an HTTPS server`,说明仍有客户端用 **`http://`** 访问只提供 HTTPS 的端口,请改为 **`https://`**。
|
||||||
|
|
||||||
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
|
**说明:** Python 虚拟环境(`venv/`)由 `run.sh` 自动创建和管理。需要 Python 的工具(如 `api-fuzzer`、`http-framework-test` 等)会自动使用该环境。
|
||||||
|
|
||||||
### CyberStrikeAI 版本更新(无兼容性问题)
|
### CyberStrikeAI 版本更新(无兼容性问题)
|
||||||
|
|||||||
+43
-3
@@ -9,22 +9,62 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"os/signal"
|
"os/signal"
|
||||||
|
"strings"
|
||||||
"syscall"
|
"syscall"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
var configPath = flag.String("config", "config.yaml", "配置文件路径")
|
||||||
|
var httpsBootstrap = flag.Bool("https", false, "启用主站 HTTPS:未配置 tls_cert_path/tls_key_path 时使用内存自签证书(本地测试);与 run.sh 默认行为一致")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
|
|
||||||
|
// 环境变量兼容(便于 systemd/docker 等不传参场景)
|
||||||
|
if !*httpsBootstrap {
|
||||||
|
v := strings.TrimSpace(os.Getenv("CYBERSTRIKE_HTTPS"))
|
||||||
|
if v == "1" || strings.EqualFold(v, "true") || strings.EqualFold(v, "yes") {
|
||||||
|
*httpsBootstrap = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 加载配置
|
// 加载配置
|
||||||
cfg, err := config.Load(*configPath)
|
cp := strings.TrimSpace(*configPath)
|
||||||
|
if cp == "" {
|
||||||
|
cp = "config.yaml"
|
||||||
|
}
|
||||||
|
if strings.HasPrefix(cp, "-") {
|
||||||
|
fmt.Fprintf(os.Stderr, "无效的 -config 路径 %q。\n若同时需要 HTTPS,请写成: ./cyberstrike-ai --https -config config.yaml(-config 后必须是 yaml 文件路径)。\n", cp)
|
||||||
|
os.Exit(2)
|
||||||
|
}
|
||||||
|
cfg, err := config.Load(cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Printf("加载配置失败: %v\n", err)
|
fmt.Printf("加载配置失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if *httpsBootstrap {
|
||||||
|
config.ApplyDevHTTPSBootstrap(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
port := cfg.Server.Port
|
||||||
|
if port <= 0 {
|
||||||
|
port = 8080
|
||||||
|
}
|
||||||
|
scheme := "http"
|
||||||
|
if config.MainWebUIUsesHTTPS(&cfg.Server) {
|
||||||
|
scheme = "https"
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
fmt.Printf("→ Web 界面: %s://127.0.0.1:%d/\n", scheme, port)
|
||||||
|
if scheme == "https" && cfg.Server.TLSAutoSelfSign {
|
||||||
|
fmt.Println(" (内存自签证书:浏览器首次需确认「继续访问」)")
|
||||||
|
}
|
||||||
|
if scheme == "https" && config.ServerHTTPRedirectEnabled(&cfg.Server) {
|
||||||
|
fmt.Printf(" (http://127.0.0.1:%d/ 将自动跳转到 HTTPS)\n", port)
|
||||||
|
}
|
||||||
|
fmt.Println()
|
||||||
|
|
||||||
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
// MCP 启用且 auth_header_value 为空时,自动生成随机密钥并写回配置
|
||||||
if err := config.EnsureMCPAuth(*configPath, cfg); err != nil {
|
if err := config.EnsureMCPAuth(cp, cfg); err != nil {
|
||||||
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
fmt.Printf("MCP 鉴权配置失败: %v\n", err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -44,7 +84,7 @@ func main() {
|
|||||||
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
signal.Notify(sigCh, syscall.SIGINT, syscall.SIGTERM)
|
||||||
|
|
||||||
// 创建应用
|
// 创建应用
|
||||||
application, err := app.New(cfg, log)
|
application, err := app.New(cfg, log, cp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatal("应用初始化失败", "error", err)
|
log.Fatal("应用初始化失败", "error", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+14
-3
@@ -10,11 +10,22 @@
|
|||||||
# ============================================
|
# ============================================
|
||||||
|
|
||||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||||
version: "v1.6.11"
|
version: "v1.6.16"
|
||||||
# 服务器配置
|
# 服务器配置
|
||||||
server:
|
server:
|
||||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||||
port: 8080 # HTTP 服务端口,可通过浏览器访问 http://localhost:8080
|
port: 8080 # 服务端口;未启用 TLS 时为 http://localhost:8080
|
||||||
|
# --- 可选:HTTPS + HTTP/2(缓解浏览器对同源 HTTP/1.1 的并发连接数限制,多路 Deep 流式更稳)---
|
||||||
|
# 启用 TLS 的条件(满足其一即可):tls_enabled: true,或 tls_auto_self_sign: true,或同时配置了 tls_cert_path + tls_key_path。
|
||||||
|
# 启用后请用 https://127.0.0.1:<本端口>/ 访问;若仍用 http:// 访问同端口,将自动 308 跳转到 HTTPS(可用 tls_http_redirect: false 关闭)。
|
||||||
|
tls_enabled: true
|
||||||
|
# 启用 HTTPS 时,明文 HTTP 是否自动跳转到 HTTPS(默认 true;同端口嗅探 TLS/HTTP 后分流)
|
||||||
|
# tls_http_redirect: true
|
||||||
|
# 方式 A(推荐生产):PEM 证书与私钥路径
|
||||||
|
# tls_cert_path: /path/to/fullchain.pem
|
||||||
|
# tls_key_path: /path/to/privkey.pem
|
||||||
|
# 方式 B(仅本地/测试):无证书文件时内存自签(浏览器会提示不受信任;SAN 含 localhost / 127.0.0.1)
|
||||||
|
tls_auto_self_sign: true
|
||||||
# 认证配置
|
# 认证配置
|
||||||
auth:
|
auth:
|
||||||
password: # Web 登录密码,请修改为强密码
|
password: # Web 登录密码,请修改为强密码
|
||||||
@@ -43,7 +54,7 @@ openai:
|
|||||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||||
reasoning:
|
reasoning:
|
||||||
mode: off # auto | on | off;off 时不附加任何推理扩展字段
|
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||||
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
|
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
|
||||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ require (
|
|||||||
go.opentelemetry.io/otel/sdk v1.34.0
|
go.opentelemetry.io/otel/sdk v1.34.0
|
||||||
go.opentelemetry.io/otel/trace v1.34.0
|
go.opentelemetry.io/otel/trace v1.34.0
|
||||||
go.uber.org/zap v1.26.0
|
go.uber.org/zap v1.26.0
|
||||||
|
golang.org/x/net v0.35.0
|
||||||
golang.org/x/text v0.26.0
|
golang.org/x/text v0.26.0
|
||||||
golang.org/x/time v0.14.0
|
golang.org/x/time v0.14.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
@@ -88,7 +89,6 @@ require (
|
|||||||
golang.org/x/arch v0.15.0 // indirect
|
golang.org/x/arch v0.15.0 // indirect
|
||||||
golang.org/x/crypto v0.39.0 // indirect
|
golang.org/x/crypto v0.39.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||||
golang.org/x/net v0.34.0 // indirect
|
|
||||||
golang.org/x/oauth2 v0.30.0 // indirect
|
golang.org/x/oauth2 v0.30.0 // indirect
|
||||||
golang.org/x/sys v0.33.0 // indirect
|
golang.org/x/sys v0.33.0 // indirect
|
||||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||||
|
|||||||
@@ -245,8 +245,8 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
|||||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||||
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=
|
||||||
|
|||||||
+72
-9
@@ -3,8 +3,10 @@ package app
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"crypto/subtle"
|
"crypto/subtle"
|
||||||
|
"crypto/tls"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"net"
|
||||||
"net/http"
|
"net/http"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
@@ -30,6 +32,7 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
"golang.org/x/net/http2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// App 应用
|
// App 应用
|
||||||
@@ -60,7 +63,7 @@ type App struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// New 创建新应用
|
// New 创建新应用
|
||||||
func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
func New(cfg *config.Config, log *logger.Logger, configPath string) (*App, error) {
|
||||||
gin.SetMode(gin.ReleaseMode)
|
gin.SetMode(gin.ReleaseMode)
|
||||||
router := gin.Default()
|
router := gin.Default()
|
||||||
|
|
||||||
@@ -292,10 +295,10 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取配置文件路径
|
// 配置文件路径必须由入口传入(与 flag -config 一致)。勿再用 os.Args[1],否则 ./cyberstrike-ai --https 会把 --https 当成路径。
|
||||||
configPath := "config.yaml"
|
configPath = strings.TrimSpace(configPath)
|
||||||
if len(os.Args) > 1 {
|
if configPath == "" {
|
||||||
configPath = os.Args[1]
|
configPath = "config.yaml"
|
||||||
}
|
}
|
||||||
|
|
||||||
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
skillsDir := skillpackage.SkillsRootFromConfig(cfg.SkillsDir, configPath)
|
||||||
@@ -530,18 +533,49 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
|||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 启动主服务器
|
// 启动主服务器(可选 HTTPS + HTTP/2,见 config server.tls_*)
|
||||||
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
addr := fmt.Sprintf("%s:%d", a.config.Server.Host, a.config.Server.Port)
|
||||||
a.logger.Info("启动HTTP服务器", zap.String("address", addr))
|
tlsMode, tlsConf, certFile, keyFile, tlsErr := prepareMainServerTLS(&a.config.Server)
|
||||||
|
if tlsErr != nil {
|
||||||
|
return tlsErr
|
||||||
|
}
|
||||||
|
|
||||||
srv := &http.Server{Addr: addr, Handler: a.router}
|
srv := &http.Server{Addr: addr, Handler: a.router}
|
||||||
|
var mainMux *mainServerMux
|
||||||
|
httpRedirect := config.ServerHTTPRedirectEnabled(&a.config.Server)
|
||||||
|
if tlsMode != mainTLSOff {
|
||||||
|
srv.TLSConfig = tlsConf
|
||||||
|
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
|
||||||
|
return fmt.Errorf("主服务 HTTP/2 配置失败: %w", err)
|
||||||
|
}
|
||||||
|
switch tlsMode {
|
||||||
|
case mainTLSFromFiles:
|
||||||
|
a.logger.Info("启动 HTTPS 主服务(已启用 HTTP/2 协商)",
|
||||||
|
zap.String("address", addr),
|
||||||
|
zap.String("cert", certFile),
|
||||||
|
)
|
||||||
|
case mainTLSInMemorySelfSigned:
|
||||||
|
a.logger.Info("启动 HTTPS 主服务(内存自签证书,仅测试;已启用 HTTP/2 协商)",
|
||||||
|
zap.String("address", addr),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if httpRedirect {
|
||||||
|
a.logger.Info("已启用 HTTP→HTTPS 自动跳转(同端口嗅探分流)", zap.String("address", addr))
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
a.logger.Info("启动 HTTP 主服务", zap.String("address", addr))
|
||||||
|
}
|
||||||
|
|
||||||
// 监听 context 取消,优雅关闭 HTTP 服务器
|
// 监听 context 取消,优雅关闭 HTTP 服务器
|
||||||
go func() {
|
go func() {
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
if err := srv.Shutdown(shutdownCtx); err != nil {
|
if mainMux != nil {
|
||||||
|
if err := mainMux.Shutdown(shutdownCtx); err != nil {
|
||||||
|
a.logger.Error("HTTP/HTTPS 分流服务器关闭失败", zap.Error(err))
|
||||||
|
}
|
||||||
|
} else if err := srv.Shutdown(shutdownCtx); err != nil {
|
||||||
a.logger.Error("HTTP服务器关闭失败", zap.Error(err))
|
a.logger.Error("HTTP服务器关闭失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
if mcpServer != nil {
|
if mcpServer != nil {
|
||||||
@@ -551,7 +585,36 @@ func (a *App) RunWithContext(ctx context.Context) error {
|
|||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
var err error
|
||||||
|
switch {
|
||||||
|
case tlsMode != mainTLSOff && httpRedirect:
|
||||||
|
var tlsConfReady *tls.Config
|
||||||
|
tlsConfReady, err = ensureMainTLSConfigCerts(tlsMode, tlsConf, certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("加载 TLS 证书: %w", err)
|
||||||
|
}
|
||||||
|
srv.TLSConfig = tlsConfReady
|
||||||
|
var ln net.Listener
|
||||||
|
ln, err = net.Listen("tcp", addr)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
mainMux = newMainServerMux(ln, srv, portFromListenAddr(addr), a.logger.Logger)
|
||||||
|
err = mainMux.Serve()
|
||||||
|
case tlsMode == mainTLSOff:
|
||||||
|
err = srv.ListenAndServe()
|
||||||
|
case tlsMode == mainTLSFromFiles:
|
||||||
|
err = srv.ListenAndServeTLS(certFile, keyFile)
|
||||||
|
case tlsMode == mainTLSInMemorySelfSigned:
|
||||||
|
var ln net.Listener
|
||||||
|
ln, err = tls.Listen("tcp", addr, srv.TLSConfig)
|
||||||
|
if err == nil {
|
||||||
|
err = srv.Serve(ln)
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
err = srv.ListenAndServe()
|
||||||
|
}
|
||||||
|
if err != nil && err != http.ErrServerClosed {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
|
"crypto/tls"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"strconv"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// peekedConn 在已预读首字节后仍将连接交给 net/http 或 crypto/tls。
|
||||||
|
type peekedConn struct {
|
||||||
|
net.Conn
|
||||||
|
r *bufio.Reader
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *peekedConn) Read(p []byte) (int, error) {
|
||||||
|
return c.r.Read(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
// oneConnListener 供 http.Server.Serve 处理单条 TCP 连接(含 keep-alive)。
|
||||||
|
type oneConnListener struct {
|
||||||
|
conn net.Conn
|
||||||
|
addr net.Addr
|
||||||
|
once sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *oneConnListener) Accept() (net.Conn, error) {
|
||||||
|
var c net.Conn
|
||||||
|
l.once.Do(func() {
|
||||||
|
c = l.conn
|
||||||
|
l.conn = nil
|
||||||
|
})
|
||||||
|
if c == nil {
|
||||||
|
return nil, net.ErrClosed
|
||||||
|
}
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (l *oneConnListener) Close() error { return nil }
|
||||||
|
func (l *oneConnListener) Addr() net.Addr { return l.addr }
|
||||||
|
|
||||||
|
func isTLSHandshakeRecord(b byte) bool {
|
||||||
|
return b == 0x16
|
||||||
|
}
|
||||||
|
|
||||||
|
func newHTTPToHTTPSRedirectHandler(httpsPort int) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
host := r.Host
|
||||||
|
if h, _, err := net.SplitHostPort(host); err == nil {
|
||||||
|
host = h
|
||||||
|
}
|
||||||
|
var target string
|
||||||
|
if httpsPort == 443 {
|
||||||
|
target = fmt.Sprintf("https://%s%s", host, r.URL.RequestURI())
|
||||||
|
} else {
|
||||||
|
target = fmt.Sprintf("https://%s:%d%s", host, httpsPort, r.URL.RequestURI())
|
||||||
|
}
|
||||||
|
http.Redirect(w, r, target, http.StatusPermanentRedirect)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func portFromListenAddr(addr string) int {
|
||||||
|
_, portStr, err := net.SplitHostPort(addr)
|
||||||
|
if err != nil {
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
p, err := strconv.Atoi(portStr)
|
||||||
|
if err != nil || p <= 0 {
|
||||||
|
return 443
|
||||||
|
}
|
||||||
|
return p
|
||||||
|
}
|
||||||
|
|
||||||
|
func ensureMainTLSConfigCerts(mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string) (*tls.Config, error) {
|
||||||
|
if mode != mainTLSFromFiles {
|
||||||
|
return tlsConf, nil
|
||||||
|
}
|
||||||
|
if tlsConf == nil {
|
||||||
|
tlsConf = &tls.Config{MinVersion: tls.VersionTLS12}
|
||||||
|
}
|
||||||
|
if len(tlsConf.Certificates) > 0 {
|
||||||
|
return tlsConf, nil
|
||||||
|
}
|
||||||
|
cert, err := tls.LoadX509KeyPair(certFile, keyFile)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
tlsConf.Certificates = []tls.Certificate{cert}
|
||||||
|
return tlsConf, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type mainServerMux struct {
|
||||||
|
ln net.Listener
|
||||||
|
httpsSrv *http.Server
|
||||||
|
redirectSrv *http.Server
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMainServerMux(ln net.Listener, httpsSrv *http.Server, httpsPort int, logger *zap.Logger) *mainServerMux {
|
||||||
|
return &mainServerMux{
|
||||||
|
ln: ln,
|
||||||
|
httpsSrv: httpsSrv,
|
||||||
|
redirectSrv: &http.Server{Handler: newHTTPToHTTPSRedirectHandler(httpsPort), ReadHeaderTimeout: 10 * time.Second},
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mainServerMux) Serve() error {
|
||||||
|
for {
|
||||||
|
conn, err := m.ln.Accept()
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, net.ErrClosed) {
|
||||||
|
return http.ErrServerClosed
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
go m.handleConn(conn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mainServerMux) handleConn(raw net.Conn) {
|
||||||
|
if err := raw.SetReadDeadline(time.Now().Add(10 * time.Second)); err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
br := bufio.NewReader(raw)
|
||||||
|
b, err := br.Peek(1)
|
||||||
|
if err != nil {
|
||||||
|
_ = raw.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_ = raw.SetReadDeadline(time.Time{})
|
||||||
|
|
||||||
|
pc := &peekedConn{Conn: raw, r: br}
|
||||||
|
ocl := &oneConnListener{conn: pc, addr: raw.LocalAddr()}
|
||||||
|
|
||||||
|
if isTLSHandshakeRecord(b[0]) {
|
||||||
|
m.serveHTTPS(pc, raw.LocalAddr())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.redirectSrv.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
m.logger.Debug("HTTP 重定向连接处理结束", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// serveHTTPS 在已嗅探为 TLS 的连接上完成握手,再按 ALPN 走 HTTP/2 或 HTTP/1.1。
|
||||||
|
// 不能对同一 http.Server 并发调用 Serve(TLSConfig!=nil),否则握手/ALPN 会异常(浏览器 ERR_SSL_PROTOCOL_ERROR)。
|
||||||
|
func (m *mainServerMux) serveHTTPS(pc *peekedConn, localAddr net.Addr) {
|
||||||
|
tlsConn := tls.Server(pc, m.httpsSrv.TLSConfig)
|
||||||
|
handCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
|
defer cancel()
|
||||||
|
if err := tlsConn.HandshakeContext(handCtx); err != nil {
|
||||||
|
m.logger.Debug("TLS 握手失败", zap.Error(err))
|
||||||
|
_ = pc.Close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
srv := m.httpsSrv
|
||||||
|
if srv.TLSNextProto != nil {
|
||||||
|
proto := tlsConn.ConnectionState().NegotiatedProtocol
|
||||||
|
if fn := srv.TLSNextProto[proto]; fn != nil {
|
||||||
|
fn(srv, tlsConn, srv.Handler)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := *srv
|
||||||
|
plain.TLSConfig = nil
|
||||||
|
ocl := &oneConnListener{conn: tlsConn, addr: localAddr}
|
||||||
|
if err := plain.Serve(ocl); err != nil && !errors.Is(err, net.ErrClosed) && !errors.Is(err, http.ErrServerClosed) {
|
||||||
|
m.logger.Debug("HTTPS 连接处理结束", zap.Error(err))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *mainServerMux) Shutdown(ctx context.Context) error {
|
||||||
|
_ = m.ln.Close()
|
||||||
|
var err1, err2 error
|
||||||
|
if m.httpsSrv != nil {
|
||||||
|
err1 = m.httpsSrv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
if m.redirectSrv != nil {
|
||||||
|
err2 = m.redirectSrv.Shutdown(ctx)
|
||||||
|
}
|
||||||
|
if err1 != nil {
|
||||||
|
return err1
|
||||||
|
}
|
||||||
|
return err2
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/tls"
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strconv"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
|
||||||
|
"golang.org/x/net/http2"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNewHTTPToHTTPSRedirectHandler(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
httpsPort int
|
||||||
|
host string
|
||||||
|
uri string
|
||||||
|
wantTarget string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "non standard port",
|
||||||
|
httpsPort: 8080,
|
||||||
|
host: "127.0.0.1:8080",
|
||||||
|
uri: "/login?next=/",
|
||||||
|
wantTarget: "https://127.0.0.1:8080/login?next=/",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "standard port",
|
||||||
|
httpsPort: 443,
|
||||||
|
host: "example.com:80",
|
||||||
|
uri: "/",
|
||||||
|
wantTarget: "https://example.com/",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
tt := tt
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
h := newHTTPToHTTPSRedirectHandler(tt.httpsPort)
|
||||||
|
req := httptest.NewRequest(http.MethodGet, "http://"+tt.host+tt.uri, nil)
|
||||||
|
req.Host = tt.host
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
h.ServeHTTP(rec, req)
|
||||||
|
if rec.Code != http.StatusPermanentRedirect {
|
||||||
|
t.Fatalf("status = %d, want %d", rec.Code, http.StatusPermanentRedirect)
|
||||||
|
}
|
||||||
|
if got := rec.Header().Get("Location"); got != tt.wantTarget {
|
||||||
|
t.Fatalf("Location = %q, want %q", got, tt.wantTarget)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIsTLSHandshakeRecord(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
if !isTLSHandshakeRecord(0x16) {
|
||||||
|
t.Fatal("expected TLS handshake record")
|
||||||
|
}
|
||||||
|
if isTLSHandshakeRecord('G') {
|
||||||
|
t.Fatal("GET should not be TLS")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestServerHTTPRedirectEnabled(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
disabled := false
|
||||||
|
enabled := true
|
||||||
|
if config.ServerHTTPRedirectEnabled(nil) {
|
||||||
|
t.Fatal("nil config should disable redirect")
|
||||||
|
}
|
||||||
|
if !config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true}) {
|
||||||
|
t.Fatal("HTTPS without explicit flag should enable redirect")
|
||||||
|
}
|
||||||
|
if config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true, TLSHTTPRedirect: &disabled}) {
|
||||||
|
t.Fatal("explicit false should disable redirect")
|
||||||
|
}
|
||||||
|
if !config.ServerHTTPRedirectEnabled(&config.ServerConfig{TLSEnabled: true, TLSHTTPRedirect: &enabled}) {
|
||||||
|
t.Fatal("explicit true should enable redirect")
|
||||||
|
}
|
||||||
|
if config.ServerHTTPRedirectEnabled(&config.ServerConfig{}) {
|
||||||
|
t.Fatal("plain HTTP should not redirect")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainServerMuxHTTPRedirectAndHTTPS(t *testing.T) {
|
||||||
|
cert, err := generateMainServerSelfSignedCert()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate cert: %v", err)
|
||||||
|
}
|
||||||
|
handler := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
_, _ = io.WriteString(w, "ok")
|
||||||
|
})
|
||||||
|
srv := &http.Server{Handler: handler, TLSConfig: &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
}}
|
||||||
|
if err := http2.ConfigureServer(srv, &http2.Server{}); err != nil {
|
||||||
|
t.Fatalf("configure http2: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ln, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("listen: %v", err)
|
||||||
|
}
|
||||||
|
defer ln.Close()
|
||||||
|
|
||||||
|
mux := newMainServerMux(ln, srv, portFromListenAddr(ln.Addr().String()), nil)
|
||||||
|
go func() { _ = mux.Serve() }()
|
||||||
|
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &http.Transport{
|
||||||
|
TLSClientConfig: &tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12},
|
||||||
|
},
|
||||||
|
CheckRedirect: func(_ *http.Request, _ []*http.Request) error {
|
||||||
|
return http.ErrUseLastResponse
|
||||||
|
},
|
||||||
|
}
|
||||||
|
addr := ln.Addr().String()
|
||||||
|
|
||||||
|
httpResp, err := client.Get("http://" + addr + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("http get: %v", err)
|
||||||
|
}
|
||||||
|
_ = httpResp.Body.Close()
|
||||||
|
if httpResp.StatusCode != http.StatusPermanentRedirect {
|
||||||
|
t.Fatalf("http status = %d, want %d", httpResp.StatusCode, http.StatusPermanentRedirect)
|
||||||
|
}
|
||||||
|
if got := httpResp.Header.Get("Location"); got != "https://127.0.0.1:"+strconv.Itoa(portFromListenAddr(addr))+"/" {
|
||||||
|
t.Fatalf("Location = %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
httpsResp, err := client.Get("https://" + addr + "/")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("https get: %v", err)
|
||||||
|
}
|
||||||
|
defer httpsResp.Body.Close()
|
||||||
|
if httpsResp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("https status = %d, want %d", httpsResp.StatusCode, http.StatusOK)
|
||||||
|
}
|
||||||
|
body, _ := io.ReadAll(httpsResp.Body)
|
||||||
|
if string(body) != "ok" {
|
||||||
|
t.Fatalf("body = %q, want ok", body)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"crypto/ecdsa"
|
||||||
|
"crypto/elliptic"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/tls"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
|
"encoding/pem"
|
||||||
|
"fmt"
|
||||||
|
"math/big"
|
||||||
|
"net"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// mainTLSMode 主 Web 服务 TLS 启动方式。
|
||||||
|
type mainTLSMode int
|
||||||
|
|
||||||
|
const (
|
||||||
|
mainTLSOff mainTLSMode = iota
|
||||||
|
mainTLSFromFiles
|
||||||
|
mainTLSInMemorySelfSigned
|
||||||
|
)
|
||||||
|
|
||||||
|
// prepareMainServerTLS 根据 server 配置决定主站是否启用 HTTPS(及 HTTP/2 协商)。
|
||||||
|
// fromFiles:使用 tls_cert_path + tls_key_path,由 http.Server.ListenAndServeTLS 加载 PEM。
|
||||||
|
// inMemory:tls_auto_self_sign 生成的自签证书,仅用于本地/测试。
|
||||||
|
func prepareMainServerTLS(cfg *config.ServerConfig) (mode mainTLSMode, tlsConf *tls.Config, certFile, keyFile string, err error) {
|
||||||
|
if cfg == nil || !config.MainWebUIUsesHTTPS(cfg) {
|
||||||
|
return mainTLSOff, nil, "", "", nil
|
||||||
|
}
|
||||||
|
certFile = strings.TrimSpace(cfg.TLSCertPath)
|
||||||
|
keyFile = strings.TrimSpace(cfg.TLSKeyPath)
|
||||||
|
if certFile != "" && keyFile != "" {
|
||||||
|
// 证书由 ListenAndServeTLS 从文件加载;此处仅提供最小 TLS 配置供 http2.ConfigureServer 合并 ALPN。
|
||||||
|
return mainTLSFromFiles, &tls.Config{MinVersion: tls.VersionTLS12}, certFile, keyFile, nil
|
||||||
|
}
|
||||||
|
if cfg.TLSAutoSelfSign {
|
||||||
|
cert, genErr := generateMainServerSelfSignedCert()
|
||||||
|
if genErr != nil {
|
||||||
|
return mainTLSOff, nil, "", "", fmt.Errorf("生成自签 TLS 证书: %w", genErr)
|
||||||
|
}
|
||||||
|
tlsConf = &tls.Config{
|
||||||
|
MinVersion: tls.VersionTLS12,
|
||||||
|
Certificates: []tls.Certificate{cert},
|
||||||
|
}
|
||||||
|
return mainTLSInMemorySelfSigned, tlsConf, "", "", nil
|
||||||
|
}
|
||||||
|
return mainTLSOff, nil, "", "", fmt.Errorf("server: 已启用 TLS(tls_enabled / tls_auto_self_sign / 证书路径),请设置 tls_cert_path 与 tls_key_path,或将 tls_auto_self_sign 设为 true(仅测试环境)")
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateMainServerSelfSignedCert() (tls.Certificate, error) {
|
||||||
|
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
serial, err := rand.Int(rand.Reader, big.NewInt(1<<62))
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
tmpl := &x509.Certificate{
|
||||||
|
SerialNumber: serial,
|
||||||
|
Subject: pkix.Name{CommonName: "CyberStrikeAI"},
|
||||||
|
NotBefore: time.Now().Add(-1 * time.Hour),
|
||||||
|
NotAfter: time.Now().Add(365 * 24 * time.Hour),
|
||||||
|
KeyUsage: x509.KeyUsageDigitalSignature,
|
||||||
|
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||||
|
IPAddresses: []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")},
|
||||||
|
DNSNames: []string{"localhost"},
|
||||||
|
}
|
||||||
|
der, err := x509.CreateCertificate(rand.Reader, tmpl, tmpl, &priv.PublicKey, priv)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
keyDER, err := x509.MarshalECPrivateKey(priv)
|
||||||
|
if err != nil {
|
||||||
|
return tls.Certificate{}, err
|
||||||
|
}
|
||||||
|
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||||
|
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "EC PRIVATE KEY", Bytes: keyDER})
|
||||||
|
return tls.X509KeyPair(certPEM, keyPEM)
|
||||||
|
}
|
||||||
@@ -239,13 +239,15 @@ func (m *Manager) StartListener(id string) (*database.C2Listener, error) {
|
|||||||
}
|
}
|
||||||
cfg.ApplyDefaults()
|
cfg.ApplyDefaults()
|
||||||
|
|
||||||
// 通过工厂创建具体实现
|
// 通过工厂创建具体实现。必须使用 rec 的副本:HTTP handler 在返回 JSON 前会清空
|
||||||
|
// rec.ImplantToken / EncryptionKey 做脱敏,若 listener 实现持有同一指针会导致 beacon 鉴权永久失败。
|
||||||
|
listenerRec := *rec
|
||||||
factory := m.registry.Get(rec.Type)
|
factory := m.registry.Get(rec.Type)
|
||||||
if factory == nil {
|
if factory == nil {
|
||||||
return nil, ErrUnsupportedType
|
return nil, ErrUnsupportedType
|
||||||
}
|
}
|
||||||
inst, err := factory(ListenerCreationCtx{
|
inst, err := factory(ListenerCreationCtx{
|
||||||
Listener: rec,
|
Listener: &listenerRec,
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
Manager: m,
|
Manager: m,
|
||||||
Logger: m.logger.With(zap.String("listener_id", rec.ID), zap.String("type", rec.Type)),
|
Logger: m.logger.With(zap.String("listener_id", rec.ID), zap.String("type", rec.Type)),
|
||||||
|
|||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package c2
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net"
|
||||||
|
"net/http"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"cyberstrike-ai/internal/database"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 回归:StartListener 返回的 rec 被 handler 脱敏清空 ImplantToken 后,运行中的 HTTP listener 仍能鉴权。
|
||||||
|
func TestStartListener_ImplantTokenSurvivesHandlerRedaction(t *testing.T) {
|
||||||
|
tmp := t.TempDir()
|
||||||
|
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = db.Close() })
|
||||||
|
|
||||||
|
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
port := lnPick.Addr().(*net.TCPAddr).Port
|
||||||
|
_ = lnPick.Close()
|
||||||
|
|
||||||
|
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||||
|
mgr.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
|
||||||
|
rec, err := mgr.CreateListener(CreateListenerInput{
|
||||||
|
Name: "t",
|
||||||
|
Type: string(ListenerTypeHTTPBeacon),
|
||||||
|
BindHost: "127.0.0.1",
|
||||||
|
BindPort: port,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
token := rec.ImplantToken
|
||||||
|
|
||||||
|
rec, err = mgr.StartListener(rec.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// 模拟 internal/handler/c2.go StartListener 在 JSON 响应前的脱敏
|
||||||
|
rec.ImplantToken = ""
|
||||||
|
rec.EncryptionKey = ""
|
||||||
|
|
||||||
|
time.Sleep(50 * time.Millisecond)
|
||||||
|
|
||||||
|
body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}`
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/check_in", strings.NewReader(body))
|
||||||
|
req.Header.Set("X-Implant-Token", token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
b, _ := io.ReadAll(resp.Body)
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(b), "session_id") {
|
||||||
|
t.Fatalf("expected session_id in body: %s", b)
|
||||||
|
}
|
||||||
|
_ = mgr.StopListener(rec.ID)
|
||||||
|
}
|
||||||
@@ -391,7 +391,8 @@ type MultiAgentAPIUpdate struct {
|
|||||||
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
RobotUseMultiAgent bool `json:"robot_use_multi_agent"`
|
||||||
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
BatchUseMultiAgent bool `json:"batch_use_multi_agent"`
|
||||||
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
PlanExecuteLoopMaxIterations *int `json:"plan_execute_loop_max_iterations,omitempty"`
|
||||||
ToolSearchAlwaysVisibleTools []string `json:"tool_search_always_visible_tools,omitempty"`
|
// 指针区分「JSON 未传该字段」与「传空数组要清空」;省略时不应覆盖 YAML 中的常驻工具白名单。
|
||||||
|
ToolSearchAlwaysVisibleTools *[]string `json:"tool_search_always_visible_tools,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
// RobotsConfig 机器人配置(企业微信、钉钉、飞书等)
|
||||||
@@ -443,8 +444,17 @@ type RobotLarkConfig struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ServerConfig struct {
|
type ServerConfig struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host" json:"host"`
|
||||||
Port int `yaml:"port"`
|
Port int `yaml:"port" json:"port"`
|
||||||
|
// TLSEnabled 为 true 时主 Web UI 使用 HTTPS;现代浏览器在同源下会协商 HTTP/2,缓解 HTTP/1.1 每源并发连接数限制。
|
||||||
|
TLSEnabled bool `yaml:"tls_enabled,omitempty" json:"tls_enabled,omitempty"`
|
||||||
|
// TLSCertPath / TLSKeyPath 非空时从 PEM 文件加载证书(生产环境推荐)。
|
||||||
|
TLSCertPath string `yaml:"tls_cert_path,omitempty" json:"tls_cert_path,omitempty"`
|
||||||
|
TLSKeyPath string `yaml:"tls_key_path,omitempty" json:"tls_key_path,omitempty"`
|
||||||
|
// TLSAutoSelfSign 为 true 且未配置有效证书路径时,启动时生成内存自签证书(仅本地/测试;浏览器会提示不受信任)。
|
||||||
|
TLSAutoSelfSign bool `yaml:"tls_auto_self_sign,omitempty" json:"tls_auto_self_sign,omitempty"`
|
||||||
|
// TLSHTTPRedirect 为 false 时禁用 HTTP→HTTPS 跳转;省略或为 true 且已启用 HTTPS 时,明文 HTTP 访问将 308 跳转到 HTTPS(同端口嗅探分流)。
|
||||||
|
TLSHTTPRedirect *bool `yaml:"tls_http_redirect,omitempty" json:"tls_http_redirect,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type LogConfig struct {
|
type LogConfig struct {
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package config
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// MainWebUIUsesHTTPS 判断主 Web UI 是否以 HTTPS 监听(与 internal/app.prepareMainServerTLS 前置条件一致)。
|
||||||
|
func MainWebUIUsesHTTPS(s *ServerConfig) bool {
|
||||||
|
if s == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.TLSEnabled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if s.TLSAutoSelfSign {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
cert := strings.TrimSpace(s.TLSCertPath)
|
||||||
|
key := strings.TrimSpace(s.TLSKeyPath)
|
||||||
|
return cert != "" && key != ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServerHTTPRedirectEnabled 是否在主站启用 HTTPS 时把明文 HTTP 请求重定向到 HTTPS(默认开启)。
|
||||||
|
func ServerHTTPRedirectEnabled(s *ServerConfig) bool {
|
||||||
|
if s == nil || !MainWebUIUsesHTTPS(s) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if s.TLSHTTPRedirect == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return *s.TLSHTTPRedirect
|
||||||
|
}
|
||||||
|
|
||||||
|
// ApplyDevHTTPSBootstrap 供 --https / 一键脚本使用:强制开启主站 TLS。
|
||||||
|
// 若已配置 tls_cert_path 与 tls_key_path 则仅用 PEM,不开启自签;否则启用 tls_auto_self_sign(内存证书,仅本地测试)。
|
||||||
|
func ApplyDevHTTPSBootstrap(cfg *Config) {
|
||||||
|
if cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Server.TLSEnabled = true
|
||||||
|
cert := strings.TrimSpace(cfg.Server.TLSCertPath)
|
||||||
|
key := strings.TrimSpace(cfg.Server.TLSKeyPath)
|
||||||
|
if cert != "" && key != "" {
|
||||||
|
cfg.Server.TLSAutoSelfSign = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cfg.Server.TLSAutoSelfSign = true
|
||||||
|
}
|
||||||
@@ -26,7 +26,7 @@ type Conversation struct {
|
|||||||
// Message 消息
|
// Message 消息
|
||||||
type Message struct {
|
type Message struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ConversationID string `json:"conversationId"`
|
ConversationID string `json:"conversationId"`
|
||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Content string `json:"content"`
|
Content string `json:"content"`
|
||||||
ReasoningContent string `json:"reasoningContent,omitempty"`
|
ReasoningContent string `json:"reasoningContent,omitempty"`
|
||||||
@@ -117,6 +117,7 @@ func (db *DB) GetConversationByWebshellConnectionID(connectionID string) (*Conve
|
|||||||
}
|
}
|
||||||
for i := range conv.Messages {
|
for i := range conv.Messages {
|
||||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||||
|
details = DedupeConsecutiveProcessDetails(details)
|
||||||
detailsJSON := make([]map[string]interface{}, len(details))
|
detailsJSON := make([]map[string]interface{}, len(details))
|
||||||
for j, detail := range details {
|
for j, detail := range details {
|
||||||
var data interface{}
|
var data interface{}
|
||||||
@@ -235,6 +236,7 @@ func (db *DB) GetConversation(id string) (*Conversation, error) {
|
|||||||
// 将过程详情附加到对应的消息上
|
// 将过程详情附加到对应的消息上
|
||||||
for i := range conv.Messages {
|
for i := range conv.Messages {
|
||||||
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
if details, ok := processDetailsMap[conv.Messages[i].ID]; ok {
|
||||||
|
details = DedupeConsecutiveProcessDetails(details)
|
||||||
// 将ProcessDetail转换为JSON格式,以便前端使用
|
// 将ProcessDetail转换为JSON格式,以便前端使用
|
||||||
detailsJSON := make([]map[string]interface{}, len(details))
|
detailsJSON := make([]map[string]interface{}, len(details))
|
||||||
for j, detail := range details {
|
for j, detail := range details {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DedupeConsecutiveProcessDetails 去掉相邻且语义相同的过程详情(使用 DB 中 data 列原始 JSON 作指纹,避免 map 序列化键序不稳定)。
|
||||||
|
func DedupeConsecutiveProcessDetails(rows []ProcessDetail) []ProcessDetail {
|
||||||
|
if len(rows) < 2 {
|
||||||
|
return rows
|
||||||
|
}
|
||||||
|
out := make([]ProcessDetail, 0, len(rows))
|
||||||
|
var lastKey string
|
||||||
|
for _, d := range rows {
|
||||||
|
key := processDetailRowKey(d)
|
||||||
|
if len(out) > 0 && key != "" && key == lastKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, d)
|
||||||
|
lastKey = key
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func processDetailRowKey(d ProcessDetail) string {
|
||||||
|
return fmt.Sprintf("%s\x00%s\x00%s", d.EventType, strings.TrimSpace(d.Message), d.Data)
|
||||||
|
}
|
||||||
@@ -755,7 +755,9 @@ func (h *ConfigHandler) UpdateConfig(c *gin.Context) {
|
|||||||
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
if req.MultiAgent.PlanExecuteLoopMaxIterations != nil {
|
||||||
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
h.config.MultiAgent.PlanExecuteLoopMaxIterations = *req.MultiAgent.PlanExecuteLoopMaxIterations
|
||||||
}
|
}
|
||||||
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(req.MultiAgent.ToolSearchAlwaysVisibleTools)
|
if req.MultiAgent.ToolSearchAlwaysVisibleTools != nil {
|
||||||
|
h.config.MultiAgent.EinoMiddleware.ToolSearchAlwaysVisibleTools = dedupeToolNameList(*req.MultiAgent.ToolSearchAlwaysVisibleTools)
|
||||||
|
}
|
||||||
h.logger.Info("更新多代理配置",
|
h.logger.Info("更新多代理配置",
|
||||||
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
zap.Bool("enabled", h.config.MultiAgent.Enabled),
|
||||||
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
|
zap.Bool("robot_use_multi_agent", h.config.MultiAgent.RobotUseMultiAgent),
|
||||||
@@ -1474,6 +1476,11 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
|||||||
root := doc.Content[0]
|
root := doc.Content[0]
|
||||||
robotsNode := ensureMap(root, "robots")
|
robotsNode := ensureMap(root, "robots")
|
||||||
|
|
||||||
|
if cfg.Session.StrictUserIdentity != nil {
|
||||||
|
sessionNode := ensureMap(robotsNode, "session")
|
||||||
|
setBoolInMap(sessionNode, "strict_user_identity", *cfg.Session.StrictUserIdentity)
|
||||||
|
}
|
||||||
|
|
||||||
wecomNode := ensureMap(robotsNode, "wecom")
|
wecomNode := ensureMap(robotsNode, "wecom")
|
||||||
setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled)
|
setBoolInMap(wecomNode, "enabled", cfg.Wecom.Enabled)
|
||||||
setStringInMap(wecomNode, "token", cfg.Wecom.Token)
|
setStringInMap(wecomNode, "token", cfg.Wecom.Token)
|
||||||
@@ -1486,12 +1493,14 @@ func updateRobotsConfig(doc *yaml.Node, cfg config.RobotsConfig) {
|
|||||||
setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled)
|
setBoolInMap(dingtalkNode, "enabled", cfg.Dingtalk.Enabled)
|
||||||
setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID)
|
setStringInMap(dingtalkNode, "client_id", cfg.Dingtalk.ClientID)
|
||||||
setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret)
|
setStringInMap(dingtalkNode, "client_secret", cfg.Dingtalk.ClientSecret)
|
||||||
|
setBoolInMap(dingtalkNode, "allow_conversation_id_fallback", cfg.Dingtalk.AllowConversationIDFallback)
|
||||||
|
|
||||||
larkNode := ensureMap(robotsNode, "lark")
|
larkNode := ensureMap(robotsNode, "lark")
|
||||||
setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled)
|
setBoolInMap(larkNode, "enabled", cfg.Lark.Enabled)
|
||||||
setStringInMap(larkNode, "app_id", cfg.Lark.AppID)
|
setStringInMap(larkNode, "app_id", cfg.Lark.AppID)
|
||||||
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
|
setStringInMap(larkNode, "app_secret", cfg.Lark.AppSecret)
|
||||||
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
|
setStringInMap(larkNode, "verify_token", cfg.Lark.VerifyToken)
|
||||||
|
setBoolInMap(larkNode, "allow_chat_id_fallback", cfg.Lark.AllowChatIDFallback)
|
||||||
}
|
}
|
||||||
|
|
||||||
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
func updateMultiAgentConfig(doc *yaml.Node, cfg config.MultiAgentConfig) {
|
||||||
|
|||||||
@@ -117,6 +117,8 @@ func (h *ConversationHandler) GetMessageProcessDetails(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
details = database.DedupeConsecutiveProcessDetails(details)
|
||||||
|
|
||||||
// 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致)
|
// 转换为前端期望的 JSON 结构(与 GetConversation 中 processDetails 结构一致)
|
||||||
out := make([]map[string]interface{}, 0, len(details))
|
out := make([]map[string]interface{}, 0, len(details))
|
||||||
for _, d := range details {
|
for _, d := range details {
|
||||||
|
|||||||
@@ -573,6 +573,8 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
var subAssistantBuf string
|
var subAssistantBuf string
|
||||||
var subReplyStreamID string
|
var subReplyStreamID string
|
||||||
var mainAssistantBuf string
|
var mainAssistantBuf string
|
||||||
|
// 已通过 response_delta 推到前端的正文(与 monitor.js normalizeStreamingDeltaJs 累积一致)
|
||||||
|
var mainAssistWireAccum string
|
||||||
var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重
|
var mainAssistDupTarget string // 非空表示本段主助手流需缓冲至 EOF,与 execute 输出比对去重
|
||||||
var reasoningBuf string
|
var reasoningBuf string
|
||||||
var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示
|
var prevReasoningDisplay string // UI 用:剥离 Claude 内部 signature 尾缀后的累计展示
|
||||||
@@ -681,6 +683,7 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
"einoAgent": ev.AgentName,
|
"einoAgent": ev.AgentName,
|
||||||
"orchestration": orchMode,
|
"orchestration": orchMode,
|
||||||
})
|
})
|
||||||
|
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, contentDelta)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if !streamsMainAssistant(ev.AgentName) {
|
} else if !streamsMainAssistant(ev.AgentName) {
|
||||||
@@ -726,21 +729,29 @@ func runEinoADKAgentLoop(ctx context.Context, args *einoADKRunLoopArgs, baseMsgs
|
|||||||
}
|
}
|
||||||
} else if s != "" {
|
} else if s != "" {
|
||||||
if progress != nil {
|
if progress != nil {
|
||||||
progress("response_start", "", map[string]interface{}{
|
// 仅用 TrimSpace 与 execute 比对;推到 UI 的必须是 mainAssistantBuf,
|
||||||
"conversationId": conversationID,
|
// 否则尾部空白/换行与已流式前缀不一致时,前端 normalize 会走拼接路径造成叠字。
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
_, eofTail := normalizeStreamingDelta(mainAssistWireAccum, mainAssistantBuf)
|
||||||
"messageGeneratedBy": "eino:" + ev.AgentName,
|
if eofTail != "" {
|
||||||
"einoRole": "orchestrator",
|
if !streamHeaderSent {
|
||||||
"einoAgent": ev.AgentName,
|
progress("response_start", "", map[string]interface{}{
|
||||||
"orchestration": orchMode,
|
"conversationId": conversationID,
|
||||||
})
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
progress("response_delta", s, map[string]interface{}{
|
"messageGeneratedBy": "eino:" + ev.AgentName,
|
||||||
"conversationId": conversationID,
|
"einoRole": "orchestrator",
|
||||||
"mcpExecutionIds": snapshotMCPIDs(),
|
"einoAgent": ev.AgentName,
|
||||||
"einoRole": "orchestrator",
|
"orchestration": orchMode,
|
||||||
"einoAgent": ev.AgentName,
|
})
|
||||||
"orchestration": orchMode,
|
}
|
||||||
})
|
progress("response_delta", eofTail, map[string]interface{}{
|
||||||
|
"conversationId": conversationID,
|
||||||
|
"mcpExecutionIds": snapshotMCPIDs(),
|
||||||
|
"einoRole": "orchestrator",
|
||||||
|
"einoAgent": ev.AgentName,
|
||||||
|
"orchestration": orchMode,
|
||||||
|
})
|
||||||
|
mainAssistWireAccum, _ = normalizeStreamingDelta(mainAssistWireAccum, eofTail)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lastAssistant = s
|
lastAssistant = s
|
||||||
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
runAccumulatedMsgs = append(runAccumulatedMsgs, schema.AssistantMessage(s, nil))
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Eino execute 去重分支 EOF flush 须以 mainAssistantBuf 为基准计算 tail,
|
||||||
|
// 若误用 TrimSpace(mainAssistantBuf),会与已推前缀在空白处失配,normalize 走拼接路径叠字。
|
||||||
|
func TestNormalizeStreamingDelta_eofTailUsesRawBufNotTrim(t *testing.T) {
|
||||||
|
wireAccum := "phrase "
|
||||||
|
rawFull := "phrase \n"
|
||||||
|
_, tail := normalizeStreamingDelta(wireAccum, rawFull)
|
||||||
|
if want := "\n"; tail != want {
|
||||||
|
t.Fatalf("tail=%q want %q", tail, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
nextWrong, badTail := normalizeStreamingDelta(wireAccum, strings.TrimSpace(rawFull))
|
||||||
|
if badTail != "phrase" || nextWrong != "phrase phrase" {
|
||||||
|
t.Fatalf("trimmed full vs wire prefix mismatch should concat-append; got next=%q badTail=%q", nextWrong, badTail)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
### What it does
|
### What it does
|
||||||
|
|
||||||
- Configure **Host / Port / Password** and choose **Single-Agent** or **Multi-Agent**
|
- Configure **Host / Port / HTTPS / Password** and choose an agent mode
|
||||||
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
||||||
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
|
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
|
||||||
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
||||||
@@ -63,6 +63,7 @@ If you already have Gradle available, you can still use `build.gradle` to build.
|
|||||||
|
|
||||||
### Notes
|
### Notes
|
||||||
|
|
||||||
- This extension connects to your CyberStrikeAI server (default is `http://127.0.0.1:8080`).
|
- Default connection is `https://127.0.0.1:8080` (**HTTPS** checked). Self-signed / local certs are trusted automatically (no import).
|
||||||
|
- Uncheck **HTTPS** only if your server runs plain HTTP.
|
||||||
- It uses **Bearer Token** authentication obtained from the configured password.
|
- It uses **Bearer Token** authentication obtained from the configured password.
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,8 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
|
|||||||
2) 填写:
|
2) 填写:
|
||||||
- **Host**:例如 `127.0.0.1`
|
- **Host**:例如 `127.0.0.1`
|
||||||
- **Port**:例如 `8080`
|
- **Port**:例如 `8080`
|
||||||
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `config.yaml` 的 `auth.password`)
|
- **HTTPS**:默认勾选(对接 `config.yaml` 中 `tls_enabled` / 自签证书);插件会自动信任本地自签证书,无需导入
|
||||||
|
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `auth.password`)
|
||||||
- **Agent mode**:选择 `Single Agent` 或 `Multi Agent`
|
- **Agent mode**:选择 `Single Agent` 或 `Multi Agent`
|
||||||
3) 点击 **Validate**
|
3) 点击 **Validate**
|
||||||
- 成功:状态显示 `OK (token saved)`
|
- 成功:状态显示 `OK (token saved)`
|
||||||
@@ -94,8 +95,9 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
|
|||||||
|
|
||||||
- **Validate 失败 / 401**
|
- **Validate 失败 / 401**
|
||||||
- 确认密码是否正确(服务端 `auth.password`)
|
- 确认密码是否正确(服务端 `auth.password`)
|
||||||
- 确认 IP/端口是否能访问(例如浏览器能打开 `http://IP:PORT/`)
|
- 确认 IP/端口是否能访问(例如浏览器能打开 `https://IP:PORT/`)
|
||||||
- 若服务器启用了反向代理/HTTPS,需要把插件里 baseUrl 改成对应协议与端口(当前插件默认使用 `http://`)
|
- 服务端启用 TLS 时勾选 **HTTPS**(默认已勾选);自签证书无需手动导入
|
||||||
|
- 若仍为纯 HTTP 部署,取消勾选 **HTTPS**
|
||||||
|
|
||||||
- **选择 Multi Agent 后提示“多代理未启用”**
|
- **选择 Multi Agent 后提示“多代理未启用”**
|
||||||
- 服务端需要开启:`config.yaml` 中 `multi_agent.enabled: true`
|
- 服务端需要开启:`config.yaml` 中 `multi_agent.enabled: true`
|
||||||
|
|||||||
BIN
Binary file not shown.
+52
-11
@@ -73,15 +73,34 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
|||||||
public void onEvent(String type, String message, String rawJson) {
|
public void onEvent(String type, String message, String rawJson) {
|
||||||
if (type == null) type = "";
|
if (type == null) type = "";
|
||||||
switch (type) {
|
switch (type) {
|
||||||
|
case "response_start":
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[主回复]\n");
|
||||||
|
break;
|
||||||
case "response_delta":
|
case "response_delta":
|
||||||
case "eino_agent_reply_stream_delta":
|
if (message != null && !message.isEmpty()) {
|
||||||
tab.appendFinalToRun(runId, message);
|
tab.appendFinalToRun(runId, message);
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
case "response":
|
case "response":
|
||||||
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
|
||||||
tab.appendFinalToRun(runId, message);
|
tab.appendFinalToRun(runId, message);
|
||||||
tab.setFinalResponse(runId, message);
|
tab.setFinalResponse(runId, message);
|
||||||
break;
|
break;
|
||||||
|
case "eino_agent_reply_stream_start":
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n");
|
||||||
|
break;
|
||||||
|
case "eino_agent_reply_stream_delta":
|
||||||
|
if (message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "eino_agent_reply_stream_end":
|
||||||
|
tab.appendProgressToRun(runId, "\n");
|
||||||
|
break;
|
||||||
|
case "eino_agent_reply":
|
||||||
|
if (message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n" + message + "\n");
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "progress":
|
case "progress":
|
||||||
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||||
tab.setRunStatus(runId, "running");
|
tab.setRunStatus(runId, "running");
|
||||||
@@ -94,21 +113,40 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
|||||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||||
tab.setRunStatus(runId, "error");
|
tab.setRunStatus(runId, "error");
|
||||||
break;
|
break;
|
||||||
|
case "reasoning_chain_stream_start":
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[推理过程]\n");
|
||||||
|
break;
|
||||||
|
case "reasoning_chain_stream_delta":
|
||||||
|
if (message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case "reasoning_chain_stream_end":
|
||||||
|
tab.appendProgressToRun(runId, "\n");
|
||||||
|
break;
|
||||||
|
case "reasoning_chain":
|
||||||
|
if (message != null && !message.isEmpty()) {
|
||||||
|
String streamId = rawJson != null ? SimpleJson.extractStringField(rawJson, "streamId") : "";
|
||||||
|
if (streamId == null || streamId.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, "\n\n[推理过程]\n" + message + "\n");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "thinking_stream_start":
|
case "thinking_stream_start":
|
||||||
if (tab.isShowDebugEvents()) {
|
if (tab.isShowDebugEvents()) {
|
||||||
tab.resetThinkingStream(runId);
|
tab.resetThinkingStream(runId);
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "thinking_stream_delta":
|
case "thinking_stream_delta":
|
||||||
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
|
tab.appendProgressToRun(runId, message);
|
||||||
|
}
|
||||||
|
break;
|
||||||
case "tool_call":
|
case "tool_call":
|
||||||
case "tool_result":
|
case "tool_result":
|
||||||
case "tool_result_delta":
|
case "tool_result_delta":
|
||||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||||
if ("thinking_stream_delta".equals(type)) {
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
tab.appendThinkingDelta(runId, message);
|
|
||||||
} else {
|
|
||||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case "conversation":
|
case "conversation":
|
||||||
@@ -125,7 +163,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
|||||||
case "done":
|
case "done":
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()
|
||||||
|
&& !type.endsWith("_stream_delta") && !type.endsWith("_stream_start")
|
||||||
|
&& !type.endsWith("_stream_end")) {
|
||||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
@@ -134,8 +174,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
|||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onError(String message, Exception e) {
|
public void onError(String message, Exception e) {
|
||||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
boolean cancelled = message != null && message.toLowerCase().contains("cancel");
|
||||||
tab.setRunStatus(runId, "error");
|
tab.appendProgressToRun(runId, cancelled ? "\n[info] " + message + "\n" : "\n[error] " + message + "\n");
|
||||||
|
tab.setRunStatus(runId, cancelled ? "cancelled" : "error");
|
||||||
callbacks.printError("CyberStrikeAI stream error: " + message);
|
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||||
if (e != null) {
|
if (e != null) {
|
||||||
callbacks.printError(e.toString());
|
callbacks.printError(e.toString());
|
||||||
|
|||||||
+127
-11
@@ -2,17 +2,29 @@ package burp;
|
|||||||
|
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
import java.io.IOException;
|
import java.io.IOException;
|
||||||
|
import java.io.InterruptedIOException;
|
||||||
import java.io.InputStream;
|
import java.io.InputStream;
|
||||||
import java.io.InputStreamReader;
|
import java.io.InputStreamReader;
|
||||||
import java.io.OutputStream;
|
import java.io.OutputStream;
|
||||||
import java.net.HttpURLConnection;
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.SocketTimeoutException;
|
||||||
import java.net.URL;
|
import java.net.URL;
|
||||||
import java.nio.charset.StandardCharsets;
|
import java.nio.charset.StandardCharsets;
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
import java.util.concurrent.atomic.AtomicReference;
|
||||||
|
|
||||||
final class CyberStrikeAIClient {
|
final class CyberStrikeAIClient {
|
||||||
|
|
||||||
|
private static final int AUTH_CONNECT_TIMEOUT_MS = 4_000;
|
||||||
|
private static final int AUTH_READ_TIMEOUT_MS = 5_000;
|
||||||
|
/** login + validate 整段上限,避免两次读超时叠加拖到半分钟 */
|
||||||
|
private static final int AUTH_OVERALL_TIMEOUT_MS = 10_000;
|
||||||
|
private static final int DEFAULT_READ_TIMEOUT_MS = 15_000;
|
||||||
|
|
||||||
|
private final AtomicReference<HttpURLConnection> activeConnection = new AtomicReference<>();
|
||||||
|
private final AtomicReference<Thread> activeThread = new AtomicReference<>();
|
||||||
|
|
||||||
static final class Config {
|
static final class Config {
|
||||||
final String baseUrl; // e.g. http://127.0.0.1:8080
|
final String baseUrl; // e.g. http://127.0.0.1:8080
|
||||||
final String password;
|
final String password;
|
||||||
@@ -49,15 +61,97 @@ final class CyberStrikeAIClient {
|
|||||||
void onDone();
|
void onDone();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
boolean hasActiveRequest() {
|
||||||
|
return activeConnection.get() != null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelActiveRequest() {
|
||||||
|
HttpURLConnection conn = activeConnection.getAndSet(null);
|
||||||
|
if (conn != null) {
|
||||||
|
try {
|
||||||
|
conn.disconnect();
|
||||||
|
} catch (Exception ignored) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Thread t = activeThread.getAndSet(null);
|
||||||
|
if (t != null) {
|
||||||
|
t.interrupt();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
String loginAndValidate(Config cfg) throws IOException {
|
String loginAndValidate(Config cfg) throws IOException {
|
||||||
String token = login(cfg.baseUrl, cfg.password);
|
Thread worker = Thread.currentThread();
|
||||||
validate(cfg.baseUrl, token);
|
java.util.Timer deadline = new java.util.Timer("CyberStrikeAI-AuthDeadline", true);
|
||||||
return token;
|
deadline.schedule(new java.util.TimerTask() {
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
worker.interrupt();
|
||||||
|
cancelActiveRequest();
|
||||||
|
}
|
||||||
|
}, AUTH_OVERALL_TIMEOUT_MS);
|
||||||
|
try {
|
||||||
|
String token = login(cfg.baseUrl, cfg.password);
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw timeoutIOException();
|
||||||
|
}
|
||||||
|
validate(cfg.baseUrl, token);
|
||||||
|
if (Thread.interrupted()) {
|
||||||
|
throw timeoutIOException();
|
||||||
|
}
|
||||||
|
return token;
|
||||||
|
} catch (SocketTimeoutException e) {
|
||||||
|
throw timeoutIOException();
|
||||||
|
} finally {
|
||||||
|
deadline.cancel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IOException timeoutIOException() {
|
||||||
|
return new IOException("Connection timed out (~" + (AUTH_OVERALL_TIMEOUT_MS / 1000)
|
||||||
|
+ "s). Check host/port and HTTPS checkbox.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void trackConnection(HttpURLConnection conn) {
|
||||||
|
activeThread.set(Thread.currentThread());
|
||||||
|
activeConnection.set(conn);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void releaseConnection(HttpURLConnection conn) {
|
||||||
|
if (activeConnection.compareAndSet(conn, null)) {
|
||||||
|
activeThread.set(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean isCancelled(Throwable e) {
|
||||||
|
if (e == null) {
|
||||||
|
return Thread.currentThread().isInterrupted();
|
||||||
|
}
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e instanceof InterruptedIOException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if (e instanceof SocketTimeoutException) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
Throwable cause = e.getCause();
|
||||||
|
if (cause != null && cause != e) {
|
||||||
|
return isCancelled(cause);
|
||||||
|
}
|
||||||
|
String msg = e.getMessage();
|
||||||
|
return msg != null && (
|
||||||
|
msg.toLowerCase().contains("cancel")
|
||||||
|
|| msg.toLowerCase().contains("abort")
|
||||||
|
|| msg.toLowerCase().contains("closed")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
private String login(String baseUrl, String password) throws IOException {
|
private String login(String baseUrl, String password) throws IOException {
|
||||||
URL url = new URL(baseUrl + "/api/auth/login");
|
URL url = new URL(baseUrl + "/api/auth/login");
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||||
|
trackConnection(conn);
|
||||||
|
try {
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setDoOutput(true);
|
conn.setDoOutput(true);
|
||||||
conn.setRequestProperty("Content-Type", "application/json");
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
@@ -92,11 +186,16 @@ final class CyberStrikeAIClient {
|
|||||||
throw new IOException("Login response missing token. Check backend address and credentials.");
|
throw new IOException("Login response missing token. Check backend address and credentials.");
|
||||||
}
|
}
|
||||||
return token;
|
return token;
|
||||||
|
} finally {
|
||||||
|
releaseConnection(conn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void validate(String baseUrl, String token) throws IOException {
|
private void validate(String baseUrl, String token) throws IOException {
|
||||||
URL url = new URL(baseUrl + "/api/auth/validate");
|
URL url = new URL(baseUrl + "/api/auth/validate");
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||||
|
trackConnection(conn);
|
||||||
|
try {
|
||||||
conn.setRequestMethod("GET");
|
conn.setRequestMethod("GET");
|
||||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||||
int code = conn.getResponseCode();
|
int code = conn.getResponseCode();
|
||||||
@@ -104,6 +203,9 @@ final class CyberStrikeAIClient {
|
|||||||
if (code < 200 || code >= 300) {
|
if (code < 200 || code >= 300) {
|
||||||
throw new IOException("Validate failed (" + code + "): " + resp);
|
throw new IOException("Validate failed (" + code + "): " + resp);
|
||||||
}
|
}
|
||||||
|
} finally {
|
||||||
|
releaseConnection(conn);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void streamTest(Config cfg, String token, String message, StreamListener listener) {
|
void streamTest(Config cfg, String token, String message, StreamListener listener) {
|
||||||
@@ -117,11 +219,12 @@ final class CyberStrikeAIClient {
|
|||||||
payload.put("orchestration", cfg.agentMode.orchestration);
|
payload.put("orchestration", cfg.agentMode.orchestration);
|
||||||
}
|
}
|
||||||
|
|
||||||
new Thread(() -> {
|
Thread worker = new Thread(() -> {
|
||||||
HttpURLConnection conn = null;
|
HttpURLConnection conn = null;
|
||||||
try {
|
try {
|
||||||
URL url = new URL(urlStr);
|
URL url = new URL(urlStr);
|
||||||
conn = (HttpURLConnection) url.openConnection();
|
conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, 0);
|
||||||
|
trackConnection(conn);
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setDoOutput(true);
|
conn.setDoOutput(true);
|
||||||
conn.setRequestProperty("Content-Type", "application/json");
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
@@ -142,6 +245,9 @@ final class CyberStrikeAIClient {
|
|||||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||||
String line;
|
String line;
|
||||||
while ((line = br.readLine()) != null) {
|
while ((line = br.readLine()) != null) {
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
// SSE format: "data: {json}"
|
// SSE format: "data: {json}"
|
||||||
if (line.startsWith("data:")) {
|
if (line.startsWith("data:")) {
|
||||||
String json = line.substring("data:".length()).trim();
|
String json = line.substring("data:".length()).trim();
|
||||||
@@ -156,15 +262,25 @@ final class CyberStrikeAIClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
listener.onDone();
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
listener.onError("Cancelled.", null);
|
||||||
|
} else {
|
||||||
|
listener.onDone();
|
||||||
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
listener.onError(e.getMessage(), e);
|
if (isCancelled(e)) {
|
||||||
|
listener.onError("Cancelled.", e);
|
||||||
|
} else {
|
||||||
|
listener.onError(e.getMessage(), e);
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (conn != null) {
|
if (conn != null) {
|
||||||
|
releaseConnection(conn);
|
||||||
conn.disconnect();
|
conn.disconnect();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, "CyberStrikeAI-Stream").start();
|
}, "CyberStrikeAI-Stream");
|
||||||
|
worker.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
|
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
|
||||||
@@ -172,7 +288,7 @@ final class CyberStrikeAIClient {
|
|||||||
throw new IOException("Missing conversationId.");
|
throw new IOException("Missing conversationId.");
|
||||||
}
|
}
|
||||||
URL url = new URL(baseUrl + "/api/agent-loop/cancel");
|
URL url = new URL(baseUrl + "/api/agent-loop/cancel");
|
||||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||||
conn.setRequestMethod("POST");
|
conn.setRequestMethod("POST");
|
||||||
conn.setDoOutput(true);
|
conn.setDoOutput(true);
|
||||||
conn.setRequestProperty("Content-Type", "application/json");
|
conn.setRequestProperty("Content-Type", "application/json");
|
||||||
|
|||||||
+130
-34
@@ -14,6 +14,7 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
|
|
||||||
private final JTextField hostField = new JTextField("127.0.0.1");
|
private final JTextField hostField = new JTextField("127.0.0.1");
|
||||||
private final JTextField portField = new JTextField("8080");
|
private final JTextField portField = new JTextField("8080");
|
||||||
|
private final JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
|
||||||
private final JPasswordField passwordField = new JPasswordField();
|
private final JPasswordField passwordField = new JPasswordField();
|
||||||
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
|
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
|
||||||
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
|
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
|
||||||
@@ -29,6 +30,10 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
|
|
||||||
private final JTextArea progressArea = new JTextArea();
|
private final JTextArea progressArea = new JTextArea();
|
||||||
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
|
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
|
||||||
|
private JScrollPane progressScrollPane;
|
||||||
|
private JScrollPane finalRawScrollPane;
|
||||||
|
/** 距底部在此像素内视为「跟随滚动」,否则用户上拉阅读时不抢滚动条 */
|
||||||
|
private static final int SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||||
private final JEditorPane markdownPane = new JEditorPane("text/html", "");
|
private final JEditorPane markdownPane = new JEditorPane("text/html", "");
|
||||||
private final CardLayout outputCardsLayout = new CardLayout();
|
private final CardLayout outputCardsLayout = new CardLayout();
|
||||||
private final JPanel outputCards = new JPanel(outputCardsLayout);
|
private final JPanel outputCards = new JPanel(outputCardsLayout);
|
||||||
@@ -41,6 +46,7 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
|
|
||||||
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||||
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
||||||
|
private final AtomicReference<Thread> validateThreadRef = new AtomicReference<>();
|
||||||
|
|
||||||
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
|
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
|
||||||
private final JList<TestRun> testList = new JList<>(testListModel);
|
private final JList<TestRun> testList = new JList<>(testListModel);
|
||||||
@@ -107,6 +113,8 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
row1.add(hostField);
|
row1.add(hostField);
|
||||||
row1.add(new JLabel("Port"));
|
row1.add(new JLabel("Port"));
|
||||||
row1.add(portField);
|
row1.add(portField);
|
||||||
|
useHttpsBox.setToolTipText("Use https:// for CyberStrikeAI (self-signed certs are trusted automatically)");
|
||||||
|
row1.add(useHttpsBox);
|
||||||
row1.add(new JLabel("Password"));
|
row1.add(new JLabel("Password"));
|
||||||
row1.add(passwordField);
|
row1.add(passwordField);
|
||||||
row1.add(validateButton);
|
row1.add(validateButton);
|
||||||
@@ -186,15 +194,22 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
configureTextArea(requestArea, false);
|
configureTextArea(requestArea, false);
|
||||||
configureTextArea(responseArea, false);
|
configureTextArea(responseArea, false);
|
||||||
|
|
||||||
outputCards.add(new JScrollPane(finalRawArea), "raw");
|
finalRawScrollPane = new JScrollPane(finalRawArea);
|
||||||
|
finalRawScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||||
|
finalRawScrollPane.getVerticalScrollBar().setUnitIncrement(16);
|
||||||
|
outputCards.add(finalRawScrollPane, "raw");
|
||||||
outputCards.add(new JScrollPane(markdownPane), "md");
|
outputCards.add(new JScrollPane(markdownPane), "md");
|
||||||
|
|
||||||
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
|
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
|
||||||
outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
|
outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
|
||||||
|
|
||||||
rightTabs.addTab("Output", outputRoot);
|
rightTabs.addTab("Output", outputRoot);
|
||||||
rightTabs.addTab("Request", new JScrollPane(requestArea));
|
JScrollPane requestScroll = new JScrollPane(requestArea);
|
||||||
rightTabs.addTab("Response", new JScrollPane(responseArea));
|
requestScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||||
|
rightTabs.addTab("Request", requestScroll);
|
||||||
|
JScrollPane responseScroll = new JScrollPane(responseArea);
|
||||||
|
responseScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||||
|
rightTabs.addTab("Response", responseScroll);
|
||||||
return rightTabs;
|
return rightTabs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -210,12 +225,13 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private JComponent buildOutputBody() {
|
private JComponent buildOutputBody() {
|
||||||
JScrollPane progressScroll = new JScrollPane(progressArea);
|
progressScrollPane = new JScrollPane(progressArea);
|
||||||
progressScroll.setBorder(BorderFactory.createTitledBorder("Progress"));
|
progressScrollPane.setBorder(BorderFactory.createTitledBorder("Progress"));
|
||||||
progressScroll.getVerticalScrollBar().setUnitIncrement(16);
|
progressScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||||
|
progressScrollPane.getVerticalScrollBar().setUnitIncrement(16);
|
||||||
|
|
||||||
JPanel empty = new JPanel();
|
JPanel empty = new JPanel();
|
||||||
progressContainer.add(progressScroll, "show");
|
progressContainer.add(progressScrollPane, "show");
|
||||||
progressContainer.add(empty, "hide");
|
progressContainer.add(empty, "hide");
|
||||||
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
|
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
|
||||||
|
|
||||||
@@ -259,10 +275,27 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
return split;
|
return split;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static boolean isScrollNearBottom(JScrollPane scrollPane) {
|
||||||
|
if (scrollPane == null) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
JScrollBar bar = scrollPane.getVerticalScrollBar();
|
||||||
|
int max = Math.max(0, bar.getMaximum() - bar.getVisibleAmount());
|
||||||
|
return bar.getValue() >= max - SCROLL_FOLLOW_THRESHOLD_PX;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void scrollPaneToBottom(JScrollPane scrollPane) {
|
||||||
|
if (scrollPane == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
JScrollBar bar = scrollPane.getVerticalScrollBar();
|
||||||
|
bar.setValue(bar.getMaximum());
|
||||||
|
}
|
||||||
|
|
||||||
private static void configureTextArea(JTextArea area, boolean monospaced) {
|
private static void configureTextArea(JTextArea area, boolean monospaced) {
|
||||||
area.setEditable(false);
|
area.setEditable(false);
|
||||||
area.setLineWrap(false);
|
area.setLineWrap(true);
|
||||||
area.setWrapStyleWord(false);
|
area.setWrapStyleWord(true);
|
||||||
if (monospaced) {
|
if (monospaced) {
|
||||||
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||||
} else {
|
} else {
|
||||||
@@ -381,24 +414,44 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
|
|
||||||
private void wireActions() {
|
private void wireActions() {
|
||||||
validateButton.addActionListener(e -> {
|
validateButton.addActionListener(e -> {
|
||||||
validateButton.setEnabled(false);
|
if ("Cancel".equals(validateButton.getText())) {
|
||||||
|
cancelValidateInProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
validateButton.setText("Cancel");
|
||||||
|
validateButton.setEnabled(true);
|
||||||
|
stopButton.setEnabled(true);
|
||||||
statusLabel.setText("Validating...");
|
statusLabel.setText("Validating...");
|
||||||
log("Validating connection...");
|
log("Validating connection... (max ~10s; click Cancel or Stop to abort)");
|
||||||
new Thread(() -> {
|
Thread worker = new Thread(() -> {
|
||||||
try {
|
try {
|
||||||
CyberStrikeAIClient.Config cfg = currentConfig();
|
CyberStrikeAIClient.Config cfg = currentConfig();
|
||||||
String token = client.loginAndValidate(cfg);
|
String token = client.loginAndValidate(cfg);
|
||||||
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
tokenRef.set(token);
|
tokenRef.set(token);
|
||||||
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
|
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
|
||||||
log("Validation OK.");
|
log("Validation OK.");
|
||||||
} catch (Exception ex) {
|
} catch (Exception ex) {
|
||||||
tokenRef.set("");
|
tokenRef.set("");
|
||||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
if (Thread.currentThread().isInterrupted()) {
|
||||||
log("Validation failed: " + ex.getMessage());
|
SwingUtilities.invokeLater(() -> statusLabel.setText("Cancelled"));
|
||||||
|
log("Validation cancelled.");
|
||||||
|
} else {
|
||||||
|
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
||||||
|
log("Validation failed: " + ex.getMessage());
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
SwingUtilities.invokeLater(() -> validateButton.setEnabled(true));
|
validateThreadRef.set(null);
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
validateButton.setText("Validate");
|
||||||
|
validateButton.setEnabled(true);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}, "CyberStrikeAI-Validate").start();
|
}, "CyberStrikeAI-Validate");
|
||||||
|
validateThreadRef.set(worker);
|
||||||
|
worker.start();
|
||||||
});
|
});
|
||||||
|
|
||||||
clearButton.addActionListener(e -> {
|
clearButton.addActionListener(e -> {
|
||||||
@@ -435,10 +488,23 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
});
|
});
|
||||||
|
|
||||||
stopButton.addActionListener(e -> {
|
stopButton.addActionListener(e -> {
|
||||||
|
if ("Cancel".equals(validateButton.getText())) {
|
||||||
|
cancelValidateInProgress();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
String runId = selectedRunId;
|
String runId = selectedRunId;
|
||||||
|
if (runId != null && client.hasActiveRequest()) {
|
||||||
|
client.cancelActiveRequest();
|
||||||
|
appendProgressToRun(runId, "\n[info] Stream stopped.\n");
|
||||||
|
setRunStatus(runId, "cancelled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (runId == null) return;
|
if (runId == null) return;
|
||||||
TestRun run = runs.get(runId);
|
TestRun run = runs.get(runId);
|
||||||
if (run == null) return;
|
if (run == null) return;
|
||||||
|
|
||||||
String token = getToken();
|
String token = getToken();
|
||||||
if (token == null || token.trim().isEmpty()) {
|
if (token == null || token.trim().isEmpty()) {
|
||||||
appendProgressToRun(runId, "\n[error] Not validated.\n");
|
appendProgressToRun(runId, "\n[error] Not validated.\n");
|
||||||
@@ -483,7 +549,8 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
String host = hostField.getText().trim();
|
String host = hostField.getText().trim();
|
||||||
String port = portField.getText().trim();
|
String port = portField.getText().trim();
|
||||||
String password = new String(passwordField.getPassword());
|
String password = new String(passwordField.getPassword());
|
||||||
String baseUrl = "http://" + host + ":" + port;
|
String scheme = useHttpsBox.isSelected() ? "https" : "http";
|
||||||
|
String baseUrl = scheme + "://" + host + ":" + port;
|
||||||
int idx = agentModeBox.getSelectedIndex();
|
int idx = agentModeBox.getSelectedIndex();
|
||||||
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
||||||
? AGENT_MODES[idx]
|
? AGENT_MODES[idx]
|
||||||
@@ -567,10 +634,31 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
run.progressBuffer.append(s);
|
run.progressBuffer.append(s);
|
||||||
}
|
}
|
||||||
if (runId.equals(selectedRunId)) {
|
if (runId.equals(selectedRunId)) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> appendProgressUi(s, false));
|
||||||
progressArea.append(s);
|
}
|
||||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
}
|
||||||
});
|
|
||||||
|
private void appendProgressUi(String s, boolean forceFollow) {
|
||||||
|
JScrollBar bar = progressScrollPane != null ? progressScrollPane.getVerticalScrollBar() : null;
|
||||||
|
int scrollBefore = bar != null ? bar.getValue() : 0;
|
||||||
|
boolean follow = forceFollow || isScrollNearBottom(progressScrollPane);
|
||||||
|
progressArea.append(s);
|
||||||
|
if (follow) {
|
||||||
|
scrollPaneToBottom(progressScrollPane);
|
||||||
|
} else if (bar != null) {
|
||||||
|
bar.setValue(scrollBefore);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void appendFinalUi(String s, boolean forceFollow) {
|
||||||
|
JScrollBar bar = finalRawScrollPane != null ? finalRawScrollPane.getVerticalScrollBar() : null;
|
||||||
|
int scrollBefore = bar != null ? bar.getValue() : 0;
|
||||||
|
boolean follow = forceFollow || isScrollNearBottom(finalRawScrollPane);
|
||||||
|
finalRawArea.append(s);
|
||||||
|
if (follow) {
|
||||||
|
scrollPaneToBottom(finalRawScrollPane);
|
||||||
|
} else if (bar != null) {
|
||||||
|
bar.setValue(scrollBefore);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -620,10 +708,7 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
run.finalBuffer.append(s);
|
run.finalBuffer.append(s);
|
||||||
}
|
}
|
||||||
if (runId.equals(selectedRunId)) {
|
if (runId.equals(selectedRunId)) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> appendFinalUi(s, false));
|
||||||
finalRawArea.append(s);
|
|
||||||
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -656,9 +741,9 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
}
|
}
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
progressArea.setText(progress);
|
progressArea.setText(progress);
|
||||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
scrollPaneToBottom(progressScrollPane);
|
||||||
finalRawArea.setText(fin);
|
finalRawArea.setText(fin);
|
||||||
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
scrollPaneToBottom(finalRawScrollPane);
|
||||||
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
|
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
|
||||||
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
|
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
|
||||||
refreshOutputView();
|
refreshOutputView();
|
||||||
@@ -682,25 +767,36 @@ final class CyberStrikeAITab implements ITab {
|
|||||||
|
|
||||||
void clearAndShowStreamHeader(String title) {
|
void clearAndShowStreamHeader(String title) {
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> {
|
||||||
progressArea.setText("");
|
progressArea.setText("[*] " + title + "\n\n");
|
||||||
finalRawArea.setText(title + "\n\n");
|
finalRawArea.setText("");
|
||||||
|
markdownPane.setText("");
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Legacy helpers kept for Validate logging
|
// Legacy helpers kept for Validate logging
|
||||||
void appendStreamLine(String s) {
|
void appendStreamLine(String s) {
|
||||||
if (s == null) return;
|
if (s == null) return;
|
||||||
SwingUtilities.invokeLater(() -> {
|
SwingUtilities.invokeLater(() -> appendProgressUi(s + "\n", false));
|
||||||
progressArea.append(s);
|
|
||||||
progressArea.append("\n");
|
|
||||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private void log(String s) {
|
private void log(String s) {
|
||||||
appendStreamLine("[*] " + s);
|
appendStreamLine("[*] " + s);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void cancelValidateInProgress() {
|
||||||
|
client.cancelActiveRequest();
|
||||||
|
Thread t = validateThreadRef.getAndSet(null);
|
||||||
|
if (t != null) {
|
||||||
|
t.interrupt();
|
||||||
|
}
|
||||||
|
SwingUtilities.invokeLater(() -> {
|
||||||
|
statusLabel.setText("Cancelled");
|
||||||
|
validateButton.setText("Validate");
|
||||||
|
validateButton.setEnabled(true);
|
||||||
|
});
|
||||||
|
log("Validation cancelled.");
|
||||||
|
}
|
||||||
|
|
||||||
private void applyFilter() {
|
private void applyFilter() {
|
||||||
String q = searchField.getText();
|
String q = searchField.getText();
|
||||||
if (q == null) q = "";
|
if (q == null) q = "";
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
package burp;
|
||||||
|
|
||||||
|
import javax.net.ssl.HostnameVerifier;
|
||||||
|
import javax.net.ssl.HttpsURLConnection;
|
||||||
|
import javax.net.ssl.SSLSocketFactory;
|
||||||
|
import javax.net.ssl.SSLContext;
|
||||||
|
import javax.net.ssl.TrustManager;
|
||||||
|
import javax.net.ssl.X509TrustManager;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.net.HttpURLConnection;
|
||||||
|
import java.net.InetSocketAddress;
|
||||||
|
import java.net.Socket;
|
||||||
|
import java.net.URL;
|
||||||
|
import java.security.cert.X509Certificate;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opens HTTPS connections without validating server certificates (self-signed / local dev).
|
||||||
|
* Applied per-connection only; does not change JVM-wide defaults for other Burp components.
|
||||||
|
*/
|
||||||
|
final class SslTrustAll {
|
||||||
|
|
||||||
|
private static volatile SSLSocketFactory socketFactory;
|
||||||
|
private static final HostnameVerifier TRUST_ALL_HOSTS = (hostname, session) -> true;
|
||||||
|
|
||||||
|
private SslTrustAll() {
|
||||||
|
}
|
||||||
|
|
||||||
|
static HttpURLConnection open(URL url) throws IOException {
|
||||||
|
return open(url, 5_000, 30_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
static HttpURLConnection open(URL url, int connectTimeoutMs, int readTimeoutMs) throws IOException {
|
||||||
|
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||||
|
conn.setConnectTimeout(connectTimeoutMs);
|
||||||
|
conn.setReadTimeout(readTimeoutMs);
|
||||||
|
if (conn instanceof HttpsURLConnection) {
|
||||||
|
HttpsURLConnection https = (HttpsURLConnection) conn;
|
||||||
|
https.setSSLSocketFactory(new TimeoutSslSocketFactory(socketFactory(), connectTimeoutMs, readTimeoutMs));
|
||||||
|
https.setHostnameVerifier(TRUST_ALL_HOSTS);
|
||||||
|
}
|
||||||
|
return conn;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static SSLSocketFactory socketFactory() {
|
||||||
|
SSLSocketFactory sf = socketFactory;
|
||||||
|
if (sf != null) {
|
||||||
|
return sf;
|
||||||
|
}
|
||||||
|
synchronized (SslTrustAll.class) {
|
||||||
|
sf = socketFactory;
|
||||||
|
if (sf != null) {
|
||||||
|
return sf;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
TrustManager[] trustAll = new TrustManager[]{
|
||||||
|
new X509TrustManager() {
|
||||||
|
@Override
|
||||||
|
public X509Certificate[] getAcceptedIssuers() {
|
||||||
|
return new X509Certificate[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void checkServerTrusted(X509Certificate[] chain, String authType) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||||
|
ctx.init(null, trustAll, new java.security.SecureRandom());
|
||||||
|
sf = ctx.getSocketFactory();
|
||||||
|
socketFactory = sf;
|
||||||
|
return sf;
|
||||||
|
} catch (Exception e) {
|
||||||
|
throw new RuntimeException("Failed to initialize trust-all TLS", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensures TCP connect + socket read respect timeouts (plain HttpURLConnection SSL can hang longer). */
|
||||||
|
private static final class TimeoutSslSocketFactory extends SSLSocketFactory {
|
||||||
|
private final SSLSocketFactory delegate;
|
||||||
|
private final int connectTimeoutMs;
|
||||||
|
private final int readTimeoutMs;
|
||||||
|
|
||||||
|
TimeoutSslSocketFactory(SSLSocketFactory delegate, int connectTimeoutMs, int readTimeoutMs) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.connectTimeoutMs = connectTimeoutMs;
|
||||||
|
this.readTimeoutMs = readTimeoutMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getDefaultCipherSuites() {
|
||||||
|
return delegate.getDefaultCipherSuites();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String[] getSupportedCipherSuites() {
|
||||||
|
return delegate.getSupportedCipherSuites();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Socket createSocket() throws IOException {
|
||||||
|
return tune(delegate.createSocket());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||||
|
return tune(delegate.createSocket(s, host, port, autoClose));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Socket createSocket(String host, int port) throws IOException {
|
||||||
|
Socket plain = new Socket();
|
||||||
|
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||||
|
return tune(delegate.createSocket(plain, host, port, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Socket createSocket(String host, int port, java.net.InetAddress localHost, int localPort) throws IOException {
|
||||||
|
Socket plain = new Socket();
|
||||||
|
plain.bind(new InetSocketAddress(localHost, localPort));
|
||||||
|
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||||
|
return tune(delegate.createSocket(plain, host, port, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Socket createSocket(java.net.InetAddress host, int port) throws IOException {
|
||||||
|
Socket plain = new Socket();
|
||||||
|
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||||
|
return tune(delegate.createSocket(plain, host.getHostName(), port, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Socket createSocket(java.net.InetAddress address, int port, java.net.InetAddress localAddress, int localPort) throws IOException {
|
||||||
|
Socket plain = new Socket();
|
||||||
|
plain.bind(new InetSocketAddress(localAddress, localPort));
|
||||||
|
plain.connect(new InetSocketAddress(address, port), connectTimeoutMs);
|
||||||
|
return tune(delegate.createSocket(plain, address.getHostName(), port, true));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Socket tune(Socket socket) throws IOException {
|
||||||
|
socket.setSoTimeout(readTimeoutMs);
|
||||||
|
return socket;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+4
@@ -1,12 +1,16 @@
|
|||||||
|
burp/SslTrustAll.class
|
||||||
|
burp/SslTrustAll$TimeoutSslSocketFactory.class
|
||||||
burp/CyberStrikeAIClient$StreamListener.class
|
burp/CyberStrikeAIClient$StreamListener.class
|
||||||
burp/CyberStrikeAIClient$Config.class
|
burp/CyberStrikeAIClient$Config.class
|
||||||
burp/CyberStrikeAIClient$AgentMode.class
|
burp/CyberStrikeAIClient$AgentMode.class
|
||||||
burp/MarkdownRenderer.class
|
burp/MarkdownRenderer.class
|
||||||
burp/SimpleJson.class
|
burp/SimpleJson.class
|
||||||
burp/CyberStrikeAIClient.class
|
burp/CyberStrikeAIClient.class
|
||||||
|
burp/CyberStrikeAIClient$1.class
|
||||||
burp/CyberStrikeAITab$DotIcon.class
|
burp/CyberStrikeAITab$DotIcon.class
|
||||||
burp/CyberStrikeAITab.class
|
burp/CyberStrikeAITab.class
|
||||||
burp/CyberStrikeAITab$1.class
|
burp/CyberStrikeAITab$1.class
|
||||||
|
burp/SslTrustAll$1.class
|
||||||
burp/BurpExtender$1.class
|
burp/BurpExtender$1.class
|
||||||
burp/BurpExtender.class
|
burp/BurpExtender.class
|
||||||
burp/CyberStrikeAITab$TestRun.class
|
burp/CyberStrikeAITab$TestRun.class
|
||||||
|
|||||||
+1
@@ -4,3 +4,4 @@
|
|||||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/HttpMessageFormatter.java
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/HttpMessageFormatter.java
|
||||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/MarkdownRenderer.java
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/MarkdownRenderer.java
|
||||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
|
||||||
|
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SslTrustAll.java
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ show_progress() {
|
|||||||
echo ""
|
echo ""
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo " CyberStrikeAI 一键部署启动脚本"
|
echo " CyberStrikeAI 一键部署启动脚本"
|
||||||
|
echo " (默认 HTTPS 自签证书;纯 HTTP 请用: $0 --http)"
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
@@ -353,7 +354,18 @@ need_rebuild() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# 主流程
|
# 主流程
|
||||||
|
# 默认启动主站 HTTPS(--https 传给二进制);传 --http 则走明文 HTTP。
|
||||||
main() {
|
main() {
|
||||||
|
USE_HTTPS=1
|
||||||
|
FORWARD_ARGS=()
|
||||||
|
for arg in "$@"; do
|
||||||
|
if [ "$arg" = "--http" ]; then
|
||||||
|
USE_HTTPS=0
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
FORWARD_ARGS+=("$arg")
|
||||||
|
done
|
||||||
|
|
||||||
# 环境检查
|
# 环境检查
|
||||||
info "检查运行环境..."
|
info "检查运行环境..."
|
||||||
check_python
|
check_python
|
||||||
@@ -377,13 +389,30 @@ main() {
|
|||||||
# 启动服务器
|
# 启动服务器
|
||||||
success "所有准备工作完成!"
|
success "所有准备工作完成!"
|
||||||
echo ""
|
echo ""
|
||||||
info "启动 CyberStrikeAI 服务器..."
|
if [ "$USE_HTTPS" -eq 1 ]; then
|
||||||
|
info "启动 CyberStrikeAI 服务器(HTTPS + HTTP/2,自签证书)..."
|
||||||
|
note "纯 HTTP 启动请使用: $0 --http"
|
||||||
|
else
|
||||||
|
info "启动 CyberStrikeAI 服务器(HTTP)..."
|
||||||
|
fi
|
||||||
echo "=========================================="
|
echo "=========================================="
|
||||||
echo ""
|
echo ""
|
||||||
|
|
||||||
# 运行服务器
|
# 始终传入项目根目录下的 config.yaml,避免 cwd 不在项目根时找不到配置;额外参数仍可追加(如再次 -config 覆盖,以 Go flag 后写为准)。
|
||||||
exec "./$BINARY_NAME"
|
if [ "$USE_HTTPS" -eq 1 ]; then
|
||||||
|
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||||
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https "${FORWARD_ARGS[@]}"
|
||||||
|
else
|
||||||
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE" --https
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
if [ "${#FORWARD_ARGS[@]}" -gt 0 ]; then
|
||||||
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE" "${FORWARD_ARGS[@]}"
|
||||||
|
else
|
||||||
|
exec "./$BINARY_NAME" -config "$CONFIG_FILE"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
# 执行主流程
|
# 执行主流程(支持参数,如: ./run.sh --http)
|
||||||
main
|
main "$@"
|
||||||
|
|||||||
@@ -440,6 +440,230 @@ args:
|
|||||||
print("Body: <empty>")
|
print("Body: <empty>")
|
||||||
|
|
||||||
|
|
||||||
|
def compile_response_filter(pattern: str, ignore_case: bool):
|
||||||
|
flags = 0
|
||||||
|
if ignore_case:
|
||||||
|
flags |= re.IGNORECASE
|
||||||
|
try:
|
||||||
|
return re.compile(pattern, flags)
|
||||||
|
except re.error as exc:
|
||||||
|
print(f"Invalid response_filter regex: {exc}", file=sys.stderr)
|
||||||
|
sys.exit(2)
|
||||||
|
|
||||||
|
|
||||||
|
def truncate_utf8(text: str, max_bytes: int) -> Tuple[str, bool]:
|
||||||
|
if max_bytes <= 0 or not text:
|
||||||
|
return text, False
|
||||||
|
encoded = text.encode("utf-8", errors="replace")
|
||||||
|
if len(encoded) <= max_bytes:
|
||||||
|
return text, False
|
||||||
|
truncated = encoded[:max_bytes].decode("utf-8", errors="ignore")
|
||||||
|
return truncated, True
|
||||||
|
|
||||||
|
|
||||||
|
def cap_line_entries(entries: List[Tuple[int, str]], max_lines: int) -> Tuple[List[Tuple[int, str]], bool]:
|
||||||
|
if max_lines <= 0 or len(entries) <= max_lines:
|
||||||
|
return entries, False
|
||||||
|
return entries[:max_lines], True
|
||||||
|
|
||||||
|
|
||||||
|
def expand_line_context(line_numbers: List[int], total_lines: int, context: int) -> List[int]:
|
||||||
|
if context <= 0:
|
||||||
|
return sorted(set(line_numbers))
|
||||||
|
included = set()
|
||||||
|
for num in line_numbers:
|
||||||
|
start = max(1, num - context)
|
||||||
|
end = min(total_lines, num + context)
|
||||||
|
for i in range(start, end + 1):
|
||||||
|
included.add(i)
|
||||||
|
return sorted(included)
|
||||||
|
|
||||||
|
|
||||||
|
def format_line_entries(lines: List[str], indices: List[int], ellipsis_gaps: bool = True) -> str:
|
||||||
|
if not indices:
|
||||||
|
return ""
|
||||||
|
chunks = []
|
||||||
|
prev = None
|
||||||
|
for num in indices:
|
||||||
|
if ellipsis_gaps and prev is not None and num > prev + 1:
|
||||||
|
chunks.append(" ...")
|
||||||
|
chunks.append(f" L{num}: {lines[num - 1]}")
|
||||||
|
prev = num
|
||||||
|
return "\n".join(chunks)
|
||||||
|
|
||||||
|
|
||||||
|
def filter_body_by_lines(
|
||||||
|
lines: List[str],
|
||||||
|
compiled: "re.Pattern",
|
||||||
|
invert: bool,
|
||||||
|
context_lines: int,
|
||||||
|
max_lines: int,
|
||||||
|
) -> Tuple[str, Dict[str, object]]:
|
||||||
|
matched_nums = []
|
||||||
|
for idx, line in enumerate(lines, start=1):
|
||||||
|
hit = compiled.search(line) is not None
|
||||||
|
if invert:
|
||||||
|
hit = not hit
|
||||||
|
if hit:
|
||||||
|
matched_nums.append(idx)
|
||||||
|
total = len(lines)
|
||||||
|
meta = {
|
||||||
|
"mode": "line",
|
||||||
|
"total_lines": total,
|
||||||
|
"matched_lines": len(matched_nums),
|
||||||
|
"invert": invert,
|
||||||
|
"truncated": False,
|
||||||
|
"byte_truncated": False,
|
||||||
|
}
|
||||||
|
if not matched_nums:
|
||||||
|
return "", meta
|
||||||
|
display_nums = expand_line_context(matched_nums, total, context_lines)
|
||||||
|
entries = [(n, lines[n - 1]) for n in display_nums]
|
||||||
|
entries, line_capped = cap_line_entries(entries, max_lines)
|
||||||
|
meta["truncated"] = line_capped
|
||||||
|
meta["display_lines"] = len(entries)
|
||||||
|
return format_line_entries(lines, [n for n, _ in entries], ellipsis_gaps=context_lines > 0), meta
|
||||||
|
|
||||||
|
|
||||||
|
def filter_body_multiline(
|
||||||
|
text: str,
|
||||||
|
compiled: "re.Pattern",
|
||||||
|
invert: bool,
|
||||||
|
max_lines: int,
|
||||||
|
dotall: bool,
|
||||||
|
) -> Tuple[str, Dict[str, object]]:
|
||||||
|
flags = compiled.flags
|
||||||
|
if dotall:
|
||||||
|
pattern = re.compile(compiled.pattern, flags | re.DOTALL | re.MULTILINE)
|
||||||
|
else:
|
||||||
|
pattern = re.compile(compiled.pattern, flags | re.MULTILINE)
|
||||||
|
matches = list(pattern.finditer(text))
|
||||||
|
if invert:
|
||||||
|
if matches:
|
||||||
|
return "", {"mode": "multiline" if not dotall else "full", "total_lines": text.count("\n") + (1 if text else 0), "matched_lines": 0, "invert": True, "truncated": False, "byte_truncated": False}
|
||||||
|
output = text
|
||||||
|
meta = {"mode": "multiline" if not dotall else "full", "matched_lines": 1, "invert": True, "truncated": False, "byte_truncated": False}
|
||||||
|
lines = text.splitlines()
|
||||||
|
if max_lines > 0 and len(lines) > max_lines:
|
||||||
|
output = "\n".join(lines[:max_lines])
|
||||||
|
meta["truncated"] = True
|
||||||
|
meta["total_lines"] = len(lines)
|
||||||
|
meta["display_lines"] = min(len(lines), max_lines) if max_lines > 0 else len(lines)
|
||||||
|
return output, meta
|
||||||
|
chunks = []
|
||||||
|
for match in matches:
|
||||||
|
snippet = match.group(0)
|
||||||
|
if "\n" in snippet:
|
||||||
|
snippet = snippet.replace("\n", "\\n")
|
||||||
|
start_line = text.count("\n", 0, match.start()) + 1
|
||||||
|
chunks.append((start_line, f" @{start_line}: {snippet}"))
|
||||||
|
entries, line_capped = cap_line_entries(chunks, max_lines if max_lines > 0 else len(chunks))
|
||||||
|
meta = {
|
||||||
|
"mode": "multiline" if not dotall else "full",
|
||||||
|
"total_lines": text.count("\n") + (1 if text else 0),
|
||||||
|
"matched_lines": len(matches),
|
||||||
|
"invert": False,
|
||||||
|
"truncated": line_capped,
|
||||||
|
"byte_truncated": False,
|
||||||
|
"display_lines": len(entries),
|
||||||
|
}
|
||||||
|
return "\n".join(line for _, line in entries), meta
|
||||||
|
|
||||||
|
|
||||||
|
def apply_body_limits_plain(text: str, max_lines: int) -> Tuple[str, Dict[str, object]]:
|
||||||
|
lines = text.splitlines()
|
||||||
|
meta = {
|
||||||
|
"mode": "plain",
|
||||||
|
"total_lines": len(lines),
|
||||||
|
"matched_lines": len(lines),
|
||||||
|
"invert": False,
|
||||||
|
"truncated": False,
|
||||||
|
"byte_truncated": False,
|
||||||
|
"display_lines": len(lines),
|
||||||
|
}
|
||||||
|
output = text
|
||||||
|
if max_lines > 0 and len(lines) > max_lines:
|
||||||
|
output = "\n".join(lines[:max_lines])
|
||||||
|
meta["truncated"] = True
|
||||||
|
meta["display_lines"] = max_lines
|
||||||
|
return output, meta
|
||||||
|
|
||||||
|
|
||||||
|
def format_response_body_output(
|
||||||
|
decoded_body: str,
|
||||||
|
filter_pattern: str,
|
||||||
|
filter_mode: str,
|
||||||
|
filter_invert: bool,
|
||||||
|
filter_ignore_case: bool,
|
||||||
|
max_lines: int,
|
||||||
|
max_bytes: int,
|
||||||
|
preview_lines: int,
|
||||||
|
context_lines: int,
|
||||||
|
compiled_filter=None,
|
||||||
|
) -> Tuple[str, Dict[str, object]]:
|
||||||
|
text = decoded_body.rstrip("\r\n")
|
||||||
|
if not text:
|
||||||
|
return "", {"mode": "empty", "total_lines": 0, "matched_lines": 0, "invert": filter_invert, "truncated": False, "byte_truncated": False, "display_lines": 0}
|
||||||
|
|
||||||
|
lines = text.splitlines()
|
||||||
|
mode = (filter_mode or "line").strip().lower()
|
||||||
|
if mode not in {"line", "multiline", "full"}:
|
||||||
|
mode = "line"
|
||||||
|
|
||||||
|
if filter_pattern:
|
||||||
|
compiled = compiled_filter or compile_response_filter(filter_pattern, filter_ignore_case)
|
||||||
|
if mode == "line":
|
||||||
|
output, meta = filter_body_by_lines(lines, compiled, filter_invert, context_lines, max_lines)
|
||||||
|
else:
|
||||||
|
output, meta = filter_body_multiline(text, compiled, filter_invert, max_lines, dotall=(mode == "full"))
|
||||||
|
meta["filter_pattern"] = filter_pattern
|
||||||
|
if not output and not filter_invert:
|
||||||
|
preview = min(max(preview_lines, 0), len(lines))
|
||||||
|
if preview > 0:
|
||||||
|
preview_text = format_line_entries(lines, list(range(1, preview + 1)), ellipsis_gaps=False)
|
||||||
|
preview_text, byte_truncated = truncate_utf8(preview_text, max_bytes)
|
||||||
|
return preview_text, {
|
||||||
|
**meta,
|
||||||
|
"preview": True,
|
||||||
|
"matched_lines": 0,
|
||||||
|
"display_lines": preview,
|
||||||
|
"byte_truncated": byte_truncated,
|
||||||
|
}
|
||||||
|
return "", {**meta, "preview": False, "matched_lines": 0, "display_lines": 0}
|
||||||
|
else:
|
||||||
|
output, meta = apply_body_limits_plain(text, max_lines)
|
||||||
|
|
||||||
|
output, byte_truncated = truncate_utf8(output, max_bytes)
|
||||||
|
if byte_truncated:
|
||||||
|
meta["byte_truncated"] = True
|
||||||
|
return output, meta
|
||||||
|
|
||||||
|
|
||||||
|
def print_response_body_summary(meta: Dict[str, object]):
|
||||||
|
mode = meta.get("mode")
|
||||||
|
if mode == "empty":
|
||||||
|
return
|
||||||
|
parts = [f"mode={mode}"]
|
||||||
|
if meta.get("filter_pattern"):
|
||||||
|
parts.append(f"pattern={meta['filter_pattern']!r}")
|
||||||
|
if meta.get("invert"):
|
||||||
|
parts.append("invert=true")
|
||||||
|
total = meta.get("total_lines")
|
||||||
|
matched = meta.get("matched_lines")
|
||||||
|
displayed = meta.get("display_lines")
|
||||||
|
if total is not None and matched is not None:
|
||||||
|
parts.append(f"matched {matched}/{total} lines")
|
||||||
|
if displayed is not None:
|
||||||
|
parts.append(f"showing {displayed}")
|
||||||
|
if meta.get("preview"):
|
||||||
|
parts.append("preview on zero match")
|
||||||
|
if meta.get("truncated"):
|
||||||
|
parts.append("line cap applied")
|
||||||
|
if meta.get("byte_truncated"):
|
||||||
|
parts.append("byte cap applied")
|
||||||
|
print(f"[body] {' | '.join(parts)}")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(description="Pure Python HTTP testing helper powered by httpx")
|
parser = argparse.ArgumentParser(description="Pure Python HTTP testing helper powered by httpx")
|
||||||
parser.add_argument("--url", required=True)
|
parser.add_argument("--url", required=True)
|
||||||
@@ -466,6 +690,16 @@ args:
|
|||||||
parser.add_argument("--debug", dest="debug", action="store_true")
|
parser.add_argument("--debug", dest="debug", action="store_true")
|
||||||
parser.add_argument("--response-encoding", dest="response_encoding", default="")
|
parser.add_argument("--response-encoding", dest="response_encoding", default="")
|
||||||
parser.add_argument("--download", dest="download", default="")
|
parser.add_argument("--download", dest="download", default="")
|
||||||
|
parser.add_argument("--response-filter", dest="response_filter", default="")
|
||||||
|
parser.add_argument("--response-filter-mode", dest="response_filter_mode", default="line")
|
||||||
|
parser.add_argument("--response-filter-invert", dest="response_filter_invert", action="store_true")
|
||||||
|
parser.add_argument("--no-response-filter-invert", dest="response_filter_invert", action="store_false")
|
||||||
|
parser.add_argument("--response-filter-ignore-case", dest="response_filter_ignore_case", action="store_true")
|
||||||
|
parser.add_argument("--no-response-filter-ignore-case", dest="response_filter_ignore_case", action="store_false")
|
||||||
|
parser.add_argument("--response-max-lines", dest="response_max_lines", type=int, default=0)
|
||||||
|
parser.add_argument("--response-max-bytes", dest="response_max_bytes", type=int, default=0)
|
||||||
|
parser.add_argument("--response-preview-lines", dest="response_preview_lines", type=int, default=5)
|
||||||
|
parser.add_argument("--response-context-lines", dest="response_context_lines", type=int, default=0)
|
||||||
parser.set_defaults(
|
parser.set_defaults(
|
||||||
include_headers=False,
|
include_headers=False,
|
||||||
auto_encode_url=False,
|
auto_encode_url=False,
|
||||||
@@ -475,9 +709,22 @@ args:
|
|||||||
show_command=False,
|
show_command=False,
|
||||||
show_summary=False,
|
show_summary=False,
|
||||||
debug=False,
|
debug=False,
|
||||||
|
response_filter_invert=False,
|
||||||
|
response_filter_ignore_case=False,
|
||||||
)
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
response_filter = (args.response_filter or "").strip()
|
||||||
|
response_max_lines = max(0, args.response_max_lines or 0)
|
||||||
|
response_max_bytes = max(0, args.response_max_bytes or 0)
|
||||||
|
response_preview_lines = max(0, args.response_preview_lines if args.response_preview_lines is not None else 5)
|
||||||
|
response_context_lines = max(0, args.response_context_lines or 0)
|
||||||
|
compiled_response_filter = None
|
||||||
|
if response_filter:
|
||||||
|
compiled_response_filter = compile_response_filter(
|
||||||
|
response_filter, args.response_filter_ignore_case
|
||||||
|
)
|
||||||
|
|
||||||
repeat = max(1, args.repeat)
|
repeat = max(1, args.repeat)
|
||||||
try:
|
try:
|
||||||
delay_between = float(args.delay or "0")
|
delay_between = float(args.delay or "0")
|
||||||
@@ -648,9 +895,37 @@ args:
|
|||||||
for key, value in response.headers.items():
|
for key, value in response.headers.items():
|
||||||
print(f"{key}: {value}")
|
print(f"{key}: {value}")
|
||||||
print("")
|
print("")
|
||||||
output_body = decoded_body.rstrip()
|
output_body, body_output_meta = format_response_body_output(
|
||||||
|
decoded_body,
|
||||||
|
response_filter,
|
||||||
|
args.response_filter_mode,
|
||||||
|
args.response_filter_invert,
|
||||||
|
args.response_filter_ignore_case,
|
||||||
|
response_max_lines,
|
||||||
|
response_max_bytes,
|
||||||
|
response_preview_lines,
|
||||||
|
response_context_lines,
|
||||||
|
compiled_filter=compiled_response_filter,
|
||||||
|
)
|
||||||
|
has_filter_or_cap = bool(
|
||||||
|
response_filter or response_max_lines > 0 or response_max_bytes > 0
|
||||||
|
)
|
||||||
|
if has_filter_or_cap and body_output_meta.get("mode") != "empty":
|
||||||
|
print_response_body_summary(body_output_meta)
|
||||||
|
if body_output_meta.get("preview") and not body_output_meta.get("matched_lines"):
|
||||||
|
print("[body] no regex match; showing preview:")
|
||||||
if output_body:
|
if output_body:
|
||||||
print(output_body)
|
print(output_body)
|
||||||
|
if body_output_meta.get("truncated") or body_output_meta.get("byte_truncated"):
|
||||||
|
omitted = (body_output_meta.get("total_lines") or 0) - (
|
||||||
|
body_output_meta.get("display_lines") or 0
|
||||||
|
)
|
||||||
|
if omitted > 0:
|
||||||
|
print(f"[body] ... {omitted} more line(s) omitted (use --download for full body)")
|
||||||
|
elif body_output_meta.get("mode") == "empty":
|
||||||
|
print("[no body]")
|
||||||
|
elif response_filter and not body_output_meta.get("preview"):
|
||||||
|
print("[body] no lines matched filter")
|
||||||
else:
|
else:
|
||||||
print("[no body]")
|
print("[no body]")
|
||||||
|
|
||||||
@@ -729,6 +1004,13 @@ description: |
|
|||||||
- 连接探针:在无代理场景下额外进行 DNS/TCP/TLS 探测,粗粒度复刻 curl -w 指标
|
- 连接探针:在无代理场景下额外进行 DNS/TCP/TLS 探测,粗粒度复刻 curl -w 指标
|
||||||
- 可重复观测:repeat/delay + TTFB/total/speed_download 统计,便于盲注/时序测试
|
- 可重复观测:repeat/delay + TTFB/total/speed_download 统计,便于盲注/时序测试
|
||||||
- 扩展开关:additional_args 解析 http2/cert/verify/trust_env/max_redirects 等 httpx 选项
|
- 扩展开关:additional_args 解析 http2/cert/verify/trust_env/max_redirects 等 httpx 选项
|
||||||
|
- 响应体瘦身:response_filter 按行/块正则提取,配合 max_lines/max_bytes 限制 stdout,降低 Agent token 消耗
|
||||||
|
|
||||||
|
**响应过滤最佳实践:**
|
||||||
|
- 大页面/HTML:用 `response_filter` 抓 error|exception|password|token|uid 等关键字行
|
||||||
|
- 无 filter 时:设 `response_max_lines=80` 或 `response_max_bytes=8192` 防止整页灌入上下文
|
||||||
|
- 0 命中:自动预览前 `response_preview_lines` 行,避免误判「空响应」
|
||||||
|
- 完整留存:大 body 用 `download` 落盘,stdout 只保留摘要行
|
||||||
parameters:
|
parameters:
|
||||||
- name: "url"
|
- name: "url"
|
||||||
type: "string"
|
type: "string"
|
||||||
@@ -836,6 +1118,56 @@ parameters:
|
|||||||
description: "强制响应解码使用的编码(如GBK),覆盖自动探测"
|
description: "强制响应解码使用的编码(如GBK),覆盖自动探测"
|
||||||
required: false
|
required: false
|
||||||
flag: "--response-encoding"
|
flag: "--response-encoding"
|
||||||
|
- name: "response_filter"
|
||||||
|
type: "string"
|
||||||
|
description: |
|
||||||
|
响应体正则过滤(仅影响 stdout,不影响 --download 与指标)。
|
||||||
|
默认 line 模式按行匹配;示例:'(error|exception|SQL|password|token|uid)'。
|
||||||
|
与 response_max_lines/response_max_bytes 配合可显著减少 token 消耗。
|
||||||
|
required: false
|
||||||
|
flag: "--response-filter"
|
||||||
|
- name: "response_filter_mode"
|
||||||
|
type: "string"
|
||||||
|
description: "过滤模式:line(按行,默认)、multiline(跨行块)、full(整段 DOTALL 匹配)"
|
||||||
|
required: false
|
||||||
|
default: "line"
|
||||||
|
flag: "--response-filter-mode"
|
||||||
|
- name: "response_filter_invert"
|
||||||
|
type: "bool"
|
||||||
|
description: "反向过滤:输出不匹配 regex 的行(用于剔除 HTML 噪音)"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
flag: "--response-filter-invert"
|
||||||
|
- name: "response_filter_ignore_case"
|
||||||
|
type: "bool"
|
||||||
|
description: "正则忽略大小写"
|
||||||
|
required: false
|
||||||
|
default: false
|
||||||
|
flag: "--response-filter-ignore-case"
|
||||||
|
- name: "response_max_lines"
|
||||||
|
type: "int"
|
||||||
|
description: "stdout 最多输出行数(0=不限制);有 filter 时限制命中行数,无 filter 时截断全文"
|
||||||
|
required: false
|
||||||
|
default: 0
|
||||||
|
flag: "--response-max-lines"
|
||||||
|
- name: "response_max_bytes"
|
||||||
|
type: "int"
|
||||||
|
description: "stdout 响应体 UTF-8 字节上限(0=不限制),超出部分截断"
|
||||||
|
required: false
|
||||||
|
default: 0
|
||||||
|
flag: "--response-max-bytes"
|
||||||
|
- name: "response_preview_lines"
|
||||||
|
type: "int"
|
||||||
|
description: "filter 零命中时预览的前 N 行(默认 5,0=不预览)"
|
||||||
|
required: false
|
||||||
|
default: 5
|
||||||
|
flag: "--response-preview-lines"
|
||||||
|
- name: "response_context_lines"
|
||||||
|
type: "int"
|
||||||
|
description: "line 模式下命中行上下各保留 N 行上下文(类似 grep -C)"
|
||||||
|
required: false
|
||||||
|
default: 0
|
||||||
|
flag: "--response-context-lines"
|
||||||
- name: "action"
|
- name: "action"
|
||||||
type: "string"
|
type: "string"
|
||||||
description: "保留字段:标识调用意图(request, spider等),脚本内部不使用"
|
description: "保留字段:标识调用意图(request, spider等),脚本内部不使用"
|
||||||
|
|||||||
+34
-3
@@ -260,8 +260,14 @@
|
|||||||
gap: 12px;
|
gap: 12px;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
max-width: 420px;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.c2-actions > button {
|
||||||
|
flex: 1;
|
||||||
|
min-width: min(100%, 160px);
|
||||||
|
}
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Listener Cards
|
Listener Cards
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -851,10 +857,35 @@
|
|||||||
background: var(--c2-surface);
|
background: var(--c2-surface);
|
||||||
border-radius: var(--c2-radius);
|
border-radius: var(--c2-radius);
|
||||||
border: 1px solid var(--c2-border);
|
border: 1px solid var(--c2-border);
|
||||||
overflow: hidden;
|
overflow-x: auto;
|
||||||
|
overflow-y: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
.c2-task-table { width: 100%; border-collapse: collapse; }
|
/* 操作列:仅占按钮宽度,避免 100% 表格把余白摊到最右列 */
|
||||||
|
.c2-task-table th.c2-task-table-col-actions,
|
||||||
|
.c2-task-table td.c2-task-table-col-actions {
|
||||||
|
width: 1%;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-table-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-table-actions .btn-small,
|
||||||
|
.c2-task-table-actions .btn-sm {
|
||||||
|
min-height: 30px;
|
||||||
|
min-width: 52px;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.c2-task-table { width: 100%; border-collapse: collapse; table-layout: auto; }
|
||||||
|
|
||||||
.c2-task-table th {
|
.c2-task-table th {
|
||||||
text-align: left;
|
text-align: left;
|
||||||
@@ -1255,7 +1286,7 @@
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
z-index: 1000;
|
z-index: 10050;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
animation: c2-fade-in 0.15s ease-out;
|
animation: c2-fade-in 0.15s ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
+1514
-52
File diff suppressed because it is too large
Load Diff
@@ -77,6 +77,7 @@
|
|||||||
"settings": "System settings",
|
"settings": "System settings",
|
||||||
"hitl": "Human-in-the-loop",
|
"hitl": "Human-in-the-loop",
|
||||||
"c2": "C2",
|
"c2": "C2",
|
||||||
|
"c2Manage": "C2 management",
|
||||||
"c2Listeners": "Listeners",
|
"c2Listeners": "Listeners",
|
||||||
"c2Sessions": "Sessions",
|
"c2Sessions": "Sessions",
|
||||||
"c2Tasks": "Tasks",
|
"c2Tasks": "Tasks",
|
||||||
@@ -146,6 +147,7 @@
|
|||||||
"active": "Active",
|
"active": "Active",
|
||||||
"highFreq": "High frequency",
|
"highFreq": "High frequency",
|
||||||
"noCallData": "No call data",
|
"noCallData": "No call data",
|
||||||
|
"severityClickHint": "Click to view",
|
||||||
"lastUpdated": "Last updated",
|
"lastUpdated": "Last updated",
|
||||||
"viewAll": "View all →",
|
"viewAll": "View all →",
|
||||||
"recentVulns": "Recent vulnerabilities",
|
"recentVulns": "Recent vulnerabilities",
|
||||||
@@ -1304,6 +1306,35 @@
|
|||||||
"noCallsYet": "No calls yet",
|
"noCallsYet": "No calls yet",
|
||||||
"unknownTool": "Unknown tool",
|
"unknownTool": "Unknown tool",
|
||||||
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
"successFailedRate": "Success {{success}} / Failed {{failed}} · {{rate}}% success rate",
|
||||||
|
"topToolsTitle": "Top {{n}} tools by calls",
|
||||||
|
"barVolumeLegend": "Bar length: relative call volume; green/red: success vs failure share",
|
||||||
|
"clickToFilterTool": "Click a row to filter records below",
|
||||||
|
"toolRowAriaLabel": "{{name}}, {{total}} calls, {{rate}}% success rate. Click to filter records.",
|
||||||
|
"successRateAria": "Success rate {{rate}}%",
|
||||||
|
"filterByToolTitle": "Filtered by: {{tool}}",
|
||||||
|
"clearToolFilter": "Clear tool filter",
|
||||||
|
"successCount": "Success {{n}}",
|
||||||
|
"failedCount": "Failed {{n}}",
|
||||||
|
"rateHealthy": "Running smoothly",
|
||||||
|
"rateWarning": "Some failures detected",
|
||||||
|
"rateCritical": "High failure rate",
|
||||||
|
"statsSubtitle": "Refreshed {{time}} · {{count}} tools",
|
||||||
|
"distTitle": "Call distribution",
|
||||||
|
"distLegend": "Slice area shows share of all calls",
|
||||||
|
"distClickHint": "Click legend or slice to filter records",
|
||||||
|
"distHeaderHint": "{{n}} total calls",
|
||||||
|
"distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times",
|
||||||
|
"distOthersNoFilter": "Other tools cannot be filtered individually",
|
||||||
|
"distTotalCalls": "{{n}} total calls",
|
||||||
|
"distTop6Share": "Top {{n}} share of all calls",
|
||||||
|
"distOthers": "Other tools",
|
||||||
|
"distCallsUnit": "{{n}} calls",
|
||||||
|
"riskTitle": "Failure alerts",
|
||||||
|
"riskNone": "No recent failures",
|
||||||
|
"riskItem": "{{name}}: {{failed}} / {{total}} failed",
|
||||||
|
"selectedToolTitle": "Active filter",
|
||||||
|
"selectedToolEmpty": "Click a tool on the left to filter records below",
|
||||||
|
"selectedToolStats": "{{total}} calls · {{success}} ok · {{failed}} failed · {{rate}}% success",
|
||||||
"columnTool": "Tool",
|
"columnTool": "Tool",
|
||||||
"columnStatus": "Status",
|
"columnStatus": "Status",
|
||||||
"columnStartTime": "Start time",
|
"columnStartTime": "Start time",
|
||||||
@@ -1484,6 +1515,11 @@
|
|||||||
},
|
},
|
||||||
"vulnerabilityPage": {
|
"vulnerabilityPage": {
|
||||||
"statTotal": "Total",
|
"statTotal": "Total",
|
||||||
|
"statClickAll": "View all (clear severity filter)",
|
||||||
|
"statClickFilter": "Click to filter by this severity; click again to clear",
|
||||||
|
"advancedFilters": "Advanced filters",
|
||||||
|
"activeFilters": "Active filters",
|
||||||
|
"chipRemove": "Remove",
|
||||||
"filter": "Filter",
|
"filter": "Filter",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
"vulnId": "Vuln ID",
|
"vulnId": "Vuln ID",
|
||||||
|
|||||||
@@ -77,6 +77,7 @@
|
|||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"hitl": "人机协同",
|
"hitl": "人机协同",
|
||||||
"c2": "C2",
|
"c2": "C2",
|
||||||
|
"c2Manage": "C2 管理",
|
||||||
"c2Listeners": "监听器",
|
"c2Listeners": "监听器",
|
||||||
"c2Sessions": "会话",
|
"c2Sessions": "会话",
|
||||||
"c2Tasks": "任务",
|
"c2Tasks": "任务",
|
||||||
@@ -146,6 +147,7 @@
|
|||||||
"active": "活跃",
|
"active": "活跃",
|
||||||
"highFreq": "高频",
|
"highFreq": "高频",
|
||||||
"noCallData": "暂无调用数据",
|
"noCallData": "暂无调用数据",
|
||||||
|
"severityClickHint": "点击查看",
|
||||||
"lastUpdated": "上次更新",
|
"lastUpdated": "上次更新",
|
||||||
"viewAll": "查看全部 →",
|
"viewAll": "查看全部 →",
|
||||||
"recentVulns": "最近漏洞",
|
"recentVulns": "最近漏洞",
|
||||||
@@ -1293,6 +1295,35 @@
|
|||||||
"noCallsYet": "暂无调用",
|
"noCallsYet": "暂无调用",
|
||||||
"unknownTool": "未知工具",
|
"unknownTool": "未知工具",
|
||||||
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
"successFailedRate": "成功 {{success}} / 失败 {{failed}} · 成功率 {{rate}}%",
|
||||||
|
"topToolsTitle": "工具调用 Top {{n}}",
|
||||||
|
"barVolumeLegend": "条长表示相对调用量,条内绿/红为成功/失败占比",
|
||||||
|
"clickToFilterTool": "点击行筛选下方执行记录",
|
||||||
|
"toolRowAriaLabel": "{{name}},{{total}} 次调用,成功率 {{rate}}%,点击查看执行记录",
|
||||||
|
"successRateAria": "成功率 {{rate}}%",
|
||||||
|
"filterByToolTitle": "筛选工具:{{tool}}",
|
||||||
|
"clearToolFilter": "清除工具筛选",
|
||||||
|
"successCount": "成功 {{n}}",
|
||||||
|
"failedCount": "失败 {{n}}",
|
||||||
|
"rateHealthy": "运行平稳",
|
||||||
|
"rateWarning": "存在失败调用",
|
||||||
|
"rateCritical": "失败率偏高",
|
||||||
|
"statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具",
|
||||||
|
"distTitle": "调用分布",
|
||||||
|
"distLegend": "扇区面积为占全部调用比例",
|
||||||
|
"distClickHint": "点击图例或扇区筛选执行记录",
|
||||||
|
"distHeaderHint": "共 {{n}} 次调用",
|
||||||
|
"distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次",
|
||||||
|
"distOthersNoFilter": "其他工具无法单独筛选",
|
||||||
|
"distTotalCalls": "共 {{n}} 次调用",
|
||||||
|
"distTop6Share": "Top {{n}} 占全部调用",
|
||||||
|
"distOthers": "其他工具",
|
||||||
|
"distCallsUnit": "{{n}} 次",
|
||||||
|
"riskTitle": "失败提醒",
|
||||||
|
"riskNone": "近期无失败调用",
|
||||||
|
"riskItem": "{{name}}:失败 {{failed}} / {{total}} 次",
|
||||||
|
"selectedToolTitle": "当前筛选",
|
||||||
|
"selectedToolEmpty": "点击左侧工具行,可筛选下方执行记录",
|
||||||
|
"selectedToolStats": "调用 {{total}} 次 · 成功 {{success}} · 失败 {{failed}} · 成功率 {{rate}}%",
|
||||||
"columnTool": "工具",
|
"columnTool": "工具",
|
||||||
"columnStatus": "状态",
|
"columnStatus": "状态",
|
||||||
"columnStartTime": "开始时间",
|
"columnStartTime": "开始时间",
|
||||||
@@ -1473,6 +1504,11 @@
|
|||||||
},
|
},
|
||||||
"vulnerabilityPage": {
|
"vulnerabilityPage": {
|
||||||
"statTotal": "总漏洞数",
|
"statTotal": "总漏洞数",
|
||||||
|
"statClickAll": "查看全部(清除严重度筛选)",
|
||||||
|
"statClickFilter": "点击按此严重度筛选;再次点击清除",
|
||||||
|
"advancedFilters": "高级筛选",
|
||||||
|
"activeFilters": "已选条件",
|
||||||
|
"chipRemove": "移除",
|
||||||
"filter": "筛选",
|
"filter": "筛选",
|
||||||
"clear": "清除",
|
"clear": "清除",
|
||||||
"vulnId": "漏洞ID",
|
"vulnId": "漏洞ID",
|
||||||
|
|||||||
+48
-20
@@ -151,6 +151,25 @@
|
|||||||
return div.innerHTML;
|
return div.innerHTML;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 任务列表操作按钮(查看/取消/删除)— 事件委托 */
|
||||||
|
function bindC2TaskActionDelegation() {
|
||||||
|
if (document.documentElement.dataset.c2TaskActionsBound === '1') return;
|
||||||
|
document.documentElement.dataset.c2TaskActionsBound = '1';
|
||||||
|
document.addEventListener('click', function(e) {
|
||||||
|
const btn = e.target.closest('[data-c2-task-action]');
|
||||||
|
if (!btn) return;
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
const action = btn.getAttribute('data-c2-task-action');
|
||||||
|
const id = btn.getAttribute('data-task-id');
|
||||||
|
if (!id) return;
|
||||||
|
if (action === 'view') C2.viewTask(id);
|
||||||
|
else if (action === 'cancel') C2.cancelTask(id);
|
||||||
|
else if (action === 'delete') C2.deleteTaskById(id);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
bindC2TaskActionDelegation();
|
||||||
|
|
||||||
/** 监听器表单:Malleable Profile 下拉选项 HTML(value / 文本已转义) */
|
/** 监听器表单:Malleable Profile 下拉选项 HTML(value / 文本已转义) */
|
||||||
function listenerProfileSelectHtml(selectedProfileId) {
|
function listenerProfileSelectHtml(selectedProfileId) {
|
||||||
const sel = selectedProfileId ? String(selectedProfileId) : '';
|
const sel = selectedProfileId ? String(selectedProfileId) : '';
|
||||||
@@ -1293,14 +1312,17 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = tasks.map(t => `
|
container.innerHTML = tasks.map(t => {
|
||||||
|
const rawId = t.id || '';
|
||||||
|
return `
|
||||||
<div class="c2-task-item-compact">
|
<div class="c2-task-item-compact">
|
||||||
<span class="c2-task-status-dot ${t.status}"></span>
|
<span class="c2-task-status-dot ${escapeHtml(t.status || '')}"></span>
|
||||||
<span class="c2-task-type">${t.taskType}</span>
|
<span class="c2-task-type">${escapeHtml(t.taskType || '')}</span>
|
||||||
<span class="c2-task-meta">${escapeHtml(taskStatusLabel(t.status))} | ${formatDuration(t.durationMs)}</span>
|
<span class="c2-task-meta">${escapeHtml(taskStatusLabel(t.status))} | ${formatDuration(t.durationMs)}</span>
|
||||||
<button class="btn-ghost btn-sm" onclick="C2.viewTask('${t.id}')">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`;
|
||||||
|
}).join('');
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1334,13 +1356,12 @@
|
|||||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
|
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</th>
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
<th>${escapeHtml(c2t('c2.tasks.colCreated'))}</th>
|
||||||
<th>${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
<th class="c2-task-table-col-actions">${escapeHtml(c2t('c2.tasks.colActions'))}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
${C2.tasks.map(t => {
|
${C2.tasks.map(t => {
|
||||||
const rawId = t.id || '';
|
const rawId = t.id || '';
|
||||||
const idJson = JSON.stringify(rawId);
|
|
||||||
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
|
const shortTaskId = rawId.length > 14 ? escapeHtml(rawId.substring(0, 12)) + '\u2026' : escapeHtml(rawId);
|
||||||
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
|
const sid = t.sessionId ? escapeHtml(String(t.sessionId).substring(0, 8)) + '\u2026' : '-';
|
||||||
return `
|
return `
|
||||||
@@ -1356,12 +1377,14 @@
|
|||||||
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
||||||
<td>${formatDuration(t.durationMs)}</td>
|
<td>${formatDuration(t.durationMs)}</td>
|
||||||
<td>${formatTime(t.createdAt)}</td>
|
<td>${formatTime(t.createdAt)}</td>
|
||||||
<td>
|
<td class="c2-task-table-col-actions">
|
||||||
<button type="button" class="btn-ghost btn-sm" onclick="C2.viewTask(${idJson})">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
<div class="c2-task-table-actions">
|
||||||
|
<button type="button" class="btn-secondary btn-small" data-c2-task-action="view" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
||||||
${t.status === 'queued' || t.status === 'sent'
|
${t.status === 'queued' || t.status === 'sent'
|
||||||
? `<button type="button" class="btn-danger btn-sm" onclick="C2.cancelTask(${idJson})">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
|
? `<button type="button" class="btn-danger btn-small" data-c2-task-action="cancel" data-task-id="${escapeHtml(rawId)}">${escapeHtml(c2t('c2.tasks.cancelBtn'))}</button>`
|
||||||
: ''}
|
: ''}
|
||||||
<button type="button" class="btn-secondary btn-sm c2-task-row-delete" onclick="C2.deleteTaskById(${idJson})" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
|
<button type="button" class="btn-danger btn-small" data-c2-task-action="delete" data-task-id="${escapeHtml(rawId)}" title="${delTitle}" aria-label="${delTitle}">${escapeHtml(c2t('c2.tasks.deleteBtn'))}</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
@@ -1387,10 +1410,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="c2-modal-body">
|
<div class="c2-modal-body">
|
||||||
<div class="c2-task-detail">
|
<div class="c2-task-detail">
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelId'))}:</strong> ${t.id}</div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelId'))}:</strong> ${escapeHtml(t.id || '')}</div>
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${t.sessionId}</div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${escapeHtml(t.sessionId || '')}</div>
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelType'))}:</strong> ${t.taskType}</div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelType'))}:</strong> ${escapeHtml(t.taskType || '')}</div>
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelStatus'))}:</strong> <span class="c2-status-badge ${t.status}">${escapeHtml(taskStatusLabel(t.status))}</span></div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelStatus'))}:</strong> <span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></div>
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelCreated'))}:</strong> ${formatTime(t.createdAt)}</div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelCreated'))}:</strong> ${formatTime(t.createdAt)}</div>
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelSent'))}:</strong> ${formatTime(t.sentAt)}</div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelSent'))}:</strong> ${formatTime(t.sentAt)}</div>
|
||||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelCompleted'))}:</strong> ${formatTime(t.completedAt)}</div>
|
<div><strong>${escapeHtml(c2t('c2.tasks.labelCompleted'))}:</strong> ${formatTime(t.completedAt)}</div>
|
||||||
@@ -1416,19 +1439,24 @@
|
|||||||
renderTaskModal(local);
|
renderTaskModal(local);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
apiRequest('GET', `${API_BASE}/tasks/${id}`).then(data => {
|
apiRequest('GET', `${API_BASE}/tasks/${encodeURIComponent(id)}`).then(data => {
|
||||||
|
if (data.error) {
|
||||||
|
showToast(String(data.error), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (data.task) renderTaskModal(data.task);
|
if (data.task) renderTaskModal(data.task);
|
||||||
});
|
else showToast(c2t('c2.tasks.emptyAll'), 'warn');
|
||||||
|
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||||
};
|
};
|
||||||
|
|
||||||
C2.cancelTask = function(id) {
|
C2.cancelTask = function(id) {
|
||||||
apiRequest('POST', `${API_BASE}/tasks/${id}/cancel`, {}).then(data => {
|
apiRequest('POST', `${API_BASE}/tasks/${encodeURIComponent(id)}/cancel`, {}).then(data => {
|
||||||
if (data.error) showToast(data.error, 'error');
|
if (data.error) showToast(String(data.error), 'error');
|
||||||
else {
|
else {
|
||||||
showToast(c2t('c2.tasks.toastCancelled'), 'success');
|
showToast(c2t('c2.tasks.toastCancelled'), 'success');
|
||||||
C2.loadTasks(C2.tasksPage || 1);
|
C2.loadTasks(C2.tasksPage || 1);
|
||||||
}
|
}
|
||||||
});
|
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
@@ -2224,6 +2224,39 @@ function showCopySuccess(button) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 相邻且类型/正文/data 完全一致的过程详情只保留一条(与后端去重一致,避免时间线叠多条相同块) */
|
||||||
|
function dedupeConsecutiveProcessDetailRows(details) {
|
||||||
|
if (!Array.isArray(details) || details.length < 2) {
|
||||||
|
return details;
|
||||||
|
}
|
||||||
|
const out = [details[0]];
|
||||||
|
for (let i = 1; i < details.length; i++) {
|
||||||
|
const cur = details[i];
|
||||||
|
if (processDetailRowFingerprint(out[out.length - 1]) === processDetailRowFingerprint(cur)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
out.push(cur);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function processDetailRowFingerprint(d) {
|
||||||
|
if (!d || typeof d !== 'object') {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
const et = String(d.eventType || '');
|
||||||
|
const msg = String(d.message != null ? d.message : '').trim();
|
||||||
|
let dataKey = '';
|
||||||
|
try {
|
||||||
|
if (d.data != null) {
|
||||||
|
dataKey = JSON.stringify(d.data);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
dataKey = String(d.data);
|
||||||
|
}
|
||||||
|
return et + '\0' + msg + '\0' + dataKey;
|
||||||
|
}
|
||||||
|
|
||||||
// 渲染过程详情
|
// 渲染过程详情
|
||||||
function renderProcessDetails(messageId, processDetails) {
|
function renderProcessDetails(messageId, processDetails) {
|
||||||
const messageElement = document.getElementById(messageId);
|
const messageElement = document.getElementById(messageId);
|
||||||
@@ -2323,6 +2356,7 @@ function renderProcessDetails(messageId, processDetails) {
|
|||||||
}
|
}
|
||||||
detailsContainer.dataset.lazyNotLoaded = '0';
|
detailsContainer.dataset.lazyNotLoaded = '0';
|
||||||
detailsContainer.dataset.loaded = '1';
|
detailsContainer.dataset.loaded = '1';
|
||||||
|
processDetails = dedupeConsecutiveProcessDetailRows(processDetails);
|
||||||
// 如果没有processDetails或为空,显示空状态
|
// 如果没有processDetails或为空,显示空状态
|
||||||
if (!processDetails || processDetails.length === 0) {
|
if (!processDetails || processDetails.length === 0) {
|
||||||
// 显示空状态提示
|
// 显示空状态提示
|
||||||
|
|||||||
+417
-14
@@ -202,7 +202,6 @@ async function refreshDashboard() {
|
|||||||
openHighCount = pickOpenCount(openHighRes, highCount);
|
openHighCount = pickOpenCount(openHighRes, highCount);
|
||||||
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
|
openMediumCount = pickOpenCount(openMediumRes, mediumCount);
|
||||||
openLowCount = pickOpenCount(openLowRes, lowCount);
|
openLowCount = pickOpenCount(openLowRes, lowCount);
|
||||||
if (severityTotalEl) severityTotalEl.textContent = String(total);
|
|
||||||
severityIds.forEach(sev => {
|
severityIds.forEach(sev => {
|
||||||
const count = bySeverity[sev] || 0;
|
const count = bySeverity[sev] || 0;
|
||||||
const el = document.getElementById('dashboard-severity-' + sev);
|
const el = document.getElementById('dashboard-severity-' + sev);
|
||||||
@@ -1390,6 +1389,17 @@ function dashboardBarTooltipOnOut(ev) {
|
|||||||
if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none';
|
if (dashboardBarTooltipEl) dashboardBarTooltipEl.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 仪表盘 → 漏洞管理:带严重程度/状态筛选跳转
|
||||||
|
function navigateToVulnerabilitiesWithFilter(opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
var params = new URLSearchParams();
|
||||||
|
if (opts.severity) params.set('severity', opts.severity);
|
||||||
|
if (opts.status) params.set('status', opts.status);
|
||||||
|
var qs = params.toString();
|
||||||
|
window.location.hash = qs ? 'vulnerabilities?' + qs : 'vulnerabilities';
|
||||||
|
}
|
||||||
|
window.navigateToVulnerabilitiesWithFilter = navigateToVulnerabilitiesWithFilter;
|
||||||
|
|
||||||
// 漏洞严重程度分布:半环形(donut)渲染
|
// 漏洞严重程度分布:半环形(donut)渲染
|
||||||
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
// 几何参数固定,便于配合 viewBox 0 0 560 320 的 SVG 容器
|
||||||
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
// 段间分隔由 CSS 的白色 stroke 完成,不再使用 gapRad
|
||||||
@@ -1402,9 +1412,31 @@ var SEVERITY_DONUT_CFG = {
|
|||||||
rOuter: 165,
|
rOuter: 165,
|
||||||
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
rInner: 115, // 环厚 = 50(介于原 90 和上一版 35 之间,自然且有质感)
|
||||||
labelOffset: 14,
|
labelOffset: 14,
|
||||||
gapRad: 0
|
gapRad: 0.012
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 三段渐变:[高光浅调, 中段饱和色, 深色边缘] —— 做出类似 3D 釉面的层次
|
||||||
|
var SEVERITY_DONUT_GRADIENTS = {
|
||||||
|
critical: ['#fecaca', '#f87171', '#dc2626'],
|
||||||
|
high: ['#fed7aa', '#fb923c', '#ea580c'],
|
||||||
|
medium: ['#fef08a', '#facc15', '#ca8a04'],
|
||||||
|
low: ['#99f6e4', '#2dd4bf', '#0f766e'],
|
||||||
|
info: ['#bfdbfe', '#60a5fa', '#2563eb']
|
||||||
|
};
|
||||||
|
|
||||||
|
var severityDonutCenterDisplayed = { total: null, hoverCount: null };
|
||||||
|
|
||||||
|
var severityDonutState = {
|
||||||
|
bySeverity: {},
|
||||||
|
total: 0,
|
||||||
|
hoverId: null,
|
||||||
|
bound: false
|
||||||
|
};
|
||||||
|
|
||||||
|
var severityDonutTooltipEl = null;
|
||||||
|
var severityDonutTooltipTimer = null;
|
||||||
|
var severityDonutHoverClearTimer = null;
|
||||||
|
|
||||||
var SEVERITY_DEFAULT_LABELS = {
|
var SEVERITY_DEFAULT_LABELS = {
|
||||||
critical: '严重',
|
critical: '严重',
|
||||||
high: '高危',
|
high: '高危',
|
||||||
@@ -1422,17 +1454,65 @@ function severityLabel(id) {
|
|||||||
return SEVERITY_DEFAULT_LABELS[id] || id;
|
return SEVERITY_DEFAULT_LABELS[id] || id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function ensureSeverityDonutDefs() {
|
||||||
|
var defsEl = document.getElementById('dashboard-severity-donut-defs');
|
||||||
|
if (!defsEl || defsEl.hasChildNodes()) return;
|
||||||
|
var html = '';
|
||||||
|
html += '<linearGradient id="donut-track-face" x1="0%" y1="0%" x2="0%" y2="100%">';
|
||||||
|
html += '<stop offset="0%" stop-color="#f8fafc"/>';
|
||||||
|
html += '<stop offset="55%" stop-color="#e8eef5"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="#dce5ef"/>';
|
||||||
|
html += '</linearGradient>';
|
||||||
|
html += '<radialGradient id="donut-track-vignette" cx="50%" cy="85%" r="75%" fx="50%" fy="85%">';
|
||||||
|
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.35"/>';
|
||||||
|
html += '<stop offset="70%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||||
|
html += '</radialGradient>';
|
||||||
|
html += '<radialGradient id="donut-inner-gloss" cx="35%" cy="75%" r="55%">';
|
||||||
|
html += '<stop offset="0%" stop-color="#ffffff" stop-opacity="0.45"/>';
|
||||||
|
html += '<stop offset="55%" stop-color="#ffffff" stop-opacity="0.08"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="#ffffff" stop-opacity="0"/>';
|
||||||
|
html += '</radialGradient>';
|
||||||
|
html += '<filter id="donut-segment-soften" x="-18%" y="-18%" width="136%" height="136%" color-interpolation-filters="sRGB">';
|
||||||
|
html += '<feGaussianBlur in="SourceAlpha" stdDeviation="0.8" result="blur"/>';
|
||||||
|
html += '<feOffset dx="0" dy="1.5" in="blur" result="off"/>';
|
||||||
|
html += '<feFlood flood-color="#0f172a" flood-opacity="0.13" result="flood"/>';
|
||||||
|
html += '<feComposite in="flood" in2="off" operator="in" result="shadow"/>';
|
||||||
|
html += '<feMerge><feMergeNode in="shadow"/><feMergeNode in="SourceGraphic"/></feMerge>';
|
||||||
|
html += '</filter>';
|
||||||
|
Object.keys(SEVERITY_DONUT_GRADIENTS).forEach(function (id) {
|
||||||
|
var stops = SEVERITY_DONUT_GRADIENTS[id];
|
||||||
|
html += '<linearGradient id="donut-grad-' + id + '" x1="18%" y1="12%" x2="88%" y2="94%">';
|
||||||
|
html += '<stop offset="0%" stop-color="' + stops[0] + '"/>';
|
||||||
|
html += '<stop offset="52%" stop-color="' + stops[1] + '"/>';
|
||||||
|
html += '<stop offset="100%" stop-color="' + stops[2] + '"/>';
|
||||||
|
html += '</linearGradient>';
|
||||||
|
});
|
||||||
|
defsEl.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
function renderSeverityDonut(bySeverity, total) {
|
function renderSeverityDonut(bySeverity, total) {
|
||||||
|
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||||
var trackEl = document.getElementById('dashboard-severity-donut-track');
|
var trackEl = document.getElementById('dashboard-severity-donut-track');
|
||||||
|
var leadersEl = document.getElementById('dashboard-severity-donut-leaders');
|
||||||
var segmentsEl = document.getElementById('dashboard-severity-donut-segments');
|
var segmentsEl = document.getElementById('dashboard-severity-donut-segments');
|
||||||
|
var hitsEl = document.getElementById('dashboard-severity-donut-hits');
|
||||||
var labelsEl = document.getElementById('dashboard-severity-donut-labels');
|
var labelsEl = document.getElementById('dashboard-severity-donut-labels');
|
||||||
if (!trackEl || !segmentsEl || !labelsEl) return;
|
if (!trackEl || !segmentsEl || !labelsEl) return;
|
||||||
|
|
||||||
var cfg = SEVERITY_DONUT_CFG;
|
severityDonutState.bySeverity = bySeverity && typeof bySeverity === 'object' ? bySeverity : {};
|
||||||
|
severityDonutState.total = total || 0;
|
||||||
|
severityDonutState.hoverId = null;
|
||||||
|
|
||||||
// 背景轨迹(完整半环)只渲染一次
|
var cfg = SEVERITY_DONUT_CFG;
|
||||||
|
ensureSeverityDonutDefs();
|
||||||
|
|
||||||
|
// 背景轨迹(完整半环):双层填充营造凹槽 + 高光
|
||||||
if (!trackEl.hasChildNodes()) {
|
if (!trackEl.hasChildNodes()) {
|
||||||
trackEl.innerHTML = '<path class="donut-track" d="' + halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner) + '"/>';
|
var trackPath = halfRingPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner);
|
||||||
|
trackEl.innerHTML =
|
||||||
|
'<path class="donut-track-shadow" d="' + trackPath + '"/>' +
|
||||||
|
'<path class="donut-track" fill="url(#donut-track-face)" d="' + trackPath + '"/>' +
|
||||||
|
'<path class="donut-track-vignette" fill="url(#donut-track-vignette)" d="' + trackPath + '"/>';
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
var ids = ['critical', 'high', 'medium', 'low', 'info'];
|
||||||
@@ -1441,12 +1521,24 @@ function renderSeverityDonut(bySeverity, total) {
|
|||||||
});
|
});
|
||||||
var visible = severities.filter(function (s) { return s.value > 0; });
|
var visible = severities.filter(function (s) { return s.value > 0; });
|
||||||
|
|
||||||
|
if (svgEl) {
|
||||||
|
svgEl.classList.remove('is-highlighting');
|
||||||
|
svgEl.removeAttribute('data-hover-severity');
|
||||||
|
}
|
||||||
if (!total || total <= 0 || visible.length === 0) {
|
if (!total || total <= 0 || visible.length === 0) {
|
||||||
segmentsEl.innerHTML = '';
|
segmentsEl.innerHTML = '';
|
||||||
|
if (hitsEl) hitsEl.innerHTML = '';
|
||||||
labelsEl.innerHTML = '';
|
labelsEl.innerHTML = '';
|
||||||
|
if (leadersEl) leadersEl.innerHTML = '';
|
||||||
|
clearSeverityDonutLegendHighlight();
|
||||||
|
resetSeverityDonutCenter(false);
|
||||||
|
_clearSeverityDonutChartWrapHover();
|
||||||
|
if (svgEl) svgEl.classList.remove('donut-ready');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resetSeverityDonutCenter(true);
|
||||||
|
|
||||||
// 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白
|
// 弧长按 value/total 计算;若严重度求和 < total(存在未分级),右侧会保留背景轨迹的空白
|
||||||
var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0);
|
var sumVisible = visible.reduce(function (s, seg) { return s + seg.value; }, 0);
|
||||||
var coverage = sumVisible / total; // 半环被实际段覆盖的比例
|
var coverage = sumVisible / total; // 半环被实际段覆盖的比例
|
||||||
@@ -1456,7 +1548,10 @@ function renderSeverityDonut(bySeverity, total) {
|
|||||||
var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad);
|
var arcsTotalRad = Math.max(0, Math.PI * coverage - totalGapRad);
|
||||||
|
|
||||||
var segmentsHtml = '';
|
var segmentsHtml = '';
|
||||||
|
var hitsHtml = '';
|
||||||
|
var glossHtml = '';
|
||||||
var labelsHtml = '';
|
var labelsHtml = '';
|
||||||
|
var leadersHtml = '';
|
||||||
var cumRad = 0;
|
var cumRad = 0;
|
||||||
|
|
||||||
visible.forEach(function (seg, i) {
|
visible.forEach(function (seg, i) {
|
||||||
@@ -1466,17 +1561,21 @@ function renderSeverityDonut(bySeverity, total) {
|
|||||||
var angleEnd = angleStart - segRad;
|
var angleEnd = angleStart - segRad;
|
||||||
|
|
||||||
var path = arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner, angleStart, angleEnd);
|
var path = arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter, cfg.rInner, angleStart, angleEnd);
|
||||||
segmentsHtml += '<path class="donut-segment seg-' + seg.id + '" d="' + path + '"/>';
|
var pctOfTotal = (seg.value / total) * 100;
|
||||||
|
var pctRounded = Math.round(pctOfTotal);
|
||||||
|
var name = esc(severityLabel(seg.id));
|
||||||
|
var ariaLabel = name + ' ' + seg.value + ' (' + pctRounded + '%)';
|
||||||
|
segmentsHtml += '<path class="donut-segment seg-' + seg.id + '" data-severity="' + seg.id + '" data-count="' + seg.value + '" data-pct="' + pctRounded + '" fill="url(#donut-grad-' + seg.id + ')" d="' + path + '"/>';
|
||||||
|
hitsHtml += '<path class="donut-segment-hit seg-' + seg.id + '" data-severity="' + seg.id + '" fill="transparent" d="' + path + '" tabindex="0" role="button" aria-label="' + ariaLabel + '"/>';
|
||||||
|
glossHtml += '<path class="donut-segment-gloss seg-' + seg.id + '" data-severity="' + seg.id + '" fill="url(#donut-inner-gloss)" d="' + arcSegmentPath(cfg.cx, cfg.cy, cfg.rOuter - 2, cfg.rInner + 6, angleStart, angleEnd) + '" pointer-events="none"/>';
|
||||||
|
|
||||||
// 仅当占比 >= 5% 时显示外置标签,避免小段标签互相重叠
|
// 仅当占比 >= 5% 时显示外置标签,避免小段标签互相重叠
|
||||||
var pctOfTotal = (seg.value / total) * 100;
|
|
||||||
if (pctOfTotal >= 5) {
|
if (pctOfTotal >= 5) {
|
||||||
var midAngle = (angleStart + angleEnd) / 2;
|
var midAngle = (angleStart + angleEnd) / 2;
|
||||||
var labelR = cfg.rOuter + cfg.labelOffset;
|
var labelR = cfg.rOuter + cfg.labelOffset + 6;
|
||||||
var sinMid = Math.sin(midAngle);
|
var sinMid = Math.sin(midAngle);
|
||||||
var cosMid = Math.cos(midAngle);
|
var cosMid = Math.cos(midAngle);
|
||||||
var lx = cfg.cx + labelR * cosMid;
|
var lx = cfg.cx + labelR * cosMid;
|
||||||
// 顶部区域标签整体向上抬一些,避免与外弧贴住;侧边标签则不调整
|
|
||||||
var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0;
|
var topLift = sinMid > 0.4 ? Math.round((sinMid - 0.3) * 10) : 0;
|
||||||
var ly = cfg.cy - labelR * sinMid - topLift;
|
var ly = cfg.cy - labelR * sinMid - topLift;
|
||||||
|
|
||||||
@@ -1484,11 +1583,15 @@ function renderSeverityDonut(bySeverity, total) {
|
|||||||
if (cosMid < -0.15) anchor = 'end';
|
if (cosMid < -0.15) anchor = 'end';
|
||||||
else if (cosMid > 0.15) anchor = 'start';
|
else if (cosMid > 0.15) anchor = 'start';
|
||||||
|
|
||||||
var pctText = Math.round(pctOfTotal) + '%';
|
var pctText = pctRounded + '%';
|
||||||
var name = esc(severityLabel(seg.id));
|
var arcR = cfg.rOuter + 4;
|
||||||
|
var lineX1 = cfg.cx + arcR * cosMid;
|
||||||
|
var lineY1 = cfg.cy - arcR * sinMid;
|
||||||
|
var lineX2 = cfg.cx + (cfg.rOuter + cfg.labelOffset - 2) * cosMid;
|
||||||
|
var lineY2 = cfg.cy - (cfg.rOuter + cfg.labelOffset - 2) * sinMid;
|
||||||
|
leadersHtml += '<line class="donut-leader label-' + seg.id + '" data-severity="' + seg.id + '" pathLength="100" x1="' + lineX1.toFixed(1) + '" y1="' + lineY1.toFixed(1) + '" x2="' + lineX2.toFixed(1) + '" y2="' + lineY2.toFixed(1) + '"/>';
|
||||||
|
|
||||||
// 两行:第一行 "数量 (百分比)"(弧色),第二行 "严重度名称"(同色但稍小)
|
labelsHtml += '<text class="donut-label-text label-' + seg.id + '" data-severity="' + seg.id + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '">';
|
||||||
labelsHtml += '<text class="donut-label-text label-' + seg.id + '" text-anchor="' + anchor + '" x="' + lx.toFixed(1) + '" y="' + ly.toFixed(1) + '">';
|
|
||||||
labelsHtml += '<tspan x="' + lx.toFixed(1) + '" dy="0">' + seg.value + ' <tspan class="donut-label-pct">(' + pctText + ')</tspan></tspan>';
|
labelsHtml += '<tspan x="' + lx.toFixed(1) + '" dy="0">' + seg.value + ' <tspan class="donut-label-pct">(' + pctText + ')</tspan></tspan>';
|
||||||
labelsHtml += '<tspan class="donut-label-name" x="' + lx.toFixed(1) + '" dy="14">' + name + '</tspan>';
|
labelsHtml += '<tspan class="donut-label-name" x="' + lx.toFixed(1) + '" dy="14">' + name + '</tspan>';
|
||||||
labelsHtml += '</text>';
|
labelsHtml += '</text>';
|
||||||
@@ -1498,8 +1601,308 @@ function renderSeverityDonut(bySeverity, total) {
|
|||||||
if (i < visibleCount - 1) cumRad += cfg.gapRad;
|
if (i < visibleCount - 1) cumRad += cfg.gapRad;
|
||||||
});
|
});
|
||||||
|
|
||||||
segmentsEl.innerHTML = segmentsHtml;
|
if (leadersEl) leadersEl.innerHTML = leadersHtml;
|
||||||
|
segmentsEl.innerHTML = segmentsHtml + glossHtml;
|
||||||
|
if (hitsEl) hitsEl.innerHTML = hitsHtml;
|
||||||
labelsEl.innerHTML = labelsHtml;
|
labelsEl.innerHTML = labelsHtml;
|
||||||
|
if (svgEl) {
|
||||||
|
svgEl.classList.remove('donut-ready');
|
||||||
|
void svgEl.offsetWidth;
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
svgEl.classList.add('donut-ready');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
scheduleSeverityCenterCountUp(total);
|
||||||
|
attachSeverityDonutInteractivity();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSeverityCenterCountUp(targetTotal) {
|
||||||
|
if (window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
|
var totalEl = document.getElementById('dashboard-severity-total');
|
||||||
|
if (totalEl) totalEl.textContent = String(targetTotal);
|
||||||
|
severityDonutCenterDisplayed.total = targetTotal;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var totalEl = document.getElementById('dashboard-severity-total');
|
||||||
|
if (!totalEl || severityDonutState.hoverId) return;
|
||||||
|
var from = typeof severityDonutCenterDisplayed.total === 'number' ? severityDonutCenterDisplayed.total : 0;
|
||||||
|
var to = targetTotal;
|
||||||
|
if (from === to) {
|
||||||
|
totalEl.textContent = String(to);
|
||||||
|
severityDonutCenterDisplayed.total = to;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var start = null;
|
||||||
|
var dur = Math.min(520, 180 + Math.abs(to - from) * 28);
|
||||||
|
function tick(now) {
|
||||||
|
if (!start) start = now;
|
||||||
|
var t = Math.min(1, (now - start) / dur);
|
||||||
|
var eased = 1 - Math.pow(1 - t, 3);
|
||||||
|
var val = Math.round(from + (to - from) * eased);
|
||||||
|
totalEl.textContent = String(val);
|
||||||
|
if (t < 1) {
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
} else {
|
||||||
|
totalEl.textContent = String(to);
|
||||||
|
severityDonutCenterDisplayed.total = to;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetSeverityDonutCenter(skipTotalSnapshot) {
|
||||||
|
var totalEl = document.getElementById('dashboard-severity-total');
|
||||||
|
var labelEl = document.getElementById('dashboard-severity-center-label');
|
||||||
|
var centerEl = document.getElementById('dashboard-severity-center');
|
||||||
|
var n = severityDonutState.total || 0;
|
||||||
|
if (!skipTotalSnapshot && totalEl) totalEl.textContent = String(n);
|
||||||
|
if (!skipTotalSnapshot) severityDonutCenterDisplayed.total = n;
|
||||||
|
severityDonutCenterDisplayed.hoverCount = null;
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = (typeof window.t === 'function' ? window.t('dashboard.totalVulns') : '总漏洞数');
|
||||||
|
labelEl.classList.remove('is-severity');
|
||||||
|
labelEl.removeAttribute('data-severity');
|
||||||
|
}
|
||||||
|
if (centerEl) centerEl.classList.remove('is-hovering');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSeverityDonutHover(severityId) {
|
||||||
|
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||||
|
var centerEl = document.getElementById('dashboard-severity-center');
|
||||||
|
var totalEl = document.getElementById('dashboard-severity-total');
|
||||||
|
var labelEl = document.getElementById('dashboard-severity-center-label');
|
||||||
|
if (!severityId) {
|
||||||
|
severityDonutState.hoverId = null;
|
||||||
|
if (svgEl) {
|
||||||
|
svgEl.classList.remove('is-highlighting');
|
||||||
|
svgEl.removeAttribute('data-hover-severity');
|
||||||
|
}
|
||||||
|
clearSeverityDonutLegendHighlight();
|
||||||
|
resetSeverityDonutCenter(false);
|
||||||
|
_clearSeverityDonutChartWrapHover();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[severityId]) || 0;
|
||||||
|
severityDonutState.hoverId = severityId;
|
||||||
|
if (svgEl) {
|
||||||
|
svgEl.classList.add('is-highlighting');
|
||||||
|
svgEl.setAttribute('data-hover-severity', severityId);
|
||||||
|
}
|
||||||
|
highlightSeverityDonutParts(severityId);
|
||||||
|
highlightSeverityLegendItem(severityId);
|
||||||
|
if (totalEl) {
|
||||||
|
totalEl.textContent = String(count);
|
||||||
|
severityDonutCenterDisplayed.hoverCount = count;
|
||||||
|
}
|
||||||
|
if (labelEl) {
|
||||||
|
labelEl.textContent = severityLabel(severityId);
|
||||||
|
labelEl.classList.add('is-severity');
|
||||||
|
labelEl.setAttribute('data-severity', severityId);
|
||||||
|
}
|
||||||
|
if (centerEl) centerEl.classList.add('is-hovering');
|
||||||
|
var chartWrap = document.querySelector('.dashboard-severity-chart');
|
||||||
|
if (chartWrap) chartWrap.setAttribute('data-hover-severity', severityId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function _clearSeverityDonutChartWrapHover() {
|
||||||
|
var chartWrap = document.querySelector('.dashboard-severity-chart');
|
||||||
|
if (chartWrap) chartWrap.removeAttribute('data-hover-severity');
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSeverityDonutParts(severityId) {
|
||||||
|
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||||
|
if (!svgEl) return;
|
||||||
|
svgEl.querySelectorAll('.donut-segment[data-severity], .donut-segment-gloss[data-severity], .donut-leader[data-severity], .donut-label-text[data-severity]').forEach(function (el) {
|
||||||
|
var match = el.getAttribute('data-severity') === severityId;
|
||||||
|
el.classList.toggle('is-active', match);
|
||||||
|
el.classList.toggle('is-dimmed', !match);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function highlightSeverityLegendItem(severityId) {
|
||||||
|
var legend = document.getElementById('dashboard-vuln-bars');
|
||||||
|
if (!legend) return;
|
||||||
|
legend.querySelectorAll('.dashboard-severity-legend-item').forEach(function (item) {
|
||||||
|
var match = item.getAttribute('data-severity') === severityId;
|
||||||
|
item.classList.toggle('is-active', match);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearSeverityDonutLegendHighlight() {
|
||||||
|
var legend = document.getElementById('dashboard-vuln-bars');
|
||||||
|
if (legend) {
|
||||||
|
legend.querySelectorAll('.dashboard-severity-legend-item.is-active').forEach(function (el) {
|
||||||
|
el.classList.remove('is-active');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
var svgEl = document.getElementById('dashboard-severity-donut');
|
||||||
|
if (svgEl) {
|
||||||
|
svgEl.querySelectorAll('.is-active, .is-dimmed').forEach(function (el) {
|
||||||
|
el.classList.remove('is-active', 'is-dimmed');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutTooltipText(severityId) {
|
||||||
|
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[severityId]) || 0;
|
||||||
|
var pct = severityDonutState.total > 0 ? Math.round((count / severityDonutState.total) * 100) : 0;
|
||||||
|
var hint = (typeof window.t === 'function' ? window.t('dashboard.severityClickHint') : '点击查看');
|
||||||
|
return severityLabel(severityId) + ' · ' + count + ' (' + pct + '%) — ' + hint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSeverityDonutTooltip(ev, severityId) {
|
||||||
|
if (!severityDonutTooltipEl) {
|
||||||
|
severityDonutTooltipEl = document.createElement('div');
|
||||||
|
severityDonutTooltipEl.className = 'dashboard-severity-donut-tooltip';
|
||||||
|
severityDonutTooltipEl.setAttribute('role', 'tooltip');
|
||||||
|
document.body.appendChild(severityDonutTooltipEl);
|
||||||
|
}
|
||||||
|
clearTimeout(severityDonutTooltipTimer);
|
||||||
|
severityDonutTooltipTimer = setTimeout(function () {
|
||||||
|
severityDonutTooltipEl.textContent = severityDonutTooltipText(severityId);
|
||||||
|
severityDonutTooltipEl.style.display = 'block';
|
||||||
|
requestAnimationFrame(function () {
|
||||||
|
var x = ev.clientX;
|
||||||
|
var y = ev.clientY;
|
||||||
|
var ttRect = severityDonutTooltipEl.getBoundingClientRect();
|
||||||
|
var left = x - ttRect.width / 2;
|
||||||
|
var top = y - ttRect.height - 12;
|
||||||
|
if (top < 8) top = y + 16;
|
||||||
|
var pad = 8;
|
||||||
|
if (left < pad) left = pad;
|
||||||
|
if (left + ttRect.width > window.innerWidth - pad) left = window.innerWidth - ttRect.width - pad;
|
||||||
|
severityDonutTooltipEl.style.left = left + 'px';
|
||||||
|
severityDonutTooltipEl.style.top = top + 'px';
|
||||||
|
});
|
||||||
|
}, 120);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideSeverityDonutTooltip() {
|
||||||
|
clearTimeout(severityDonutTooltipTimer);
|
||||||
|
severityDonutTooltipTimer = null;
|
||||||
|
if (severityDonutTooltipEl) severityDonutTooltipEl.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSeverityDonutInteractivity() {
|
||||||
|
var hitsEl = document.getElementById('dashboard-severity-donut-hits');
|
||||||
|
var legend = document.getElementById('dashboard-vuln-bars');
|
||||||
|
if (!hitsEl) return;
|
||||||
|
|
||||||
|
if (!severityDonutState.bound) {
|
||||||
|
severityDonutState.bound = true;
|
||||||
|
hitsEl.addEventListener('mouseover', severityDonutPointerOver);
|
||||||
|
hitsEl.addEventListener('mouseout', severityDonutPointerOut);
|
||||||
|
hitsEl.addEventListener('click', severityDonutClick);
|
||||||
|
hitsEl.addEventListener('keydown', severityDonutKeydown);
|
||||||
|
if (legend) {
|
||||||
|
legend.addEventListener('mouseover', severityLegendPointerOver);
|
||||||
|
legend.addEventListener('mouseout', severityLegendPointerOut);
|
||||||
|
legend.addEventListener('click', severityLegendClick);
|
||||||
|
legend.addEventListener('keydown', severityLegendKeydown);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
legend && legend.querySelectorAll('.dashboard-severity-legend-item').forEach(function (item) {
|
||||||
|
if (!item.getAttribute('data-severity')) return;
|
||||||
|
var sev = item.getAttribute('data-severity');
|
||||||
|
var count = (severityDonutState.bySeverity && severityDonutState.bySeverity[sev]) || 0;
|
||||||
|
item.classList.toggle('is-zero', count === 0);
|
||||||
|
item.setAttribute('aria-label', severityDonutTooltipText(sev));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutHitTarget(el) {
|
||||||
|
return el && el.closest && el.closest('.donut-segment-hit');
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutCancelHoverClear() {
|
||||||
|
clearTimeout(severityDonutHoverClearTimer);
|
||||||
|
severityDonutHoverClearTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutScheduleHoverClear() {
|
||||||
|
severityDonutCancelHoverClear();
|
||||||
|
severityDonutHoverClearTimer = setTimeout(function () {
|
||||||
|
severityDonutHoverClearTimer = null;
|
||||||
|
setSeverityDonutHover(null);
|
||||||
|
hideSeverityDonutTooltip();
|
||||||
|
}, 60);
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutPointerOver(ev) {
|
||||||
|
var target = severityDonutHitTarget(ev.target);
|
||||||
|
if (!target) return;
|
||||||
|
var id = target.getAttribute('data-severity');
|
||||||
|
if (!id) return;
|
||||||
|
severityDonutCancelHoverClear();
|
||||||
|
if (severityDonutState.hoverId === id) return;
|
||||||
|
setSeverityDonutHover(id);
|
||||||
|
showSeverityDonutTooltip(ev, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutPointerOut(ev) {
|
||||||
|
var related = ev.relatedTarget;
|
||||||
|
if (related) {
|
||||||
|
if (severityDonutHitTarget(related)) return;
|
||||||
|
var legendItem = related.closest && related.closest('.dashboard-severity-legend-item[data-severity]');
|
||||||
|
if (legendItem) return;
|
||||||
|
var hitsRoot = document.getElementById('dashboard-severity-donut-hits');
|
||||||
|
if (hitsRoot && hitsRoot.contains(related)) return;
|
||||||
|
}
|
||||||
|
severityDonutScheduleHoverClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutClick(ev) {
|
||||||
|
var target = severityDonutHitTarget(ev.target);
|
||||||
|
if (!target) return;
|
||||||
|
var id = target.getAttribute('data-severity');
|
||||||
|
if (!id) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityDonutKeydown(ev) {
|
||||||
|
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||||
|
var target = severityDonutHitTarget(ev.target);
|
||||||
|
if (!target) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var id = target.getAttribute('data-severity');
|
||||||
|
if (id) navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLegendPointerOver(ev) {
|
||||||
|
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||||
|
if (!item) return;
|
||||||
|
var id = item.getAttribute('data-severity');
|
||||||
|
if (!id) return;
|
||||||
|
severityDonutCancelHoverClear();
|
||||||
|
setSeverityDonutHover(id);
|
||||||
|
showSeverityDonutTooltip(ev, id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLegendPointerOut(ev) {
|
||||||
|
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||||
|
var related = ev.relatedTarget && ev.relatedTarget.closest && ev.relatedTarget.closest('.dashboard-severity-legend-item[data-severity]');
|
||||||
|
if (item && item === related) return;
|
||||||
|
severityDonutScheduleHoverClear();
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLegendClick(ev) {
|
||||||
|
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||||
|
if (!item) return;
|
||||||
|
var id = item.getAttribute('data-severity');
|
||||||
|
if (!id) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||||
|
}
|
||||||
|
|
||||||
|
function severityLegendKeydown(ev) {
|
||||||
|
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||||
|
var item = ev.target && ev.target.closest && ev.target.closest('.dashboard-severity-legend-item[data-severity]');
|
||||||
|
if (!item) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
var id = item.getAttribute('data-severity');
|
||||||
|
if (id) navigateToVulnerabilitiesWithFilter({ severity: id });
|
||||||
}
|
}
|
||||||
|
|
||||||
// SVG 半环(背景轨迹)路径
|
// SVG 半环(背景轨迹)路径
|
||||||
|
|||||||
+548
-48
@@ -772,7 +772,7 @@ function toggleProgressDetails(progressId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 编排器开始输出最终回复时隐藏整条进度消息(迭代阶段保持展开可见;此处整行收起而非仅折叠时间线)
|
// 编排器开始输出最终回复时隐藏整条进度消息(过程已迁入助手气泡的「展开详情」,避免与进度卡重复)
|
||||||
function hideProgressMessageForFinalReply(progressId) {
|
function hideProgressMessageForFinalReply(progressId) {
|
||||||
if (!progressId) return;
|
if (!progressId) return;
|
||||||
const el = document.getElementById(progressId);
|
const el = document.getElementById(progressId);
|
||||||
@@ -970,7 +970,7 @@ function integrateProgressToMCPSection(progressId, assistantMessageId, mcpExecut
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 移除原来的进度消息
|
// 移除原来的进度消息(详情已快照到助手消息下的 process-details)
|
||||||
removeMessage(progressId);
|
removeMessage(progressId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1885,6 +1885,8 @@ function handleStreamEvent(event, progressElement, progressId,
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
// 多代理模式下,迭代过程中的输出只显示在时间线中,不创建助手消息气泡
|
||||||
|
// 同一 progressId 再次 response_start 时先移除旧占位,避免多条「助手输出」卡片且仅最后一条收 delta
|
||||||
|
// 改为保留旧占位,让每一段 response_start 都能在时间线中完整展示。
|
||||||
// 创建时间线条目用于显示迭代过程中的输出
|
// 创建时间线条目用于显示迭代过程中的输出
|
||||||
const title = einoMainStreamPlanningTitle(responseData);
|
const title = einoMainStreamPlanningTitle(responseData);
|
||||||
const itemId = addTimelineItem(timeline, 'thinking', {
|
const itemId = addTimelineItem(timeline, 'thinking', {
|
||||||
@@ -2980,6 +2982,9 @@ async function applyMonitorFilters() {
|
|||||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
const status = statusFilter ? statusFilter.value : 'all';
|
const status = statusFilter ? statusFilter.value : 'all';
|
||||||
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
||||||
|
if (toolFilter) {
|
||||||
|
toolFilter.classList.toggle('is-filter-active', tool !== 'all');
|
||||||
|
}
|
||||||
// 当筛选条件改变时,从后端重新获取数据
|
// 当筛选条件改变时,从后端重新获取数据
|
||||||
await refreshMonitorPanelWithFilter(status, tool);
|
await refreshMonitorPanelWithFilter(status, tool);
|
||||||
}
|
}
|
||||||
@@ -3043,20 +3048,410 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
const MCP_STATS_TOP_N = 6;
|
||||||
|
|
||||||
|
function mcpMonitorT(key, params) {
|
||||||
|
if (typeof window.t !== 'function') return '';
|
||||||
|
return window.t('mcpMonitor.' + key, {
|
||||||
|
...(params || {}),
|
||||||
|
interpolation: { escapeValue: false },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMonitorStatsEntries(statsMap) {
|
||||||
|
if (!statsMap || typeof statsMap !== 'object') return [];
|
||||||
|
return Object.entries(statsMap).map(([key, item]) => {
|
||||||
|
const stat = item && typeof item === 'object' ? { ...item } : {};
|
||||||
|
if (!stat.toolName) stat.toolName = key;
|
||||||
|
return stat;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCP_STATS_TOOL_CHEVRON = '<svg class="mcp-stats-tool-chevron" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="9 18 15 12 9 6"/></svg>';
|
||||||
|
|
||||||
|
function getMcpStatsRateTone(rateNum) {
|
||||||
|
if (rateNum >= 95) return 'is-success';
|
||||||
|
if (rateNum >= 80) return 'is-warning';
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpStatsRingStrokeClass(rateNum) {
|
||||||
|
if (rateNum >= 95) return '';
|
||||||
|
if (rateNum >= 80) return 'is-warning';
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsSuccessRing(percent) {
|
||||||
|
const p = Math.min(100, Math.max(0, parseFloat(percent) || 0));
|
||||||
|
const r = 15.9155;
|
||||||
|
const circumference = 2 * Math.PI * r;
|
||||||
|
const offset = circumference - (p / 100) * circumference;
|
||||||
|
const strokeClass = getMcpStatsRingStrokeClass(p);
|
||||||
|
return `<div class="mcp-stats-ring-wrap" aria-hidden="true">
|
||||||
|
<svg class="mcp-stats-ring-svg" viewBox="0 0 36 36">
|
||||||
|
<circle class="mcp-stats-ring-track" cx="18" cy="18" r="${r}" fill="none" stroke-width="3"/>
|
||||||
|
<circle class="mcp-stats-ring-fill ${strokeClass}" cx="18" cy="18" r="${r}" fill="none" stroke-width="3"
|
||||||
|
stroke-dasharray="${circumference}" stroke-dashoffset="${offset}"/>
|
||||||
|
</svg>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsToolVolumeBar(total, success, failed, maxTotal) {
|
||||||
|
const volumePct = maxTotal > 0 && total > 0 ? (total / maxTotal) * 100 : 0;
|
||||||
|
const successPct = total > 0 ? (success / total) * 100 : 0;
|
||||||
|
const failPct = total > 0 ? (failed / total) * 100 : 0;
|
||||||
|
const legend = mcpMonitorT('barVolumeLegend') || '条长表示相对调用量';
|
||||||
|
const volumeTitle = `${total} / ${maxTotal}`;
|
||||||
|
return `<div class="mcp-stats-tool-bar-track" title="${escapeHtml(legend)} · ${escapeHtml(volumeTitle)}">
|
||||||
|
<div class="mcp-stats-tool-bar-fill" style="width:${volumePct.toFixed(2)}%">
|
||||||
|
<div class="mcp-stats-tool-bar-inner">
|
||||||
|
<span class="mcp-stats-tool-bar-seg mcp-stats-tool-bar-seg--success" style="width:${successPct.toFixed(2)}%"></span>
|
||||||
|
<span class="mcp-stats-tool-bar-seg mcp-stats-tool-bar-seg--fail" style="width:${failPct.toFixed(2)}%"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMcpToolRateClass(rateNum) {
|
||||||
|
if (rateNum >= 95) return 'is-success';
|
||||||
|
if (rateNum >= 80) return 'is-warning';
|
||||||
|
return 'is-danger';
|
||||||
|
}
|
||||||
|
|
||||||
|
const MCP_STATS_DIST_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#14b8a6', '#ec4899'];
|
||||||
|
|
||||||
|
function mcpStatsDescribeDonutSegment(startPct, endPct, outerR, innerR) {
|
||||||
|
if (endPct <= startPct) return '';
|
||||||
|
const span = endPct - startPct;
|
||||||
|
const cx = 50;
|
||||||
|
const cy = 50;
|
||||||
|
const point = (pct, r) => {
|
||||||
|
const rad = ((pct / 100) * 360 - 90) * Math.PI / 180;
|
||||||
|
return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)];
|
||||||
|
};
|
||||||
|
if (span >= 99.995) {
|
||||||
|
const [x1, y1] = point(0, outerR);
|
||||||
|
const [x2, y2] = point(50, outerR);
|
||||||
|
const [x3, y3] = point(50, innerR);
|
||||||
|
const [x4, y4] = point(0, innerR);
|
||||||
|
const [x5, y5] = point(50, outerR);
|
||||||
|
const [x6, y6] = point(100, outerR);
|
||||||
|
const [x7, y7] = point(100, innerR);
|
||||||
|
const [x8, y8] = point(50, innerR);
|
||||||
|
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 0 1 ${x2.toFixed(3)} ${y2.toFixed(3)} A ${outerR} ${outerR} 0 0 1 ${x6.toFixed(3)} ${y6.toFixed(3)} L ${x7.toFixed(3)} ${y7.toFixed(3)} A ${innerR} ${innerR} 0 0 0 ${x8.toFixed(3)} ${y8.toFixed(3)} A ${innerR} ${innerR} 0 0 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`;
|
||||||
|
}
|
||||||
|
const large = span > 50 ? 1 : 0;
|
||||||
|
const [x1, y1] = point(startPct, outerR);
|
||||||
|
const [x2, y2] = point(endPct, outerR);
|
||||||
|
const [x3, y3] = point(endPct, innerR);
|
||||||
|
const [x4, y4] = point(startPct, innerR);
|
||||||
|
return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)} L ${x3.toFixed(3)} ${y3.toFixed(3)} A ${innerR} ${innerR} 0 ${large} 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetMcpStatsDistCenter(panel) {
|
||||||
|
if (!panel) return;
|
||||||
|
const label = panel.querySelector('.mcp-stats-dist-donut-label');
|
||||||
|
const value = panel.querySelector('.mcp-stats-dist-donut-value');
|
||||||
|
const unit = panel.querySelector('.mcp-stats-dist-donut-unit');
|
||||||
|
if (!label || !value) return;
|
||||||
|
label.textContent = panel.getAttribute('data-center-label') || '';
|
||||||
|
label.classList.add('is-default');
|
||||||
|
const centerVal = panel.getAttribute('data-center-value') || '';
|
||||||
|
const numEl = panel.querySelector('.mcp-stats-dist-donut-value-num');
|
||||||
|
if (numEl) numEl.textContent = centerVal;
|
||||||
|
else value.textContent = centerVal;
|
||||||
|
if (unit) {
|
||||||
|
unit.textContent = panel.getAttribute('data-center-suffix') || '%';
|
||||||
|
unit.hidden = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function previewMcpStatsDistCenter(panel, toolName, pct) {
|
||||||
|
if (!panel) return;
|
||||||
|
const label = panel.querySelector('.mcp-stats-dist-donut-label');
|
||||||
|
const value = panel.querySelector('.mcp-stats-dist-donut-value');
|
||||||
|
const unit = panel.querySelector('.mcp-stats-dist-donut-unit');
|
||||||
|
if (!label || !value) return;
|
||||||
|
const shortName = toolName.length > 14 ? `${toolName.slice(0, 13)}…` : toolName;
|
||||||
|
label.textContent = shortName;
|
||||||
|
label.classList.remove('is-default');
|
||||||
|
const numEl = panel.querySelector('.mcp-stats-dist-donut-value-num');
|
||||||
|
if (numEl) numEl.textContent = pct;
|
||||||
|
else value.textContent = pct;
|
||||||
|
if (unit) unit.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMcpStatsDistHover(toolName) {
|
||||||
|
const panel = document.querySelector('.mcp-stats-dist-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(toolName) : toolName.replace(/"/g, '\\"');
|
||||||
|
panel.querySelectorAll('.mcp-stats-dist-segment, .mcp-stats-dist-legend-item').forEach((el) => {
|
||||||
|
const t = el.getAttribute('data-tool-name') || '';
|
||||||
|
const match = toolName && t === toolName;
|
||||||
|
el.classList.toggle('is-highlighted', !!match);
|
||||||
|
el.classList.toggle('is-dimmed', !!toolName && !match && t);
|
||||||
|
});
|
||||||
|
if (toolName) {
|
||||||
|
const el = panel.querySelector(`[data-tool-name="${esc}"]`);
|
||||||
|
if (el) {
|
||||||
|
previewMcpStatsDistCenter(panel, toolName, el.getAttribute('data-pct') || '');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
resetMcpStatsDistCenter(panel);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMonitorStatsToolFilter(toolName) {
|
||||||
|
if (!toolName) return;
|
||||||
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
|
if (toolFilter && toolFilter.value === toolName) {
|
||||||
|
clearMonitorToolFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
filterMonitorByTool(toolName);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMcpStatsInsightPanel(topTools, totals, activeToolFilter = '') {
|
||||||
|
const distTitle = mcpMonitorT('distTitle') || '调用分布';
|
||||||
|
const distLegend = mcpMonitorT('distLegend') || '扇区面积为占全部调用比例';
|
||||||
|
const distClickHint = mcpMonitorT('distClickHint') || '点击图例或扇区筛选执行记录';
|
||||||
|
const distOthersTitle = mcpMonitorT('distOthersNoFilter') || '其他工具无法单独筛选';
|
||||||
|
const top6ShareLabel = mcpMonitorT('distTop6Share', { n: MCP_STATS_TOP_N }) || `Top ${MCP_STATS_TOP_N} 占全部调用`;
|
||||||
|
const othersLabel = mcpMonitorT('distOthers') || '其他工具';
|
||||||
|
const callsUnit = (n) => mcpMonitorT('distCallsUnit', { n }) || `${n} 次`;
|
||||||
|
|
||||||
|
const top6Total = topTools.reduce((s, t) => s + (t.totalCalls || 0), 0);
|
||||||
|
const top6SharePct = totals.total > 0 ? ((top6Total / totals.total) * 100).toFixed(1) : '0.0';
|
||||||
|
const otherCalls = Math.max(0, totals.total - top6Total);
|
||||||
|
|
||||||
|
let acc = 0;
|
||||||
|
const segments = [];
|
||||||
|
topTools.forEach((tool, i) => {
|
||||||
|
const calls = tool.totalCalls || 0;
|
||||||
|
if (calls <= 0 || totals.total <= 0) return;
|
||||||
|
const pct = (calls / totals.total) * 100;
|
||||||
|
segments.push({
|
||||||
|
color: MCP_STATS_DIST_COLORS[i % MCP_STATS_DIST_COLORS.length],
|
||||||
|
start: acc,
|
||||||
|
end: acc + pct,
|
||||||
|
name: tool.toolName || '',
|
||||||
|
calls,
|
||||||
|
pct: pct.toFixed(1),
|
||||||
|
isOthers: false,
|
||||||
|
});
|
||||||
|
acc += pct;
|
||||||
|
});
|
||||||
|
if (otherCalls > 0 && totals.total > 0) {
|
||||||
|
const pct = (otherCalls / totals.total) * 100;
|
||||||
|
segments.push({
|
||||||
|
color: '#cbd5e1',
|
||||||
|
start: acc,
|
||||||
|
end: acc + pct,
|
||||||
|
name: othersLabel,
|
||||||
|
calls: otherCalls,
|
||||||
|
pct: pct.toFixed(1),
|
||||||
|
isOthers: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const segmentPathsHtml = segments.map((s) => {
|
||||||
|
const pathD = mcpStatsDescribeDonutSegment(s.start, s.end, 48, 30);
|
||||||
|
if (!pathD) return '';
|
||||||
|
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
|
||||||
|
const segAria = s.isOthers
|
||||||
|
? escapeHtml(s.name)
|
||||||
|
: escapeHtml(mcpMonitorT('distSegmentAria', { name: s.name, pct: s.pct, calls: s.calls })
|
||||||
|
|| `${s.name},占 ${s.pct}%,${s.calls} 次`);
|
||||||
|
return `<path class="mcp-stats-dist-segment${isActive ? ' is-active' : ''}${s.isOthers ? ' is-others' : ''}"
|
||||||
|
d="${pathD}"
|
||||||
|
fill="${s.color}"
|
||||||
|
data-tool-name="${s.isOthers ? '' : escapeHtml(s.name)}"
|
||||||
|
data-pct="${s.pct}"
|
||||||
|
data-calls="${s.calls}"
|
||||||
|
data-is-others="${s.isOthers ? '1' : '0'}"
|
||||||
|
tabindex="${s.isOthers ? '-1' : '0'}"
|
||||||
|
role="${s.isOthers ? 'presentation' : 'button'}"
|
||||||
|
aria-label="${segAria}" />`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const legendHtml = segments.map((s) => {
|
||||||
|
const isActive = !s.isOthers && activeToolFilter && activeToolFilter === s.name;
|
||||||
|
const inner = `
|
||||||
|
<span class="mcp-stats-dist-swatch" style="--swatch-color:${s.color}"></span>
|
||||||
|
<span class="mcp-stats-dist-legend-name" title="${escapeHtml(s.name)}">${escapeHtml(s.name)}</span>
|
||||||
|
<span class="mcp-stats-dist-legend-meta"><em>${s.pct}%</em><span>${escapeHtml(callsUnit(s.calls))}</span></span>`;
|
||||||
|
if (s.isOthers) {
|
||||||
|
return `<li class="mcp-stats-dist-legend-item is-others" title="${escapeHtml(distOthersTitle)}" data-is-others="1">${inner}</li>`;
|
||||||
|
}
|
||||||
|
const rowAria = mcpMonitorT('toolRowAriaLabel', { name: s.name, total: s.calls, rate: s.pct })
|
||||||
|
|| `${s.name},${s.calls} 次调用,占 ${s.pct}%`;
|
||||||
|
return `<li class="mcp-stats-dist-legend-item-wrap">
|
||||||
|
<button type="button" class="mcp-stats-dist-legend-item${isActive ? ' is-active' : ''}"
|
||||||
|
data-tool-name="${escapeHtml(s.name)}"
|
||||||
|
data-pct="${s.pct}"
|
||||||
|
data-calls="${s.calls}"
|
||||||
|
data-is-others="0"
|
||||||
|
aria-label="${escapeHtml(rowAria)}"
|
||||||
|
aria-pressed="${isActive ? 'true' : 'false'}">${inner}</button>
|
||||||
|
</li>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
const centerLabel = `Top ${MCP_STATS_TOP_N}`;
|
||||||
|
const distHint = totals.total > 0
|
||||||
|
? (mcpMonitorT('distTotalCalls', { n: totals.total }) || `共 ${totals.total} 次调用`)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="mcp-stats-tools-panel mcp-stats-dist-panel" aria-label="${escapeHtml(distTitle)}"
|
||||||
|
data-center-label="${escapeHtml(centerLabel)}"
|
||||||
|
data-center-value="${top6SharePct}"
|
||||||
|
data-center-suffix="%">
|
||||||
|
<div class="mcp-stats-tools-header">
|
||||||
|
<div class="mcp-stats-tools-heading">
|
||||||
|
<h4 class="mcp-stats-tools-title">${escapeHtml(distTitle)}</h4>
|
||||||
|
<span class="mcp-stats-tools-legend">${escapeHtml(distLegend)} · ${escapeHtml(distClickHint)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="mcp-stats-tools-hint">${escapeHtml(distHint)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-dist-body mcp-stats-dist-body--stacked">
|
||||||
|
<div class="mcp-stats-dist-chart-stage">
|
||||||
|
<div class="mcp-stats-dist-chart-wrap">
|
||||||
|
<svg class="mcp-stats-dist-svg" viewBox="0 0 100 100" role="img" aria-label="${escapeHtml(top6ShareLabel)} ${top6SharePct}%">
|
||||||
|
<g class="mcp-stats-dist-segments">${segmentPathsHtml}</g>
|
||||||
|
</svg>
|
||||||
|
<div class="mcp-stats-dist-donut-hole" aria-hidden="true">
|
||||||
|
<span class="mcp-stats-dist-donut-label is-default">${centerLabel}</span>
|
||||||
|
<span class="mcp-stats-dist-donut-value"><span class="mcp-stats-dist-donut-value-num">${top6SharePct}</span><span class="mcp-stats-dist-donut-unit">%</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<ul class="mcp-stats-dist-legend mcp-stats-dist-legend--grid">${legendHtml}</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function renderMcpStatsStackedBar(success, failed) {
|
||||||
|
const total = success + failed;
|
||||||
|
if (total <= 0) {
|
||||||
|
return '<div class="mcp-stats-stacked-bar" role="presentation"><div class="mcp-stats-stacked-bar-seg mcp-stats-stacked-bar-seg--success" style="flex:1"></div></div>';
|
||||||
|
}
|
||||||
|
const successFlex = Math.max(0, (success / total) * 100);
|
||||||
|
const failFlex = Math.max(0, (failed / total) * 100);
|
||||||
|
return `<div class="mcp-stats-stacked-bar" role="presentation">
|
||||||
|
<div class="mcp-stats-stacked-bar-seg mcp-stats-stacked-bar-seg--success" style="flex:${successFlex}"></div>
|
||||||
|
<div class="mcp-stats-stacked-bar-seg mcp-stats-stacked-bar-seg--fail" style="flex:${failFlex}"></div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMonitorStatsSubtitle(lastFetchedAt, toolCount) {
|
||||||
|
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||||
|
if (!subtitle) return;
|
||||||
|
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||||
|
const timeText = lastFetchedAt
|
||||||
|
? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale) : String(lastFetchedAt))
|
||||||
|
: '—';
|
||||||
|
const text = mcpMonitorT('statsSubtitle', { time: timeText, count: toolCount })
|
||||||
|
|| `最后刷新 ${timeText} · 共 ${toolCount} 个工具`;
|
||||||
|
subtitle.textContent = text;
|
||||||
|
subtitle.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMonitorByTool(toolName) {
|
||||||
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
|
if (!toolFilter || !toolName) return;
|
||||||
|
toolFilter.value = toolName;
|
||||||
|
toolFilter.classList.add('is-filter-active');
|
||||||
|
applyMonitorFilters();
|
||||||
|
const execSection = document.querySelector('.monitor-executions');
|
||||||
|
if (execSection && typeof execSection.scrollIntoView === 'function') {
|
||||||
|
execSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearMonitorToolFilter() {
|
||||||
|
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||||
|
if (!toolFilter) return;
|
||||||
|
toolFilter.value = '';
|
||||||
|
toolFilter.classList.remove('is-filter-active');
|
||||||
|
applyMonitorFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
let monitorStatsPanelEventsBound = false;
|
||||||
|
|
||||||
|
function bindMonitorStatsPanelEvents() {
|
||||||
|
if (monitorStatsPanelEventsBound) return;
|
||||||
|
const root = document.getElementById('monitor-stats');
|
||||||
|
if (!root) return;
|
||||||
|
root.addEventListener('click', function (e) {
|
||||||
|
const clearBtn = e.target.closest('.mcp-stats-clear-filter');
|
||||||
|
if (clearBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
clearMonitorToolFilter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const distEl = e.target.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]');
|
||||||
|
if (distEl && distEl.getAttribute('data-is-others') !== '1') {
|
||||||
|
const tool = distEl.getAttribute('data-tool-name');
|
||||||
|
if (tool) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMonitorStatsToolFilter(tool);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const row = e.target.closest('.mcp-stats-tool-row');
|
||||||
|
if (!row) return;
|
||||||
|
const tool = row.getAttribute('data-tool-name');
|
||||||
|
if (tool) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMonitorStatsToolFilter(tool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key !== 'Enter' && e.key !== ' ') return;
|
||||||
|
const distSeg = e.target.closest('.mcp-stats-dist-segment[data-tool-name]');
|
||||||
|
if (!distSeg || distSeg.getAttribute('data-is-others') === '1') return;
|
||||||
|
const tool = distSeg.getAttribute('data-tool-name');
|
||||||
|
if (tool) {
|
||||||
|
e.preventDefault();
|
||||||
|
handleMonitorStatsToolFilter(tool);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
root.addEventListener('mouseover', function (e) {
|
||||||
|
const el = e.target.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]');
|
||||||
|
if (!el || el.getAttribute('data-is-others') === '1') return;
|
||||||
|
const tool = el.getAttribute('data-tool-name');
|
||||||
|
if (tool) setMcpStatsDistHover(tool);
|
||||||
|
});
|
||||||
|
root.addEventListener('mouseout', function (e) {
|
||||||
|
const el = e.target.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]');
|
||||||
|
if (!el) return;
|
||||||
|
const related = e.relatedTarget;
|
||||||
|
const next = related && related.closest
|
||||||
|
? related.closest('.mcp-stats-dist-segment[data-tool-name], .mcp-stats-dist-legend-item[data-tool-name]')
|
||||||
|
: null;
|
||||||
|
if (next) return;
|
||||||
|
setMcpStatsDistHover('');
|
||||||
|
});
|
||||||
|
monitorStatsPanelEventsBound = true;
|
||||||
|
}
|
||||||
|
|
||||||
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||||
const container = document.getElementById('monitor-stats');
|
const container = document.getElementById('monitor-stats');
|
||||||
if (!container) {
|
if (!container) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.values(statsMap);
|
const entries = normalizeMonitorStatsEntries(statsMap);
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
const noStats = typeof window.t === 'function' ? window.t('mcpMonitor.noStatsData') : '暂无统计数据';
|
const noStats = mcpMonitorT('noStatsData') || '暂无统计数据';
|
||||||
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
container.innerHTML = '<div class="monitor-empty">' + escapeHtml(noStats) + '</div>';
|
||||||
|
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||||
|
if (subtitle) subtitle.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算总体汇总
|
|
||||||
const totals = entries.reduce(
|
const totals = entries.reduce(
|
||||||
(acc, item) => {
|
(acc, item) => {
|
||||||
acc.total += item.totalCalls || 0;
|
acc.total += item.totalCalls || 0;
|
||||||
@@ -3071,59 +3466,154 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
|||||||
{ total: 0, success: 0, failed: 0, lastCallTime: null }
|
{ total: 0, success: 0, failed: 0, lastCallTime: null }
|
||||||
);
|
);
|
||||||
|
|
||||||
const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0';
|
const successRateNum = totals.total > 0 ? (totals.success / totals.total) * 100 : 0;
|
||||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
const successRate = successRateNum.toFixed(1);
|
||||||
const lastUpdatedText = lastFetchedAt ? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale || 'en-US') : String(lastFetchedAt)) : 'N/A';
|
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||||
const noCallsYet = typeof window.t === 'function' ? window.t('mcpMonitor.noCallsYet') : '暂无调用';
|
const noCallsYet = mcpMonitorT('noCallsYet') || '暂无调用';
|
||||||
const lastCallText = totals.lastCallTime ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale || 'en-US') : String(totals.lastCallTime)) : noCallsYet;
|
const lastCallText = totals.lastCallTime
|
||||||
const totalCallsLabel = typeof window.t === 'function' ? window.t('mcpMonitor.totalCalls') : '总调用次数';
|
? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale) : String(totals.lastCallTime))
|
||||||
const successFailedLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successFailed', { success: totals.success, failed: totals.failed }) : `成功 ${totals.success} / 失败 ${totals.failed}`;
|
: noCallsYet;
|
||||||
const successRateLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successRate') : '成功率';
|
|
||||||
const statsFromAll = typeof window.t === 'function' ? window.t('mcpMonitor.statsFromAllTools') : '统计自全部工具调用';
|
|
||||||
const lastCallLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastCall') : '最近一次调用';
|
|
||||||
const lastRefreshLabel = typeof window.t === 'function' ? window.t('mcpMonitor.lastRefreshTime') : '最后刷新时间';
|
|
||||||
|
|
||||||
let html = `
|
const totalCallsLabel = mcpMonitorT('totalCalls') || '总调用次数';
|
||||||
<div class="monitor-stat-card">
|
const successRateLabel = mcpMonitorT('successRate') || '成功率';
|
||||||
<h4>${escapeHtml(totalCallsLabel)}</h4>
|
const lastCallLabel = mcpMonitorT('lastCall') || '最近一次调用';
|
||||||
<div class="monitor-stat-value">${totals.total}</div>
|
const statsFromAll = mcpMonitorT('statsFromAllTools') || '统计自全部工具调用';
|
||||||
<div class="monitor-stat-meta">${escapeHtml(successFailedLabel)}</div>
|
const successPill = mcpMonitorT('successCount', { n: totals.success }) || `成功 ${totals.success}`;
|
||||||
</div>
|
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || `失败 ${totals.failed}`;
|
||||||
<div class="monitor-stat-card">
|
const rateTone = getMcpStatsRateTone(successRateNum);
|
||||||
<h4>${escapeHtml(successRateLabel)}</h4>
|
let rateSubText = mcpMonitorT('rateHealthy') || '运行平稳';
|
||||||
<div class="monitor-stat-value">${successRate}%</div>
|
if (successRateNum < 80) rateSubText = mcpMonitorT('rateCritical') || '失败率偏高';
|
||||||
<div class="monitor-stat-meta">${escapeHtml(statsFromAll)}</div>
|
else if (successRateNum < 95) rateSubText = mcpMonitorT('rateWarning') || '存在失败调用';
|
||||||
</div>
|
|
||||||
<div class="monitor-stat-card">
|
const toolFilterEl = document.getElementById('monitor-tool-filter');
|
||||||
<h4>${escapeHtml(lastCallLabel)}</h4>
|
const activeToolFilter = toolFilterEl ? toolFilterEl.value.trim() : '';
|
||||||
<div class="monitor-stat-value" style="font-size:1rem;">${escapeHtml(lastCallText)}</div>
|
|
||||||
<div class="monitor-stat-meta">${escapeHtml(lastRefreshLabel)}:${escapeHtml(lastUpdatedText)}</div>
|
|
||||||
</div>
|
|
||||||
`;
|
|
||||||
|
|
||||||
// 显示最多前4个工具的统计(过滤掉 totalCalls 为 0 的工具)
|
|
||||||
const topTools = entries
|
const topTools = entries
|
||||||
.filter(tool => (tool.totalCalls || 0) > 0)
|
.filter(tool => (tool.totalCalls || 0) > 0)
|
||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
.sort((a, b) => (b.totalCalls || 0) - (a.totalCalls || 0))
|
||||||
.slice(0, 4);
|
.slice(0, MCP_STATS_TOP_N);
|
||||||
|
|
||||||
const unknownToolLabel = typeof window.t === 'function' ? window.t('mcpMonitor.unknownTool') : '未知工具';
|
const maxToolCalls = topTools.length > 0 ? (topTools[0].totalCalls || 0) : 0;
|
||||||
topTools.forEach(tool => {
|
const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具';
|
||||||
const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0';
|
const topToolsTitle = mcpMonitorT('topToolsTitle', { n: MCP_STATS_TOP_N }) || `工具调用 Top ${MCP_STATS_TOP_N}`;
|
||||||
const toolMeta = typeof window.t === 'function' ? window.t('mcpMonitor.successFailedRate', { success: tool.successCalls || 0, failed: tool.failedCalls || 0, rate: toolSuccessRate }) : `成功 ${tool.successCalls || 0} / 失败 ${tool.failedCalls || 0} · 成功率 ${toolSuccessRate}%`;
|
const toolsHint = mcpMonitorT('clickToFilterTool') || '点击行筛选下方执行记录';
|
||||||
html += `
|
const barLegend = mcpMonitorT('barVolumeLegend') || '条长表示相对调用量';
|
||||||
<div class="monitor-stat-card">
|
const successRateAria = mcpMonitorT('successRateAria', { rate: successRate }) || `成功率 ${successRate}%`;
|
||||||
<h4>${escapeHtml(tool.toolName || unknownToolLabel)}</h4>
|
|
||||||
<div class="monitor-stat-value">${tool.totalCalls || 0}</div>
|
const iconCalls = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
|
||||||
<div class="monitor-stat-meta">
|
const iconRate = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>';
|
||||||
${escapeHtml(toolMeta)}
|
const iconTime = '<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
|
||||||
</div>
|
|
||||||
</div>
|
let toolRowsHtml = '';
|
||||||
|
topTools.forEach((tool, index) => {
|
||||||
|
const name = tool.toolName || unknownToolLabel;
|
||||||
|
const total = tool.totalCalls || 0;
|
||||||
|
const success = tool.successCalls || 0;
|
||||||
|
const failed = tool.failedCalls || 0;
|
||||||
|
const toolRateNum = total > 0 ? (success / total) * 100 : 0;
|
||||||
|
const toolRate = toolRateNum.toFixed(1);
|
||||||
|
const isActive = activeToolFilter && activeToolFilter === name;
|
||||||
|
const rowAria = mcpMonitorT('toolRowAriaLabel', { name, total, rate: toolRate })
|
||||||
|
|| `${name},${total} 次调用,成功率 ${toolRate}%`;
|
||||||
|
const rateClass = getMcpToolRateClass(toolRateNum);
|
||||||
|
toolRowsHtml += `
|
||||||
|
<li class="mcp-stats-tool-item">
|
||||||
|
<button type="button" class="mcp-stats-tool-row${isActive ? ' is-active' : ''}"
|
||||||
|
data-tool-name="${escapeHtml(name)}"
|
||||||
|
aria-label="${escapeHtml(rowAria)}"
|
||||||
|
aria-pressed="${isActive ? 'true' : 'false'}">
|
||||||
|
<span class="mcp-stats-tool-rank">${index + 1}</span>
|
||||||
|
<div class="mcp-stats-tool-main">
|
||||||
|
<div class="mcp-stats-tool-top">
|
||||||
|
<span class="mcp-stats-tool-name" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
|
||||||
|
<span class="mcp-stats-tool-metrics">
|
||||||
|
<span class="mcp-stats-tool-count">${total}</span>
|
||||||
|
<span aria-hidden="true">·</span>
|
||||||
|
<span class="mcp-stats-tool-rate ${rateClass}">${toolRate}%</span>
|
||||||
|
${failed > 0 ? `<span class="mcp-stats-tool-fail-badge">${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}</span>` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
${renderMcpStatsToolVolumeBar(total, success, failed, maxToolCalls)}
|
||||||
|
</div>
|
||||||
|
${MCP_STATS_TOOL_CHEVRON}
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
`;
|
`;
|
||||||
});
|
});
|
||||||
|
|
||||||
container.innerHTML = `<div class="monitor-stats-grid">${html}</div>`;
|
const clearFilterBtn = activeToolFilter
|
||||||
|
? `<button type="button" class="mcp-stats-clear-filter">${escapeHtml(mcpMonitorT('clearToolFilter') || '清除工具筛选')}</button>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const html = `
|
||||||
|
<div class="mcp-exec-stats">
|
||||||
|
<div class="mcp-stats-kpi-row">
|
||||||
|
<article class="mcp-stats-kpi-card mcp-stats-kpi-card--calls">
|
||||||
|
<div class="mcp-stats-kpi-head">
|
||||||
|
<span class="mcp-stats-kpi-label">${escapeHtml(totalCallsLabel)}</span>
|
||||||
|
<span class="mcp-stats-kpi-icon mcp-stats-kpi-icon--calls" aria-hidden="true">${iconCalls}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-value">${totals.total}</div>
|
||||||
|
${renderMcpStatsStackedBar(totals.success, totals.failed)}
|
||||||
|
<div class="mcp-stats-kpi-sub">
|
||||||
|
<span class="mcp-stats-pill mcp-stats-pill--success">${escapeHtml(successPill)}</span>
|
||||||
|
<span class="mcp-stats-pill mcp-stats-pill--fail">${escapeHtml(failedPill)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="mcp-stats-kpi-card mcp-stats-kpi-card--rate">
|
||||||
|
<div class="mcp-stats-kpi-head">
|
||||||
|
<span class="mcp-stats-kpi-label">${escapeHtml(successRateLabel)}</span>
|
||||||
|
<span class="mcp-stats-kpi-icon mcp-stats-kpi-icon--rate" aria-hidden="true">${iconRate}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-body" role="img" aria-label="${escapeHtml(successRateAria)}">
|
||||||
|
<div class="mcp-stats-kpi-value">${successRate}%</div>
|
||||||
|
${renderMcpStatsSuccessRing(successRate)}
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-sub">
|
||||||
|
<span class="mcp-stats-kpi-sub-text ${rateTone}">${escapeHtml(rateSubText)}</span>
|
||||||
|
<span class="mcp-stats-kpi-sub-text">${escapeHtml(statsFromAll)}</span>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="mcp-stats-kpi-card mcp-stats-kpi-card--time">
|
||||||
|
<div class="mcp-stats-kpi-head">
|
||||||
|
<span class="mcp-stats-kpi-label">${escapeHtml(lastCallLabel)}</span>
|
||||||
|
<span class="mcp-stats-kpi-icon mcp-stats-kpi-icon--time" aria-hidden="true">${iconTime}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-kpi-value mcp-stats-kpi-value--time">${escapeHtml(lastCallText)}</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
${topTools.length > 0 ? `
|
||||||
|
<div class="mcp-stats-split">
|
||||||
|
<div class="mcp-stats-split-left">
|
||||||
|
<div class="mcp-stats-tools-panel">
|
||||||
|
<div class="mcp-stats-tools-header">
|
||||||
|
<div class="mcp-stats-tools-heading">
|
||||||
|
<h4 class="mcp-stats-tools-title">${escapeHtml(topToolsTitle)}</h4>
|
||||||
|
<span class="mcp-stats-tools-legend">${escapeHtml(barLegend)}</span>
|
||||||
|
</div>
|
||||||
|
<span class="mcp-stats-tools-hint">${escapeHtml(toolsHint)}</span>
|
||||||
|
</div>
|
||||||
|
<ol class="mcp-stats-tool-list" aria-label="${escapeHtml(topToolsTitle)}">${toolRowsHtml}</ol>
|
||||||
|
${clearFilterBtn}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="mcp-stats-split-right">
|
||||||
|
${renderMcpStatsInsightPanel(topTools, totals, activeToolFilter)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
` : ''}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
container.innerHTML = html;
|
||||||
|
bindMonitorStatsPanelEvents();
|
||||||
|
if (toolFilterEl && activeToolFilter) {
|
||||||
|
toolFilterEl.classList.add('is-filter-active');
|
||||||
|
} else if (toolFilterEl) {
|
||||||
|
toolFilterEl.classList.remove('is-filter-active');
|
||||||
|
}
|
||||||
|
updateMonitorStatsSubtitle(lastFetchedAt, entries.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
function renderMonitorExecutions(executions = [], statusFilter = 'all') {
|
||||||
@@ -3620,4 +4110,14 @@ document.addEventListener('languagechange', function () {
|
|||||||
updateBatchActionsState();
|
updateBatchActionsState();
|
||||||
loadActiveTasks();
|
loadActiveTasks();
|
||||||
refreshProgressAndTimelineI18n();
|
refreshProgressAndTimelineI18n();
|
||||||
|
if (monitorState.stats && Object.keys(monitorState.stats).length > 0) {
|
||||||
|
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
bindMonitorStatsPanelEvents();
|
||||||
|
});
|
||||||
|
|
||||||
|
window.filterMonitorByTool = filterMonitorByTool;
|
||||||
|
window.clearMonitorToolFilter = clearMonitorToolFilter;
|
||||||
|
|||||||
@@ -1087,6 +1087,7 @@ async function applySettings() {
|
|||||||
|
|
||||||
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
const wecomAgentIdVal = document.getElementById('robot-wecom-agent-id')?.value.trim();
|
||||||
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
|
const prevOpenai = (currentConfig && currentConfig.openai) ? currentConfig.openai : {};
|
||||||
|
const prevRobots = (currentConfig && currentConfig.robots) ? currentConfig.robots : {};
|
||||||
const config = {
|
const config = {
|
||||||
openai: {
|
openai: {
|
||||||
...prevOpenai,
|
...prevOpenai,
|
||||||
@@ -1118,7 +1119,7 @@ async function applySettings() {
|
|||||||
return {
|
return {
|
||||||
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
enabled: document.getElementById('multi-agent-enabled')?.checked === true,
|
||||||
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
robot_use_multi_agent: document.getElementById('multi-agent-robot-use')?.checked === true,
|
||||||
batch_use_multi_agent: false,
|
batch_use_multi_agent: currentConfig?.multi_agent?.batch_use_multi_agent === true,
|
||||||
plan_execute_loop_max_iterations: peLoop
|
plan_execute_loop_max_iterations: peLoop
|
||||||
};
|
};
|
||||||
})(),
|
})(),
|
||||||
@@ -1127,6 +1128,7 @@ async function applySettings() {
|
|||||||
enabled: c2Enabled
|
enabled: c2Enabled
|
||||||
},
|
},
|
||||||
robots: {
|
robots: {
|
||||||
|
...(prevRobots.session && typeof prevRobots.session === 'object' ? { session: prevRobots.session } : {}),
|
||||||
wecom: {
|
wecom: {
|
||||||
enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
|
enabled: document.getElementById('robot-wecom-enabled')?.checked === true,
|
||||||
token: document.getElementById('robot-wecom-token')?.value.trim() || '',
|
token: document.getElementById('robot-wecom-token')?.value.trim() || '',
|
||||||
@@ -1138,13 +1140,15 @@ async function applySettings() {
|
|||||||
dingtalk: {
|
dingtalk: {
|
||||||
enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true,
|
enabled: document.getElementById('robot-dingtalk-enabled')?.checked === true,
|
||||||
client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '',
|
client_id: document.getElementById('robot-dingtalk-client-id')?.value.trim() || '',
|
||||||
client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || ''
|
client_secret: document.getElementById('robot-dingtalk-client-secret')?.value.trim() || '',
|
||||||
|
allow_conversation_id_fallback: !!(prevRobots.dingtalk && prevRobots.dingtalk.allow_conversation_id_fallback)
|
||||||
},
|
},
|
||||||
lark: {
|
lark: {
|
||||||
enabled: document.getElementById('robot-lark-enabled')?.checked === true,
|
enabled: document.getElementById('robot-lark-enabled')?.checked === true,
|
||||||
app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '',
|
app_id: document.getElementById('robot-lark-app-id')?.value.trim() || '',
|
||||||
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
|
app_secret: document.getElementById('robot-lark-app-secret')?.value.trim() || '',
|
||||||
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || ''
|
verify_token: document.getElementById('robot-lark-verify-token')?.value.trim() || '',
|
||||||
|
allow_chat_id_fallback: !!(prevRobots.lark && prevRobots.lark.allow_chat_id_fallback)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
tools: []
|
tools: []
|
||||||
|
|||||||
+533
-34
@@ -61,6 +61,24 @@ let vulnerabilityPagination = {
|
|||||||
totalPages: 1
|
totalPages: 1
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const VULN_STAT_SEVERITIES = ['critical', 'high', 'medium', 'low', 'info'];
|
||||||
|
let vulnerabilityStatCardsBound = false;
|
||||||
|
let vulnerabilityFilterPanelBound = false;
|
||||||
|
let vulnerabilityFilterOptionsCache = null;
|
||||||
|
const VULNERABILITY_ADVANCED_OPEN_KEY = 'vulnerabilityAdvancedFiltersOpen';
|
||||||
|
const VULNERABILITY_DATALIST_MAX = 8;
|
||||||
|
const VULNERABILITY_DATALIST_MIN_QUERY = 2;
|
||||||
|
|
||||||
|
const VULN_FILTER_CHIP_FIELDS = [
|
||||||
|
{ key: 'id', labelKey: 'vulnerabilityPage.vulnId' },
|
||||||
|
{ key: 'status', labelKey: null, format: 'status' },
|
||||||
|
{ key: 'severity', labelKey: null, format: 'severity' },
|
||||||
|
{ key: 'conversation_id', labelKey: 'vulnerabilityPage.conversationId' },
|
||||||
|
{ key: 'task_id', labelKey: 'vulnerabilityPage.taskOrQueueId' },
|
||||||
|
{ key: 'conversation_tag', labelKey: 'vulnerabilityPage.conversationTag' },
|
||||||
|
{ key: 'task_tag', labelKey: 'vulnerabilityPage.taskTag' }
|
||||||
|
];
|
||||||
|
|
||||||
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动)
|
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动)
|
||||||
function syncVulnerabilityFiltersFromLocationHash() {
|
function syncVulnerabilityFiltersFromLocationHash() {
|
||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
@@ -72,19 +90,35 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
|||||||
const vid = (params.get('id') || '').trim();
|
const vid = (params.get('id') || '').trim();
|
||||||
const cid = (params.get('conversation_id') || '').trim();
|
const cid = (params.get('conversation_id') || '').trim();
|
||||||
const tid = (params.get('task_id') || '').trim();
|
const tid = (params.get('task_id') || '').trim();
|
||||||
if (!vid && !cid && !tid) {
|
const sev = (params.get('severity') || '').trim();
|
||||||
|
const st = (params.get('status') || '').trim();
|
||||||
|
const convTag = (params.get('conversation_tag') || '').trim();
|
||||||
|
const taskTag = (params.get('task_tag') || '').trim();
|
||||||
|
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
vulnerabilityFilters.id = '';
|
vulnerabilityFilters.id = '';
|
||||||
vulnerabilityFilters.conversation_id = '';
|
vulnerabilityFilters.conversation_id = '';
|
||||||
vulnerabilityFilters.task_id = '';
|
vulnerabilityFilters.task_id = '';
|
||||||
|
vulnerabilityFilters.conversation_tag = '';
|
||||||
|
vulnerabilityFilters.task_tag = '';
|
||||||
|
vulnerabilityFilters.severity = '';
|
||||||
|
vulnerabilityFilters.status = '';
|
||||||
const idEl = document.getElementById('vulnerability-id-filter');
|
const idEl = document.getElementById('vulnerability-id-filter');
|
||||||
const convEl = document.getElementById('vulnerability-conversation-filter');
|
const convEl = document.getElementById('vulnerability-conversation-filter');
|
||||||
const taskEl = document.getElementById('vulnerability-task-filter');
|
const taskEl = document.getElementById('vulnerability-task-filter');
|
||||||
|
const convTagEl = document.getElementById('vulnerability-conversation-tag-filter');
|
||||||
|
const taskTagEl = document.getElementById('vulnerability-task-tag-filter');
|
||||||
|
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||||||
|
const stEl = document.getElementById('vulnerability-status-filter');
|
||||||
if (idEl) idEl.value = '';
|
if (idEl) idEl.value = '';
|
||||||
if (convEl) convEl.value = '';
|
if (convEl) convEl.value = '';
|
||||||
if (taskEl) taskEl.value = '';
|
if (taskEl) taskEl.value = '';
|
||||||
|
if (convTagEl) convTagEl.value = '';
|
||||||
|
if (taskTagEl) taskTagEl.value = '';
|
||||||
|
if (sevEl) sevEl.value = '';
|
||||||
|
if (stEl) stEl.value = '';
|
||||||
|
|
||||||
if (vid) {
|
if (vid) {
|
||||||
vulnerabilityFilters.id = vid;
|
vulnerabilityFilters.id = vid;
|
||||||
@@ -98,18 +132,474 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
|||||||
vulnerabilityFilters.task_id = tid;
|
vulnerabilityFilters.task_id = tid;
|
||||||
if (taskEl) taskEl.value = tid;
|
if (taskEl) taskEl.value = tid;
|
||||||
}
|
}
|
||||||
|
if (convTag) {
|
||||||
|
vulnerabilityFilters.conversation_tag = convTag;
|
||||||
|
if (convTagEl) convTagEl.value = convTag;
|
||||||
|
}
|
||||||
|
if (taskTag) {
|
||||||
|
vulnerabilityFilters.task_tag = taskTag;
|
||||||
|
if (taskTagEl) taskTagEl.value = taskTag;
|
||||||
|
}
|
||||||
|
if (sev) {
|
||||||
|
vulnerabilityFilters.severity = sev;
|
||||||
|
if (sevEl) sevEl.value = sev;
|
||||||
|
}
|
||||||
|
if (st) {
|
||||||
|
vulnerabilityFilters.status = st;
|
||||||
|
if (stEl) stEl.value = st;
|
||||||
|
}
|
||||||
vulnerabilityPagination.currentPage = 1;
|
vulnerabilityPagination.currentPage = 1;
|
||||||
|
if (hasVulnerabilityAdvancedFiltersActive()) {
|
||||||
|
setVulnerabilityAdvancedFiltersOpen(true, false);
|
||||||
|
}
|
||||||
|
syncVulnerabilityStatCardActiveState();
|
||||||
|
updateVulnerabilityFilterPanelState();
|
||||||
|
renderVulnerabilityFilterChips();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 初始化漏洞管理页面
|
// 初始化漏洞管理页面
|
||||||
function initVulnerabilityPage() {
|
function initVulnerabilityPage() {
|
||||||
// 从localStorage加载每页条数设置
|
// 从localStorage加载每页条数设置
|
||||||
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
|
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
|
||||||
|
initVulnerabilityStatCards();
|
||||||
|
initVulnerabilityFilterPanel();
|
||||||
syncVulnerabilityFiltersFromLocationHash();
|
syncVulnerabilityFiltersFromLocationHash();
|
||||||
|
updateVulnerabilityFilterPanelState();
|
||||||
|
renderVulnerabilityFilterChips();
|
||||||
|
loadVulnerabilityFilterOptions();
|
||||||
loadVulnerabilityStats();
|
loadVulnerabilityStats();
|
||||||
loadVulnerabilities();
|
loadVulnerabilities();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initVulnerabilityStatCards() {
|
||||||
|
if (vulnerabilityStatCardsBound) {
|
||||||
|
syncVulnerabilityStatCardActiveState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const root = document.getElementById('vulnerability-stat-cards');
|
||||||
|
if (!root) return;
|
||||||
|
vulnerabilityStatCardsBound = true;
|
||||||
|
root.addEventListener('click', onVulnerabilityStatCardClick);
|
||||||
|
root.addEventListener('keydown', onVulnerabilityStatCardKeydown);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVulnerabilityStatCardClick(ev) {
|
||||||
|
const totalCard = ev.target.closest('.stat-card.stat-card-total');
|
||||||
|
if (totalCard) {
|
||||||
|
applyVulnerabilitySeverityFilter('');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const card = ev.target.closest('.stat-card.is-clickable[data-severity]');
|
||||||
|
if (!card) return;
|
||||||
|
const sev = card.getAttribute('data-severity');
|
||||||
|
if (!sev) return;
|
||||||
|
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||||||
|
const current = sevEl ? sevEl.value : vulnerabilityFilters.severity;
|
||||||
|
applyVulnerabilitySeverityFilter(current === sev ? '' : sev);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onVulnerabilityStatCardKeydown(ev) {
|
||||||
|
if (ev.key !== 'Enter' && ev.key !== ' ') return;
|
||||||
|
const card = ev.target.closest('.stat-card.is-clickable');
|
||||||
|
if (!card || !card.contains(ev.target)) return;
|
||||||
|
ev.preventDefault();
|
||||||
|
card.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVulnerabilitySeverityFilter(severity) {
|
||||||
|
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||||||
|
if (sevEl) sevEl.value = severity || '';
|
||||||
|
applyVulnerabilityFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
function readVulnerabilityFiltersFromForm() {
|
||||||
|
vulnerabilityFilters.id = (document.getElementById('vulnerability-id-filter')?.value || '').trim();
|
||||||
|
vulnerabilityFilters.conversation_id = (document.getElementById('vulnerability-conversation-filter')?.value || '').trim();
|
||||||
|
vulnerabilityFilters.task_id = (document.getElementById('vulnerability-task-filter')?.value || '').trim();
|
||||||
|
vulnerabilityFilters.conversation_tag = (document.getElementById('vulnerability-conversation-tag-filter')?.value || '').trim();
|
||||||
|
vulnerabilityFilters.task_tag = (document.getElementById('vulnerability-task-tag-filter')?.value || '').trim();
|
||||||
|
vulnerabilityFilters.severity = document.getElementById('vulnerability-severity-filter')?.value || '';
|
||||||
|
vulnerabilityFilters.status = document.getElementById('vulnerability-status-filter')?.value || '';
|
||||||
|
return vulnerabilityFilters;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVulnerabilityAdvancedFiltersActive() {
|
||||||
|
const f = vulnerabilityFilters;
|
||||||
|
return Boolean(f.conversation_id || f.task_id || f.conversation_tag || f.task_tag);
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasAnyVulnerabilityFilterActive() {
|
||||||
|
const f = vulnerabilityFilters;
|
||||||
|
return Boolean(
|
||||||
|
f.id || f.conversation_id || f.task_id || f.conversation_tag || f.task_tag || f.severity || f.status
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyVulnerabilityFilters() {
|
||||||
|
readVulnerabilityFiltersFromForm();
|
||||||
|
vulnerabilityPagination.currentPage = 1;
|
||||||
|
syncVulnerabilityStatCardActiveState();
|
||||||
|
updateVulnerabilityLocationHashFromFilters();
|
||||||
|
updateVulnerabilityFilterPanelState();
|
||||||
|
renderVulnerabilityFilterChips();
|
||||||
|
loadVulnerabilityStats();
|
||||||
|
loadVulnerabilities();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVulnerabilityLocationHashFromFilters() {
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
const hashParts = hash.split('?');
|
||||||
|
if (hashParts[0] !== 'vulnerabilities') return;
|
||||||
|
const params = new URLSearchParams(hashParts.length >= 2 ? hashParts.slice(1).join('?') : '');
|
||||||
|
const f = vulnerabilityFilters;
|
||||||
|
const pairs = [
|
||||||
|
['id', f.id],
|
||||||
|
['conversation_id', f.conversation_id],
|
||||||
|
['task_id', f.task_id],
|
||||||
|
['conversation_tag', f.conversation_tag],
|
||||||
|
['task_tag', f.task_tag],
|
||||||
|
['severity', f.severity],
|
||||||
|
['status', f.status]
|
||||||
|
];
|
||||||
|
pairs.forEach(function (pair) {
|
||||||
|
if (pair[1]) {
|
||||||
|
params.set(pair[0], pair[1]);
|
||||||
|
} else {
|
||||||
|
params.delete(pair[0]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
const qs = params.toString();
|
||||||
|
const newHash = qs ? 'vulnerabilities?' + qs : 'vulnerabilities';
|
||||||
|
if (window.location.hash.slice(1) === newHash) return;
|
||||||
|
const newFull = '#' + newHash;
|
||||||
|
if (typeof history.replaceState === 'function') {
|
||||||
|
history.replaceState(null, '', window.location.pathname + window.location.search + newFull);
|
||||||
|
} else {
|
||||||
|
window.location.hash = newHash;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleVulnerabilityAdvancedFilters(ev) {
|
||||||
|
if (ev) {
|
||||||
|
ev.preventDefault();
|
||||||
|
ev.stopPropagation();
|
||||||
|
}
|
||||||
|
const toggleBtn = document.getElementById('vulnerability-advanced-toggle');
|
||||||
|
if (!toggleBtn) return;
|
||||||
|
const expanded = toggleBtn.getAttribute('aria-expanded') === 'true';
|
||||||
|
setVulnerabilityAdvancedFiltersOpen(!expanded, true);
|
||||||
|
}
|
||||||
|
window.toggleVulnerabilityAdvancedFilters = toggleVulnerabilityAdvancedFilters;
|
||||||
|
|
||||||
|
function initVulnerabilityFilterPanel() {
|
||||||
|
const panel = document.getElementById('vulnerability-filter-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
|
||||||
|
if (vulnerabilityFilterPanelBound) {
|
||||||
|
updateVulnerabilityFilterPanelState();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
vulnerabilityFilterPanelBound = true;
|
||||||
|
|
||||||
|
let savedOpen = false;
|
||||||
|
try {
|
||||||
|
savedOpen = localStorage.getItem(VULNERABILITY_ADVANCED_OPEN_KEY) === 'true';
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
setVulnerabilityAdvancedFiltersOpen(savedOpen, false);
|
||||||
|
|
||||||
|
const stEl = document.getElementById('vulnerability-status-filter');
|
||||||
|
if (stEl) stEl.addEventListener('change', applyVulnerabilityFilters);
|
||||||
|
|
||||||
|
const textIds = [
|
||||||
|
'vulnerability-id-filter',
|
||||||
|
'vulnerability-conversation-filter',
|
||||||
|
'vulnerability-task-filter',
|
||||||
|
'vulnerability-conversation-tag-filter',
|
||||||
|
'vulnerability-task-tag-filter'
|
||||||
|
];
|
||||||
|
textIds.forEach(function (id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('keydown', function (ev) {
|
||||||
|
if (ev.key === 'Enter') {
|
||||||
|
ev.preventDefault();
|
||||||
|
applyVulnerabilityFilters();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
bindVulnerabilityFilterTypeaheads();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVulnerabilityAdvancedFiltersOpen(open, persist) {
|
||||||
|
const toggleBtn = document.getElementById('vulnerability-advanced-toggle');
|
||||||
|
const advanced = document.getElementById('vulnerability-advanced-filters');
|
||||||
|
const wrap = document.querySelector('#vulnerability-filter-panel .vulnerability-filter-advanced-wrap');
|
||||||
|
if (!toggleBtn || !advanced) return;
|
||||||
|
toggleBtn.setAttribute('aria-expanded', open ? 'true' : 'false');
|
||||||
|
advanced.hidden = !open;
|
||||||
|
advanced.classList.toggle('is-open', open);
|
||||||
|
if (wrap) wrap.classList.toggle('is-expanded', open);
|
||||||
|
if (persist) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VULNERABILITY_ADVANCED_OPEN_KEY, open ? 'true' : 'false');
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function countVulnerabilityAdvancedFiltersActive() {
|
||||||
|
const f = vulnerabilityFilters;
|
||||||
|
let n = 0;
|
||||||
|
if (f.conversation_id) n++;
|
||||||
|
if (f.task_id) n++;
|
||||||
|
if (f.conversation_tag) n++;
|
||||||
|
if (f.task_tag) n++;
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVulnerabilityAdvancedBadge() {
|
||||||
|
const badge = document.getElementById('vulnerability-advanced-badge');
|
||||||
|
if (!badge) return;
|
||||||
|
readVulnerabilityFiltersFromForm();
|
||||||
|
const n = countVulnerabilityAdvancedFiltersActive();
|
||||||
|
if (n > 0) {
|
||||||
|
badge.hidden = false;
|
||||||
|
badge.textContent = '(' + n + ')';
|
||||||
|
badge.setAttribute('aria-label', String(n));
|
||||||
|
} else {
|
||||||
|
badge.hidden = true;
|
||||||
|
badge.textContent = '';
|
||||||
|
badge.removeAttribute('aria-label');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVulnerabilityFilterPanelState() {
|
||||||
|
const panel = document.getElementById('vulnerability-filter-panel');
|
||||||
|
if (!panel) return;
|
||||||
|
readVulnerabilityFiltersFromForm();
|
||||||
|
panel.classList.toggle('is-filtered', hasAnyVulnerabilityFilterActive());
|
||||||
|
updateVulnerabilityAdvancedBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatVulnerabilityFilterChipValue(key, value) {
|
||||||
|
if (key === 'severity') return vulnSeverityLabel(value);
|
||||||
|
if (key === 'status') return vulnStatusLabel(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVulnerabilityFilterChips() {
|
||||||
|
const wrap = document.getElementById('vulnerability-filter-chips');
|
||||||
|
const list = document.getElementById('vulnerability-filter-chips-list');
|
||||||
|
if (!wrap || !list) return;
|
||||||
|
|
||||||
|
readVulnerabilityFiltersFromForm();
|
||||||
|
const chips = [];
|
||||||
|
VULN_FILTER_CHIP_FIELDS.forEach(function (field) {
|
||||||
|
const val = vulnerabilityFilters[field.key];
|
||||||
|
if (!val) return;
|
||||||
|
const label = field.labelKey ? vulnT(field.labelKey) : '';
|
||||||
|
const displayVal = formatVulnerabilityFilterChipValue(field.key, val);
|
||||||
|
const text = label ? label + ': ' + displayVal : displayVal;
|
||||||
|
chips.push({ key: field.key, text: text });
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!chips.length) {
|
||||||
|
wrap.hidden = true;
|
||||||
|
list.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
wrap.hidden = false;
|
||||||
|
const removeLabel = vulnT('vulnerabilityPage.chipRemove');
|
||||||
|
list.innerHTML = chips.map(function (chip) {
|
||||||
|
return (
|
||||||
|
'<button type="button" class="vulnerability-filter-chip" role="listitem" data-filter-key="' +
|
||||||
|
escapeHtml(chip.key) + '" title="' + escapeHtml(removeLabel) + '">' +
|
||||||
|
'<span>' + escapeHtml(chip.text) + '</span>' +
|
||||||
|
'<span class="vulnerability-filter-chip-remove" aria-hidden="true">×</span>' +
|
||||||
|
'</button>'
|
||||||
|
);
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
list.querySelectorAll('.vulnerability-filter-chip').forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
const key = btn.getAttribute('data-filter-key');
|
||||||
|
if (key) removeVulnerabilityFilterByKey(key);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeVulnerabilityFilterByKey(key) {
|
||||||
|
const map = {
|
||||||
|
id: 'vulnerability-id-filter',
|
||||||
|
conversation_id: 'vulnerability-conversation-filter',
|
||||||
|
task_id: 'vulnerability-task-filter',
|
||||||
|
conversation_tag: 'vulnerability-conversation-tag-filter',
|
||||||
|
task_tag: 'vulnerability-task-tag-filter',
|
||||||
|
severity: 'vulnerability-severity-filter',
|
||||||
|
status: 'vulnerability-status-filter'
|
||||||
|
};
|
||||||
|
const elId = map[key];
|
||||||
|
if (elId) {
|
||||||
|
const el = document.getElementById(elId);
|
||||||
|
if (el) el.value = '';
|
||||||
|
}
|
||||||
|
if (Object.prototype.hasOwnProperty.call(vulnerabilityFilters, key)) {
|
||||||
|
vulnerabilityFilters[key] = '';
|
||||||
|
}
|
||||||
|
applyVulnerabilityFilters();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadVulnerabilityFilterOptions() {
|
||||||
|
if (typeof apiFetch === 'undefined') return;
|
||||||
|
try {
|
||||||
|
const response = await apiFetch('/api/vulnerabilities/filter-options');
|
||||||
|
if (!response.ok) return;
|
||||||
|
vulnerabilityFilterOptionsCache = await response.json();
|
||||||
|
populateVulnerabilityDatalist(
|
||||||
|
'vulnerability-conversation-tag-suggestions',
|
||||||
|
vulnerabilityFilterOptionsCache.conversation_tags,
|
||||||
|
{ max: 20 }
|
||||||
|
);
|
||||||
|
populateVulnerabilityDatalist(
|
||||||
|
'vulnerability-task-tag-suggestions',
|
||||||
|
vulnerabilityFilterOptionsCache.task_tags,
|
||||||
|
{ max: 20 }
|
||||||
|
);
|
||||||
|
clearVulnerabilityDatalist('vulnerability-conversation-suggestions');
|
||||||
|
clearVulnerabilityDatalist('vulnerability-task-suggestions');
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('加载漏洞筛选建议失败', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearVulnerabilityDatalist(listId) {
|
||||||
|
const list = document.getElementById(listId);
|
||||||
|
if (list) list.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateVulnerabilityDatalist(listId, values, opts) {
|
||||||
|
const list = document.getElementById(listId);
|
||||||
|
if (!list || !Array.isArray(values)) return;
|
||||||
|
const max = (opts && opts.max) || VULNERABILITY_DATALIST_MAX;
|
||||||
|
const seen = new Set();
|
||||||
|
const unique = [];
|
||||||
|
values.forEach(function (v) {
|
||||||
|
const s = String(v || '').trim();
|
||||||
|
if (!s || seen.has(s)) return;
|
||||||
|
seen.add(s);
|
||||||
|
unique.push(s);
|
||||||
|
if (unique.length >= max) return;
|
||||||
|
});
|
||||||
|
list.innerHTML = unique.slice(0, max).map(function (v) {
|
||||||
|
return '<option value="' + escapeHtml(v) + '"></option>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterVulnerabilitySuggestionPool(pool, query) {
|
||||||
|
if (!Array.isArray(pool) || !query) return [];
|
||||||
|
const q = query.toLowerCase();
|
||||||
|
const out = [];
|
||||||
|
for (let i = 0; i < pool.length && out.length < VULNERABILITY_DATALIST_MAX; i++) {
|
||||||
|
const s = String(pool[i] || '').trim();
|
||||||
|
if (s && s.toLowerCase().indexOf(q) !== -1) out.push(s);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVulnerabilityTypeaheadDatalist(inputId, listId, poolKey) {
|
||||||
|
const el = document.getElementById(inputId);
|
||||||
|
if (!el || !vulnerabilityFilterOptionsCache) return;
|
||||||
|
const q = el.value.trim();
|
||||||
|
if (q.length < VULNERABILITY_DATALIST_MIN_QUERY) {
|
||||||
|
clearVulnerabilityDatalist(listId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let pool = vulnerabilityFilterOptionsCache[poolKey] || [];
|
||||||
|
if (poolKey === 'task_ids') {
|
||||||
|
pool = (vulnerabilityFilterOptionsCache.task_ids || []).concat(vulnerabilityFilterOptionsCache.queue_ids || []);
|
||||||
|
}
|
||||||
|
populateVulnerabilityDatalist(listId, filterVulnerabilitySuggestionPool(pool, q));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindVulnerabilityFilterTypeaheads() {
|
||||||
|
const pairs = [
|
||||||
|
{ inputId: 'vulnerability-conversation-filter', listId: 'vulnerability-conversation-suggestions', poolKey: 'conversation_ids' },
|
||||||
|
{ inputId: 'vulnerability-task-filter', listId: 'vulnerability-task-suggestions', poolKey: 'task_ids' }
|
||||||
|
];
|
||||||
|
pairs.forEach(function (pair) {
|
||||||
|
const el = document.getElementById(pair.inputId);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('input', function () {
|
||||||
|
updateVulnerabilityTypeaheadDatalist(pair.inputId, pair.listId, pair.poolKey);
|
||||||
|
});
|
||||||
|
el.addEventListener('blur', function () {
|
||||||
|
setTimeout(function () { clearVulnerabilityDatalist(pair.listId); }, 150);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
['vulnerability-conversation-tag-filter', 'vulnerability-task-tag-filter'].forEach(function (inputId) {
|
||||||
|
const el = document.getElementById(inputId);
|
||||||
|
if (!el) return;
|
||||||
|
el.addEventListener('focus', function () {
|
||||||
|
if (!vulnerabilityFilterOptionsCache) return;
|
||||||
|
const listId = inputId === 'vulnerability-conversation-tag-filter'
|
||||||
|
? 'vulnerability-conversation-tag-suggestions'
|
||||||
|
: 'vulnerability-task-tag-suggestions';
|
||||||
|
const key = inputId === 'vulnerability-conversation-tag-filter' ? 'conversation_tags' : 'task_tags';
|
||||||
|
const q = el.value.trim();
|
||||||
|
if (q.length >= VULNERABILITY_DATALIST_MIN_QUERY) {
|
||||||
|
populateVulnerabilityDatalist(listId, filterVulnerabilitySuggestionPool(vulnerabilityFilterOptionsCache[key], q), { max: 20 });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVulnerabilityStatCardActiveState() {
|
||||||
|
const sevEl = document.getElementById('vulnerability-severity-filter');
|
||||||
|
const sev = (sevEl && sevEl.value) || vulnerabilityFilters.severity || '';
|
||||||
|
const root = document.getElementById('vulnerability-stat-cards');
|
||||||
|
if (!root) return;
|
||||||
|
root.querySelectorAll('.stat-card.is-clickable').forEach(function (card) {
|
||||||
|
if (card.classList.contains('stat-card-total')) {
|
||||||
|
card.classList.toggle('is-active', !sev);
|
||||||
|
card.setAttribute('aria-pressed', sev ? 'false' : 'true');
|
||||||
|
} else {
|
||||||
|
const cardSev = card.getAttribute('data-severity');
|
||||||
|
const active = Boolean(sev && cardSev === sev);
|
||||||
|
card.classList.toggle('is-active', active);
|
||||||
|
card.setAttribute('aria-pressed', active ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateVulnerabilityStatStackedBar(bySeverity, total) {
|
||||||
|
const bar = document.getElementById('stat-stacked-bar');
|
||||||
|
if (!bar) return;
|
||||||
|
const segs = bar.querySelectorAll('.stat-stacked-seg');
|
||||||
|
if (!total) {
|
||||||
|
bar.classList.add('is-empty');
|
||||||
|
segs.forEach(function (seg) {
|
||||||
|
seg.style.flex = '0 0 0';
|
||||||
|
seg.style.display = 'none';
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
bar.classList.remove('is-empty');
|
||||||
|
segs.forEach(function (seg) {
|
||||||
|
const sev = seg.getAttribute('data-sev');
|
||||||
|
const count = bySeverity[sev] || 0;
|
||||||
|
if (count <= 0) {
|
||||||
|
seg.style.display = 'none';
|
||||||
|
seg.style.flex = '0 0 0';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
seg.style.display = '';
|
||||||
|
const pct = Math.max((count / total) * 100, 0);
|
||||||
|
seg.style.flex = '1 1 ' + pct + '%';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 加载漏洞统计
|
// 加载漏洞统计
|
||||||
async function loadVulnerabilityStats() {
|
async function loadVulnerabilityStats() {
|
||||||
try {
|
try {
|
||||||
@@ -154,14 +644,32 @@ function updateVulnerabilityStats(stats) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('stat-total').textContent = stats.total || 0;
|
const total = stats.total || 0;
|
||||||
|
|
||||||
const bySeverity = stats.by_severity || {};
|
const bySeverity = stats.by_severity || {};
|
||||||
document.getElementById('stat-critical').textContent = bySeverity.critical || 0;
|
|
||||||
document.getElementById('stat-high').textContent = bySeverity.high || 0;
|
const totalEl = document.getElementById('stat-total');
|
||||||
document.getElementById('stat-medium').textContent = bySeverity.medium || 0;
|
if (totalEl) {
|
||||||
document.getElementById('stat-low').textContent = bySeverity.low || 0;
|
totalEl.textContent = String(total);
|
||||||
document.getElementById('stat-info').textContent = bySeverity.info || 0;
|
totalEl.classList.toggle('is-zero', total === 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
VULN_STAT_SEVERITIES.forEach(function (sev) {
|
||||||
|
const count = bySeverity[sev] || 0;
|
||||||
|
const valEl = document.getElementById('stat-' + sev);
|
||||||
|
const pctEl = document.getElementById('stat-' + sev + '-pct');
|
||||||
|
if (valEl) {
|
||||||
|
valEl.textContent = String(count);
|
||||||
|
valEl.classList.toggle('is-zero', count === 0);
|
||||||
|
}
|
||||||
|
if (pctEl) {
|
||||||
|
const pct = total > 0 ? Math.round((count / total) * 100) : 0;
|
||||||
|
pctEl.textContent = pct + '%';
|
||||||
|
pctEl.setAttribute('aria-hidden', total === 0 ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
updateVulnerabilityStatStackedBar(bySeverity, total);
|
||||||
|
syncVulnerabilityStatCardActiveState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载漏洞列表
|
// 加载漏洞列表
|
||||||
@@ -575,32 +1083,26 @@ function closeVulnerabilityModal() {
|
|||||||
currentVulnerabilityId = null;
|
currentVulnerabilityId = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 筛选漏洞
|
// 筛选漏洞(应用当前表单条件)
|
||||||
function filterVulnerabilities() {
|
function filterVulnerabilities() {
|
||||||
vulnerabilityFilters.id = document.getElementById('vulnerability-id-filter').value.trim();
|
applyVulnerabilityFilters();
|
||||||
vulnerabilityFilters.conversation_id = document.getElementById('vulnerability-conversation-filter').value.trim();
|
|
||||||
vulnerabilityFilters.task_id = document.getElementById('vulnerability-task-filter').value.trim();
|
|
||||||
vulnerabilityFilters.conversation_tag = document.getElementById('vulnerability-conversation-tag-filter').value.trim();
|
|
||||||
vulnerabilityFilters.task_tag = document.getElementById('vulnerability-task-tag-filter').value.trim();
|
|
||||||
vulnerabilityFilters.severity = document.getElementById('vulnerability-severity-filter').value;
|
|
||||||
vulnerabilityFilters.status = document.getElementById('vulnerability-status-filter').value;
|
|
||||||
|
|
||||||
// 重置到第一页
|
|
||||||
vulnerabilityPagination.currentPage = 1;
|
|
||||||
|
|
||||||
loadVulnerabilityStats();
|
|
||||||
loadVulnerabilities();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 清除筛选
|
// 清除筛选
|
||||||
function clearVulnerabilityFilters() {
|
function clearVulnerabilityFilters() {
|
||||||
document.getElementById('vulnerability-id-filter').value = '';
|
const fields = [
|
||||||
document.getElementById('vulnerability-conversation-filter').value = '';
|
'vulnerability-id-filter',
|
||||||
document.getElementById('vulnerability-task-filter').value = '';
|
'vulnerability-conversation-filter',
|
||||||
document.getElementById('vulnerability-conversation-tag-filter').value = '';
|
'vulnerability-task-filter',
|
||||||
document.getElementById('vulnerability-task-tag-filter').value = '';
|
'vulnerability-conversation-tag-filter',
|
||||||
document.getElementById('vulnerability-severity-filter').value = '';
|
'vulnerability-task-tag-filter',
|
||||||
document.getElementById('vulnerability-status-filter').value = '';
|
'vulnerability-severity-filter',
|
||||||
|
'vulnerability-status-filter'
|
||||||
|
];
|
||||||
|
fields.forEach(function (id) {
|
||||||
|
const el = document.getElementById(id);
|
||||||
|
if (el) el.value = '';
|
||||||
|
});
|
||||||
|
|
||||||
vulnerabilityFilters = {
|
vulnerabilityFilters = {
|
||||||
id: '',
|
id: '',
|
||||||
@@ -612,11 +1114,7 @@ function clearVulnerabilityFilters() {
|
|||||||
status: ''
|
status: ''
|
||||||
};
|
};
|
||||||
|
|
||||||
// 重置到第一页
|
applyVulnerabilityFilters();
|
||||||
vulnerabilityPagination.currentPage = 1;
|
|
||||||
|
|
||||||
loadVulnerabilityStats();
|
|
||||||
loadVulnerabilities();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 刷新漏洞
|
// 刷新漏洞
|
||||||
@@ -892,6 +1390,7 @@ window.onclick = function(event) {
|
|||||||
document.addEventListener('languagechange', function () {
|
document.addEventListener('languagechange', function () {
|
||||||
const page = document.getElementById('page-vulnerabilities');
|
const page = document.getElementById('page-vulnerabilities');
|
||||||
if (page && page.classList.contains('active')) {
|
if (page && page.classList.contains('active')) {
|
||||||
|
renderVulnerabilityFilterChips();
|
||||||
loadVulnerabilities();
|
loadVulnerabilities();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
+122
-75
@@ -190,10 +190,12 @@
|
|||||||
<!-- C2 侧栏入口(带子菜单) -->
|
<!-- C2 侧栏入口(带子菜单) -->
|
||||||
<div class="nav-item nav-item-has-submenu" data-page="c2" id="nav-c2">
|
<div class="nav-item nav-item-has-submenu" data-page="c2" id="nav-c2">
|
||||||
<div class="nav-item-content" data-title="C2" onclick="window.toggleSubmenu('c2')" data-i18n="nav.c2" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
<div class="nav-item-content" data-title="C2" onclick="window.toggleSubmenu('c2')" data-i18n="nav.c2" data-i18n-attr="data-title" data-i18n-skip-text="true">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||||
<path d="M2 17l10 5 10-5"></path>
|
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
||||||
<path d="M2 12l10 5 10-5"></path>
|
<circle cx="12" cy="12" r="2"></circle>
|
||||||
|
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
||||||
|
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
|
||||||
</svg>
|
</svg>
|
||||||
<span data-i18n="nav.c2">C2</span>
|
<span data-i18n="nav.c2">C2</span>
|
||||||
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<svg class="submenu-arrow" width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
@@ -201,6 +203,7 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-submenu" id="submenu-c2">
|
<div class="nav-submenu" id="submenu-c2">
|
||||||
|
<div class="nav-submenu-item" data-page="c2" onclick="switchPage('c2')" data-i18n="nav.c2Manage">C2 管理</div>
|
||||||
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
|
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
|
||||||
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
|
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
|
||||||
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
|
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
|
||||||
@@ -446,42 +449,46 @@
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
<div class="dashboard-severity-chart">
|
<div class="dashboard-severity-chart">
|
||||||
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
|
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" role="img" aria-labelledby="dashboard-severity-donut-title">
|
||||||
|
<title id="dashboard-severity-donut-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</title>
|
||||||
|
<defs id="dashboard-severity-donut-defs"></defs>
|
||||||
<g id="dashboard-severity-donut-track"></g>
|
<g id="dashboard-severity-donut-track"></g>
|
||||||
|
<g id="dashboard-severity-donut-leaders"></g>
|
||||||
<g id="dashboard-severity-donut-segments"></g>
|
<g id="dashboard-severity-donut-segments"></g>
|
||||||
|
<g id="dashboard-severity-donut-hits"></g>
|
||||||
<g id="dashboard-severity-donut-labels"></g>
|
<g id="dashboard-severity-donut-labels"></g>
|
||||||
</svg>
|
</svg>
|
||||||
<div class="dashboard-severity-center">
|
<div class="dashboard-severity-center" id="dashboard-severity-center">
|
||||||
<div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div>
|
<div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div>
|
||||||
<div class="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
|
<div class="dashboard-severity-center-label" id="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-severity-legend" id="dashboard-vuln-bars">
|
<div class="dashboard-severity-legend" id="dashboard-vuln-bars">
|
||||||
<div class="dashboard-severity-legend-item">
|
<div class="dashboard-severity-legend-item" data-severity="critical" role="button" tabindex="0">
|
||||||
<span class="dashboard-severity-legend-dot critical"></span>
|
<span class="dashboard-severity-legend-dot critical"></span>
|
||||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span>
|
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span>
|
||||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span>
|
<span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span>
|
||||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span>
|
<span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-severity-legend-item">
|
<div class="dashboard-severity-legend-item" data-severity="high" role="button" tabindex="0">
|
||||||
<span class="dashboard-severity-legend-dot high"></span>
|
<span class="dashboard-severity-legend-dot high"></span>
|
||||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span>
|
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span>
|
||||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span>
|
<span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span>
|
||||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span>
|
<span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-severity-legend-item">
|
<div class="dashboard-severity-legend-item" data-severity="medium" role="button" tabindex="0">
|
||||||
<span class="dashboard-severity-legend-dot medium"></span>
|
<span class="dashboard-severity-legend-dot medium"></span>
|
||||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span>
|
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span>
|
||||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span>
|
<span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span>
|
||||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span>
|
<span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-severity-legend-item">
|
<div class="dashboard-severity-legend-item" data-severity="low" role="button" tabindex="0">
|
||||||
<span class="dashboard-severity-legend-dot low"></span>
|
<span class="dashboard-severity-legend-dot low"></span>
|
||||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span>
|
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span>
|
||||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span>
|
<span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span>
|
||||||
<span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span>
|
<span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-severity-legend-item">
|
<div class="dashboard-severity-legend-item" data-severity="info" role="button" tabindex="0">
|
||||||
<span class="dashboard-severity-legend-dot info"></span>
|
<span class="dashboard-severity-legend-dot info"></span>
|
||||||
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span>
|
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span>
|
||||||
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
|
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
|
||||||
@@ -1075,10 +1082,13 @@
|
|||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<div class="monitor-sections">
|
<div class="monitor-sections">
|
||||||
<section class="monitor-section monitor-overview">
|
<section class="monitor-section monitor-overview">
|
||||||
<div class="section-header">
|
<div class="section-header monitor-stats-section-header">
|
||||||
<h3 data-i18n="mcp.execStats">执行统计</h3>
|
<div class="monitor-stats-header-text">
|
||||||
|
<h3 data-i18n="mcp.execStats">执行统计</h3>
|
||||||
|
<p id="monitor-stats-subtitle" class="monitor-stats-subtitle" hidden></p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div id="monitor-stats" class="monitor-stats-grid">
|
<div id="monitor-stats" class="mcp-exec-stats-root">
|
||||||
<div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
|
<div class="monitor-empty" data-i18n="mcpMonitor.loading">加载中...</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
@@ -1378,89 +1388,124 @@
|
|||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||||
<div class="page-header-actions">
|
<div class="page-header-actions">
|
||||||
|
<button class="btn-secondary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
|
||||||
<button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button>
|
<button class="btn-secondary" onclick="refreshVulnerabilities()" data-i18n="common.refresh">刷新</button>
|
||||||
<button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
<button class="btn-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="page-content">
|
<div class="page-content">
|
||||||
<!-- 统计看板 -->
|
<!-- 统计看板:点击卡片筛选严重度,与下方下拉/地址栏 hash 同步 -->
|
||||||
<div class="vulnerability-dashboard" id="vulnerability-dashboard">
|
<div class="vulnerability-dashboard" id="vulnerability-dashboard">
|
||||||
<div class="dashboard-stats">
|
<div class="dashboard-stats" id="vulnerability-stat-cards" role="group" aria-label="漏洞严重度统计">
|
||||||
<div class="stat-card">
|
<div class="stat-card stat-card-total is-clickable is-active" data-severity="" role="button" tabindex="0"
|
||||||
|
data-i18n="vulnerabilityPage.statClickAll" data-i18n-attr="title" title="查看全部(清除严重度筛选)">
|
||||||
<div class="stat-label" data-i18n="vulnerabilityPage.statTotal">总漏洞数</div>
|
<div class="stat-label" data-i18n="vulnerabilityPage.statTotal">总漏洞数</div>
|
||||||
<div class="stat-value" id="stat-total">-</div>
|
<div class="stat-value" id="stat-total">-</div>
|
||||||
|
<div class="stat-stacked-bar" id="stat-stacked-bar" aria-hidden="true">
|
||||||
|
<span class="stat-stacked-seg critical" data-sev="critical"></span>
|
||||||
|
<span class="stat-stacked-seg high" data-sev="high"></span>
|
||||||
|
<span class="stat-stacked-seg medium" data-sev="medium"></span>
|
||||||
|
<span class="stat-stacked-seg low" data-sev="low"></span>
|
||||||
|
<span class="stat-stacked-seg info" data-sev="info"></span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-critical">
|
<div class="stat-card stat-critical is-clickable" data-severity="critical" role="button" tabindex="0"
|
||||||
|
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
|
||||||
<div class="stat-label" data-i18n="dashboard.severityCritical">严重</div>
|
<div class="stat-label" data-i18n="dashboard.severityCritical">严重</div>
|
||||||
<div class="stat-value" id="stat-critical">-</div>
|
<div class="stat-value" id="stat-critical">-</div>
|
||||||
|
<div class="stat-pct" id="stat-critical-pct" aria-hidden="true">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-high">
|
<div class="stat-card stat-high is-clickable" data-severity="high" role="button" tabindex="0"
|
||||||
|
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
|
||||||
<div class="stat-label" data-i18n="dashboard.severityHigh">高危</div>
|
<div class="stat-label" data-i18n="dashboard.severityHigh">高危</div>
|
||||||
<div class="stat-value" id="stat-high">-</div>
|
<div class="stat-value" id="stat-high">-</div>
|
||||||
|
<div class="stat-pct" id="stat-high-pct" aria-hidden="true">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-medium">
|
<div class="stat-card stat-medium is-clickable" data-severity="medium" role="button" tabindex="0"
|
||||||
|
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
|
||||||
<div class="stat-label" data-i18n="dashboard.severityMedium">中危</div>
|
<div class="stat-label" data-i18n="dashboard.severityMedium">中危</div>
|
||||||
<div class="stat-value" id="stat-medium">-</div>
|
<div class="stat-value" id="stat-medium">-</div>
|
||||||
|
<div class="stat-pct" id="stat-medium-pct" aria-hidden="true">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-low">
|
<div class="stat-card stat-low is-clickable" data-severity="low" role="button" tabindex="0"
|
||||||
|
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
|
||||||
<div class="stat-label" data-i18n="dashboard.severityLow">低危</div>
|
<div class="stat-label" data-i18n="dashboard.severityLow">低危</div>
|
||||||
<div class="stat-value" id="stat-low">-</div>
|
<div class="stat-value" id="stat-low">-</div>
|
||||||
|
<div class="stat-pct" id="stat-low-pct" aria-hidden="true">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card stat-info">
|
<div class="stat-card stat-info is-clickable" data-severity="info" role="button" tabindex="0"
|
||||||
|
data-i18n="vulnerabilityPage.statClickFilter" data-i18n-attr="title" title="点击按此严重度筛选;再次点击清除">
|
||||||
<div class="stat-label" data-i18n="dashboard.severityInfo">信息</div>
|
<div class="stat-label" data-i18n="dashboard.severityInfo">信息</div>
|
||||||
<div class="stat-value" id="stat-info">-</div>
|
<div class="stat-value" id="stat-info">-</div>
|
||||||
|
<div class="stat-pct" id="stat-info-pct" aria-hidden="true">—</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 筛选和搜索 -->
|
<!-- 筛选 -->
|
||||||
<div class="vulnerability-controls">
|
<div class="vulnerability-controls" id="vulnerability-filter-panel">
|
||||||
<div class="vulnerability-filters">
|
<div class="vulnerability-filter-toolbar">
|
||||||
<label>
|
<div class="vulnerability-filter-primary">
|
||||||
<span data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
<label class="vulnerability-filter-field vulnerability-filter-field--grow">
|
||||||
<input type="text" id="vulnerability-id-filter" data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞ID" />
|
<span class="sr-only" data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
||||||
</label>
|
<input type="search" id="vulnerability-id-filter" autocomplete="off"
|
||||||
<label>
|
data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞 ID,回车筛选" />
|
||||||
<span data-i18n="vulnerabilityPage.conversationId">会话ID</span>
|
</label>
|
||||||
<input type="text" id="vulnerability-conversation-filter" data-i18n="vulnerabilityPage.filterConversation" data-i18n-attr="placeholder" placeholder="筛选特定会话" />
|
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||||
</label>
|
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
||||||
<label>
|
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
|
||||||
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务ID/队列ID</span>
|
<option value="" data-i18n="knowledgePage.all">全部状态</option>
|
||||||
<input type="text" id="vulnerability-task-filter" data-i18n="vulnerabilityPage.filterTaskOrQueue" data-i18n-attr="placeholder" placeholder="筛选任务ID或队列ID" />
|
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
|
||||||
</label>
|
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
||||||
<label>
|
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
||||||
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
|
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
||||||
<input type="text" id="vulnerability-conversation-tag-filter" data-i18n="vulnerabilityPage.filterConversationTag" data-i18n-attr="placeholder" placeholder="筛选对话标签" />
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<button type="button" class="vulnerability-filter-clear-btn" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
|
||||||
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
|
</div>
|
||||||
<input type="text" id="vulnerability-task-tag-filter" data-i18n="vulnerabilityPage.filterTaskTag" data-i18n-attr="placeholder" placeholder="筛选任务标签" />
|
<select id="vulnerability-severity-filter" class="vulnerability-severity-sync" hidden aria-hidden="true" tabindex="-1">
|
||||||
</label>
|
<option value=""></option>
|
||||||
<label>
|
<option value="critical">critical</option>
|
||||||
<span data-i18n="vulnerabilityPage.severity">严重程度</span>
|
<option value="high">high</option>
|
||||||
<select id="vulnerability-severity-filter">
|
<option value="medium">medium</option>
|
||||||
<option value="" data-i18n="knowledgePage.all">全部</option>
|
<option value="low">low</option>
|
||||||
<option value="critical" data-i18n="dashboard.severityCritical">严重</option>
|
<option value="info">info</option>
|
||||||
<option value="high" data-i18n="dashboard.severityHigh">高危</option>
|
</select>
|
||||||
<option value="medium" data-i18n="dashboard.severityMedium">中危</option>
|
|
||||||
<option value="low" data-i18n="dashboard.severityLow">低危</option>
|
|
||||||
<option value="info" data-i18n="dashboard.severityInfo">信息</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
<span data-i18n="vulnerabilityPage.status">状态</span>
|
|
||||||
<select id="vulnerability-status-filter">
|
|
||||||
<option value="" data-i18n="knowledgePage.all">全部</option>
|
|
||||||
<option value="open" data-i18n="vulnerabilityPage.statusOpen">待处理</option>
|
|
||||||
<option value="confirmed" data-i18n="vulnerabilityPage.statusConfirmed">已确认</option>
|
|
||||||
<option value="fixed" data-i18n="vulnerabilityPage.statusFixed">已修复</option>
|
|
||||||
<option value="false_positive" data-i18n="vulnerabilityPage.statusFalsePositive">误报</option>
|
|
||||||
</select>
|
|
||||||
</label>
|
|
||||||
<button class="btn-secondary" onclick="filterVulnerabilities()" data-i18n="vulnerabilityPage.filter">筛选</button>
|
|
||||||
<button class="btn-secondary" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
|
|
||||||
<button class="btn-primary" onclick="exportVulnerabilityReports()" data-i18n="vulnerabilityPage.batchExport">批量导出</button>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div class="vulnerability-filter-advanced-wrap">
|
||||||
|
<button type="button" class="vulnerability-filter-advanced-toggle" id="vulnerability-advanced-toggle"
|
||||||
|
aria-expanded="false" aria-controls="vulnerability-advanced-filters"
|
||||||
|
onclick="toggleVulnerabilityAdvancedFilters(event)">
|
||||||
|
<span class="vulnerability-filter-advanced-chevron" aria-hidden="true"></span>
|
||||||
|
<span data-i18n="vulnerabilityPage.advancedFilters">高级筛选</span>
|
||||||
|
<span class="vulnerability-filter-advanced-badge" id="vulnerability-advanced-badge" hidden></span>
|
||||||
|
</button>
|
||||||
|
<div class="vulnerability-filter-advanced" id="vulnerability-advanced-filters" hidden>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.conversationId">会话 ID</span>
|
||||||
|
<input type="text" id="vulnerability-conversation-filter" list="vulnerability-conversation-suggestions" placeholder="回车筛选" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务 / 队列 ID</span>
|
||||||
|
<input type="text" id="vulnerability-task-filter" list="vulnerability-task-suggestions" placeholder="回车筛选" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
|
||||||
|
<input type="text" id="vulnerability-conversation-tag-filter" list="vulnerability-conversation-tag-suggestions" placeholder="回车筛选" />
|
||||||
|
</label>
|
||||||
|
<label class="vulnerability-filter-field">
|
||||||
|
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
|
||||||
|
<input type="text" id="vulnerability-task-tag-filter" list="vulnerability-task-tag-suggestions" placeholder="回车筛选" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="vulnerability-filter-chips" id="vulnerability-filter-chips" hidden>
|
||||||
|
<div class="vulnerability-filter-chips-list" id="vulnerability-filter-chips-list" role="list"></div>
|
||||||
|
</div>
|
||||||
|
<datalist id="vulnerability-conversation-suggestions"></datalist>
|
||||||
|
<datalist id="vulnerability-task-suggestions"></datalist>
|
||||||
|
<datalist id="vulnerability-conversation-tag-suggestions"></datalist>
|
||||||
|
<datalist id="vulnerability-task-tag-suggestions"></datalist>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 漏洞列表 -->
|
<!-- 漏洞列表 -->
|
||||||
@@ -1605,11 +1650,13 @@
|
|||||||
<div id="c2-main" class="c2-main">
|
<div id="c2-main" class="c2-main">
|
||||||
<div class="c2-welcome">
|
<div class="c2-welcome">
|
||||||
<div class="c2-welcome-icon">
|
<div class="c2-welcome-icon">
|
||||||
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
|
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
|
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
|
||||||
<path d="M12 2L2 7l10 5 10-5-10-5z"></path>
|
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
||||||
<path d="M2 17l10 5 10-5"></path>
|
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
||||||
<path d="M2 12l10 5 10-5"></path>
|
<circle cx="12" cy="12" r="2"></circle>
|
||||||
|
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
||||||
|
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
|
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
|
||||||
@@ -3506,7 +3553,7 @@
|
|||||||
<script src="/static/js/terminal.js"></script>
|
<script src="/static/js/terminal.js"></script>
|
||||||
<script src="/static/js/knowledge.js"></script>
|
<script src="/static/js/knowledge.js"></script>
|
||||||
<script src="/static/js/skills.js"></script>
|
<script src="/static/js/skills.js"></script>
|
||||||
<script src="/static/js/vulnerability.js?v=7"></script>
|
<script src="/static/js/vulnerability.js?v=12"></script>
|
||||||
<script src="/static/js/webshell.js"></script>
|
<script src="/static/js/webshell.js"></script>
|
||||||
<script src="/static/js/chat-files.js"></script>
|
<script src="/static/js/chat-files.js"></script>
|
||||||
<script src="/static/js/tasks.js"></script>
|
<script src="/static/js/tasks.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user