Compare commits

..

55 Commits

Author SHA1 Message Date
公明 9a4f3c7d35 Add files via upload 2026-05-18 17:37:29 +08:00
公明 ead2ce3ecc Add files via upload 2026-05-18 17:28:14 +08:00
公明 8733f3a2d2 Update config.yaml 2026-05-18 11:03:29 +08:00
公明 8642f3ba31 Add files via upload 2026-05-17 17:11:16 +08:00
公明 6a262a7367 Add files via upload 2026-05-17 17:09:16 +08:00
公明 eb9192ddb3 Add files via upload 2026-05-17 17:08:42 +08:00
公明 5587e75628 Add files via upload 2026-05-17 17:06:53 +08:00
公明 74bbb453e2 Add files via upload 2026-05-17 17:05:22 +08:00
公明 66842f6206 Add files via upload 2026-05-17 17:01:48 +08:00
公明 dc1779275d Add files via upload 2026-05-16 13:46:24 +08:00
公明 10dff937b1 Update config.yaml 2026-05-16 13:00:29 +08:00
公明 d4e1fe3bbe Add files via upload 2026-05-15 18:03:59 +08:00
公明 179976ae57 Add files via upload 2026-05-15 17:49:33 +08:00
公明 1c758bb98c Add files via upload 2026-05-15 17:34:25 +08:00
公明 17c4f38ee3 Add files via upload 2026-05-15 17:27:45 +08:00
公明 cd7e57d121 Add files via upload 2026-05-15 14:55:43 +08:00
公明 0f2c3f65cc Add files via upload 2026-05-15 14:21:40 +08:00
公明 7779666e27 Update config.yaml 2026-05-15 14:19:18 +08:00
公明 c74bd4403b Add files via upload 2026-05-15 14:16:04 +08:00
公明 04d23ddb43 Update config.yaml 2026-05-15 14:14:09 +08:00
公明 0874e84393 Add files via upload 2026-05-15 14:12:52 +08:00
公明 57f57f30b1 Add files via upload 2026-05-15 14:11:24 +08:00
公明 f37d613a0c Add files via upload 2026-05-15 14:09:37 +08:00
公明 87d0ff9154 Update config.yaml 2026-05-15 14:08:28 +08:00
公明 b3418f39b8 Update config.yaml 2026-05-15 11:53:07 +08:00
公明 f9e1ca0e2d Add files via upload 2026-05-15 11:49:53 +08:00
公明 2c45879669 Add files via upload 2026-05-15 11:48:58 +08:00
公明 1cdcfa2c2d Add files via upload 2026-05-15 11:47:34 +08:00
公明 eab5b73846 Add files via upload 2026-05-15 11:46:02 +08:00
公明 d961ba1ec7 Add files via upload 2026-05-15 11:43:33 +08:00
公明 1ba5e57ec6 Update config.yaml 2026-05-14 19:35:37 +08:00
公明 1216d25f96 Add files via upload 2026-05-14 19:33:15 +08:00
公明 fde693408e Add files via upload 2026-05-14 19:31:21 +08:00
公明 352a81a869 Add files via upload 2026-05-14 19:29:59 +08:00
公明 b2562b1010 Add files via upload 2026-05-14 19:28:37 +08:00
公明 0d8ba51087 Add files via upload 2026-05-14 19:26:23 +08:00
公明 0b847fcea3 Delete multiagent directory 2026-05-14 19:25:42 +08:00
公明 bf2f49fe62 Delete skillpackage directory 2026-05-14 19:25:19 +08:00
公明 75e64b1a86 Delete einomcp directory 2026-05-14 19:25:09 +08:00
公明 2167735022 Delete database directory 2026-05-14 19:24:58 +08:00
公明 4ee292cc1f Delete storage directory 2026-05-14 19:24:48 +08:00
公明 961205940f Delete agents directory 2026-05-14 19:24:19 +08:00
公明 ffe797bd06 Delete agent directory 2026-05-14 19:24:04 +08:00
公明 b6c864547e Delete mcp directory 2026-05-14 19:23:52 +08:00
公明 da369c2edc Add files via upload 2026-05-14 19:23:27 +08:00
公明 54dc31a616 Add files via upload 2026-05-14 19:21:35 +08:00
公明 9e0b985221 Add files via upload 2026-05-14 19:19:26 +08:00
公明 eb47077082 Update config.yaml 2026-05-14 14:59:27 +08:00
公明 f9a482857d Add files via upload 2026-05-14 11:57:00 +08:00
公明 679a68b12f Add files via upload 2026-05-14 11:55:47 +08:00
公明 840a26c7ef Add files via upload 2026-05-14 11:54:23 +08:00
公明 030e69c02d Add files via upload 2026-05-14 11:49:08 +08:00
公明 d9683cdb44 Add files via upload 2026-05-14 11:33:12 +08:00
公明 60a063dd7d Add files via upload 2026-05-14 11:31:56 +08:00
公明 5f0c1805a7 Add files via upload 2026-05-14 11:30:28 +08:00
33 changed files with 4506 additions and 305 deletions
+8 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -10,11 +10,22 @@
# ============================================ # ============================================
# 前端显示的版本号(可选,不填则显示默认版本) # 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.6.11" version: "v1.6.15"
# 服务器配置 # 服务器配置
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 顶层 thinkingextended thinking),mode: off 关闭 # Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinkingextended thinking),mode: off 关闭
reasoning: reasoning:
mode: off # auto | on | offoff 时不附加任何推理扩展字段 mode: on # auto | on | offoff 时不附加任何推理扩展字段
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
+1 -1
View File
@@ -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
+2 -2
View File
@@ -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
View File
@@ -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
+196
View File
@@ -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)
}
}
+86
View File
@@ -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。
// inMemorytls_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: 已启用 TLStls_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)
}
+4 -2
View File
@@ -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)),
+74
View File
@@ -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)
}
+13 -3
View File
@@ -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 {
+46
View File
@@ -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
}
+3 -1
View File
@@ -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)
}
+10 -1
View File
@@ -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) {
+2
View File
@@ -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 {
+26 -15
View File
@@ -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)
}
}
+34 -5
View File
@@ -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 "$@"
+333 -1
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+36
View File
@@ -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",
+36
View File
@@ -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
View File
@@ -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 下拉选项 HTMLvalue / 文本已转义) */ /** 监听器表单:Malleable Profile 下拉选项 HTMLvalue / 文本已转义) */
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'));
}; };
// ============================================================================ // ============================================================================
+34
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+7 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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>