mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-19 06:18:11 +02:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 951d14ef14 | |||
| 0eb22da6e9 | |||
| 5fd9ef0514 | |||
| 9a4f3c7d35 | |||
| ead2ce3ecc | |||
| 8733f3a2d2 | |||
| 8642f3ba31 | |||
| 6a262a7367 | |||
| eb9192ddb3 | |||
| 5587e75628 | |||
| 74bbb453e2 | |||
| 66842f6206 |
+2
-2
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.6.15"
|
||||
version: "v1.6.16"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
@@ -54,7 +54,7 @@ openai:
|
||||
max_total_tokens: 120000 # LLM 相关上下文的最大 Token 数限制(内存压缩和攻击链构建会共用此配置)
|
||||
# Eino 路径模型推理:DeepSeek/OpenAI 为 thinking / reasoning_effort 等;provider 为 claude 时合并为 Anthropic 顶层 thinking(extended thinking),mode: off 关闭
|
||||
reasoning:
|
||||
mode: off # auto | on | off;off 时不附加任何推理扩展字段
|
||||
mode: on # auto | on | off;off 时不附加任何推理扩展字段
|
||||
effort: max # low | medium | high | max;空表示不指定(openai_compat 下 auto 且无强度时不发请求扩展)
|
||||
allow_client_reasoning: true # false 时忽略对话请求体 reasoning,仅以下方为准
|
||||
profile: openai_compat # auto | deepseek_compat | openai_compat | output_config_effort
|
||||
|
||||
@@ -33,6 +33,7 @@ require (
|
||||
go.opentelemetry.io/otel/sdk v1.34.0
|
||||
go.opentelemetry.io/otel/trace v1.34.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/time v0.14.0
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
@@ -88,7 +89,6 @@ require (
|
||||
golang.org/x/arch v0.15.0 // indirect
|
||||
golang.org/x/crypto v0.39.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
google.golang.org/genproto/googleapis/api v0.0.0-20250115164207-1a7da9e5054f // indirect
|
||||
|
||||
@@ -245,8 +245,6 @@ 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-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.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
|
||||
golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
|
||||
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
|
||||
@@ -239,13 +239,15 @@ func (m *Manager) StartListener(id string) (*database.C2Listener, error) {
|
||||
}
|
||||
cfg.ApplyDefaults()
|
||||
|
||||
// 通过工厂创建具体实现
|
||||
// 通过工厂创建具体实现。必须使用 rec 的副本:HTTP handler 在返回 JSON 前会清空
|
||||
// rec.ImplantToken / EncryptionKey 做脱敏,若 listener 实现持有同一指针会导致 beacon 鉴权永久失败。
|
||||
listenerRec := *rec
|
||||
factory := m.registry.Get(rec.Type)
|
||||
if factory == nil {
|
||||
return nil, ErrUnsupportedType
|
||||
}
|
||||
inst, err := factory(ListenerCreationCtx{
|
||||
Listener: rec,
|
||||
Listener: &listenerRec,
|
||||
Config: cfg,
|
||||
Manager: m,
|
||||
Logger: m.logger.With(zap.String("listener_id", rec.ID), zap.String("type", rec.Type)),
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package c2
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// 回归:StartListener 返回的 rec 被 handler 脱敏清空 ImplantToken 后,运行中的 HTTP listener 仍能鉴权。
|
||||
func TestStartListener_ImplantTokenSurvivesHandlerRedaction(t *testing.T) {
|
||||
tmp := t.TempDir()
|
||||
db, err := database.NewDB(filepath.Join(tmp, "c2.sqlite"), zap.NewNop())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _ = db.Close() })
|
||||
|
||||
lnPick, err := net.Listen("tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
port := lnPick.Addr().(*net.TCPAddr).Port
|
||||
_ = lnPick.Close()
|
||||
|
||||
mgr := NewManager(db, zap.NewNop(), tmp)
|
||||
mgr.Registry().Register(string(ListenerTypeHTTPBeacon), NewHTTPBeaconListener)
|
||||
rec, err := mgr.CreateListener(CreateListenerInput{
|
||||
Name: "t",
|
||||
Type: string(ListenerTypeHTTPBeacon),
|
||||
BindHost: "127.0.0.1",
|
||||
BindPort: port,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
token := rec.ImplantToken
|
||||
|
||||
rec, err = mgr.StartListener(rec.ID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// 模拟 internal/handler/c2.go StartListener 在 JSON 响应前的脱敏
|
||||
rec.ImplantToken = ""
|
||||
rec.EncryptionKey = ""
|
||||
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
body := `{"hostname":"n","username":"u","os":"Linux","arch":"amd64","internal_ip":"10.0.0.1","pid":42}`
|
||||
req, _ := http.NewRequest(http.MethodPost, "http://127.0.0.1:"+strconv.Itoa(port)+"/check_in", strings.NewReader(body))
|
||||
req.Header.Set("X-Implant-Token", token)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
b, _ := io.ReadAll(resp.Body)
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Fatalf("status=%d body=%s", resp.StatusCode, b)
|
||||
}
|
||||
if !strings.Contains(string(b), "session_id") {
|
||||
t.Fatalf("expected session_id in body: %s", b)
|
||||
}
|
||||
_ = mgr.StopListener(rec.ID)
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
### What it does
|
||||
|
||||
- Configure **Host / Port / Password** and choose **Single-Agent** or **Multi-Agent**
|
||||
- Configure **Host / Port / HTTPS / Password** and choose an agent mode
|
||||
- Click **Validate** to login (`POST /api/auth/login`) and verify token (`GET /api/auth/validate`)
|
||||
- Right-click any HTTP message in Burp and send it to CyberStrikeAI for **streaming web pentest**
|
||||
- Keep a **test history sidebar** (searchable) so you can revisit previous runs
|
||||
@@ -63,6 +63,7 @@ If you already have Gradle available, you can still use `build.gradle` to build.
|
||||
|
||||
### Notes
|
||||
|
||||
- This extension connects to your CyberStrikeAI server (default is `http://127.0.0.1:8080`).
|
||||
- Default connection is `https://127.0.0.1:8080` (**HTTPS** checked). Self-signed / local certs are trusted automatically (no import).
|
||||
- Uncheck **HTTPS** only if your server runs plain HTTP.
|
||||
- It uses **Bearer Token** authentication obtained from the configured password.
|
||||
|
||||
|
||||
@@ -81,7 +81,8 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||
2) 填写:
|
||||
- **Host**:例如 `127.0.0.1`
|
||||
- **Port**:例如 `8080`
|
||||
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `config.yaml` 的 `auth.password`)
|
||||
- **HTTPS**:默认勾选(对接 `config.yaml` 中 `tls_enabled` / 自签证书);插件会自动信任本地自签证书,无需导入
|
||||
- **Password**:你的 CyberStrikeAI 登录密码(对应服务端 `auth.password`)
|
||||
- **Agent mode**:选择 `Single Agent` 或 `Multi Agent`
|
||||
3) 点击 **Validate**
|
||||
- 成功:状态显示 `OK (token saved)`
|
||||
@@ -94,8 +95,9 @@ cd plugins/burp-suite/cyberstrikeai-burp-extension
|
||||
|
||||
- **Validate 失败 / 401**
|
||||
- 确认密码是否正确(服务端 `auth.password`)
|
||||
- 确认 IP/端口是否能访问(例如浏览器能打开 `http://IP:PORT/`)
|
||||
- 若服务器启用了反向代理/HTTPS,需要把插件里 baseUrl 改成对应协议与端口(当前插件默认使用 `http://`)
|
||||
- 确认 IP/端口是否能访问(例如浏览器能打开 `https://IP:PORT/`)
|
||||
- 服务端启用 TLS 时勾选 **HTTPS**(默认已勾选);自签证书无需手动导入
|
||||
- 若仍为纯 HTTP 部署,取消勾选 **HTTPS**
|
||||
|
||||
- **选择 Multi Agent 后提示“多代理未启用”**
|
||||
- 服务端需要开启:`config.yaml` 中 `multi_agent.enabled: true`
|
||||
|
||||
BIN
Binary file not shown.
+52
-11
@@ -73,15 +73,34 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
public void onEvent(String type, String message, String rawJson) {
|
||||
if (type == null) type = "";
|
||||
switch (type) {
|
||||
case "response_start":
|
||||
tab.appendProgressToRun(runId, "\n\n[主回复]\n");
|
||||
break;
|
||||
case "response_delta":
|
||||
case "eino_agent_reply_stream_delta":
|
||||
tab.appendFinalToRun(runId, message);
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendFinalToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "response":
|
||||
tab.appendFinalToRun(runId, "\n\n--- Final Response ---\n");
|
||||
tab.appendFinalToRun(runId, message);
|
||||
tab.setFinalResponse(runId, message);
|
||||
break;
|
||||
case "eino_agent_reply_stream_start":
|
||||
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n");
|
||||
break;
|
||||
case "eino_agent_reply_stream_delta":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "eino_agent_reply_stream_end":
|
||||
tab.appendProgressToRun(runId, "\n");
|
||||
break;
|
||||
case "eino_agent_reply":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n\n[子代理回复]\n" + message + "\n");
|
||||
}
|
||||
break;
|
||||
case "progress":
|
||||
tab.appendProgressToRun(runId, "\n[progress] " + message + "\n");
|
||||
tab.setRunStatus(runId, "running");
|
||||
@@ -94,21 +113,40 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
break;
|
||||
case "reasoning_chain_stream_start":
|
||||
tab.appendProgressToRun(runId, "\n\n[推理过程]\n");
|
||||
break;
|
||||
case "reasoning_chain_stream_delta":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "reasoning_chain_stream_end":
|
||||
tab.appendProgressToRun(runId, "\n");
|
||||
break;
|
||||
case "reasoning_chain":
|
||||
if (message != null && !message.isEmpty()) {
|
||||
String streamId = rawJson != null ? SimpleJson.extractStringField(rawJson, "streamId") : "";
|
||||
if (streamId == null || streamId.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, "\n\n[推理过程]\n" + message + "\n");
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "thinking_stream_start":
|
||||
if (tab.isShowDebugEvents()) {
|
||||
tab.resetThinkingStream(runId);
|
||||
}
|
||||
break;
|
||||
case "thinking_stream_delta":
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
tab.appendProgressToRun(runId, message);
|
||||
}
|
||||
break;
|
||||
case "tool_call":
|
||||
case "tool_result":
|
||||
case "tool_result_delta":
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
if ("thinking_stream_delta".equals(type)) {
|
||||
tab.appendThinkingDelta(runId, message);
|
||||
} else {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
case "conversation":
|
||||
@@ -125,7 +163,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
case "done":
|
||||
break;
|
||||
default:
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()) {
|
||||
if (tab.isShowDebugEvents() && message != null && !message.isEmpty()
|
||||
&& !type.endsWith("_stream_delta") && !type.endsWith("_stream_start")
|
||||
&& !type.endsWith("_stream_end")) {
|
||||
tab.appendProgressToRun(runId, "\n[" + type + "] " + message + "\n");
|
||||
}
|
||||
break;
|
||||
@@ -134,8 +174,9 @@ public class BurpExtender implements IBurpExtender, IContextMenuFactory {
|
||||
|
||||
@Override
|
||||
public void onError(String message, Exception e) {
|
||||
tab.appendProgressToRun(runId, "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, "error");
|
||||
boolean cancelled = message != null && message.toLowerCase().contains("cancel");
|
||||
tab.appendProgressToRun(runId, cancelled ? "\n[info] " + message + "\n" : "\n[error] " + message + "\n");
|
||||
tab.setRunStatus(runId, cancelled ? "cancelled" : "error");
|
||||
callbacks.printError("CyberStrikeAI stream error: " + message);
|
||||
if (e != null) {
|
||||
callbacks.printError(e.toString());
|
||||
|
||||
+127
-11
@@ -2,17 +2,29 @@ package burp;
|
||||
|
||||
import java.io.BufferedReader;
|
||||
import java.io.IOException;
|
||||
import java.io.InterruptedIOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.io.OutputStream;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.SocketTimeoutException;
|
||||
import java.net.URL;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.concurrent.atomic.AtomicReference;
|
||||
|
||||
final class CyberStrikeAIClient {
|
||||
|
||||
private static final int AUTH_CONNECT_TIMEOUT_MS = 4_000;
|
||||
private static final int AUTH_READ_TIMEOUT_MS = 5_000;
|
||||
/** login + validate 整段上限,避免两次读超时叠加拖到半分钟 */
|
||||
private static final int AUTH_OVERALL_TIMEOUT_MS = 10_000;
|
||||
private static final int DEFAULT_READ_TIMEOUT_MS = 15_000;
|
||||
|
||||
private final AtomicReference<HttpURLConnection> activeConnection = new AtomicReference<>();
|
||||
private final AtomicReference<Thread> activeThread = new AtomicReference<>();
|
||||
|
||||
static final class Config {
|
||||
final String baseUrl; // e.g. http://127.0.0.1:8080
|
||||
final String password;
|
||||
@@ -49,15 +61,97 @@ final class CyberStrikeAIClient {
|
||||
void onDone();
|
||||
}
|
||||
|
||||
boolean hasActiveRequest() {
|
||||
return activeConnection.get() != null;
|
||||
}
|
||||
|
||||
void cancelActiveRequest() {
|
||||
HttpURLConnection conn = activeConnection.getAndSet(null);
|
||||
if (conn != null) {
|
||||
try {
|
||||
conn.disconnect();
|
||||
} catch (Exception ignored) {
|
||||
}
|
||||
}
|
||||
Thread t = activeThread.getAndSet(null);
|
||||
if (t != null) {
|
||||
t.interrupt();
|
||||
}
|
||||
}
|
||||
|
||||
String loginAndValidate(Config cfg) throws IOException {
|
||||
String token = login(cfg.baseUrl, cfg.password);
|
||||
validate(cfg.baseUrl, token);
|
||||
return token;
|
||||
Thread worker = Thread.currentThread();
|
||||
java.util.Timer deadline = new java.util.Timer("CyberStrikeAI-AuthDeadline", true);
|
||||
deadline.schedule(new java.util.TimerTask() {
|
||||
@Override
|
||||
public void run() {
|
||||
worker.interrupt();
|
||||
cancelActiveRequest();
|
||||
}
|
||||
}, AUTH_OVERALL_TIMEOUT_MS);
|
||||
try {
|
||||
String token = login(cfg.baseUrl, cfg.password);
|
||||
if (Thread.interrupted()) {
|
||||
throw timeoutIOException();
|
||||
}
|
||||
validate(cfg.baseUrl, token);
|
||||
if (Thread.interrupted()) {
|
||||
throw timeoutIOException();
|
||||
}
|
||||
return token;
|
||||
} catch (SocketTimeoutException e) {
|
||||
throw timeoutIOException();
|
||||
} finally {
|
||||
deadline.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
private static IOException timeoutIOException() {
|
||||
return new IOException("Connection timed out (~" + (AUTH_OVERALL_TIMEOUT_MS / 1000)
|
||||
+ "s). Check host/port and HTTPS checkbox.");
|
||||
}
|
||||
|
||||
private void trackConnection(HttpURLConnection conn) {
|
||||
activeThread.set(Thread.currentThread());
|
||||
activeConnection.set(conn);
|
||||
}
|
||||
|
||||
private void releaseConnection(HttpURLConnection conn) {
|
||||
if (activeConnection.compareAndSet(conn, null)) {
|
||||
activeThread.set(null);
|
||||
}
|
||||
}
|
||||
|
||||
private static boolean isCancelled(Throwable e) {
|
||||
if (e == null) {
|
||||
return Thread.currentThread().isInterrupted();
|
||||
}
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof InterruptedIOException) {
|
||||
return true;
|
||||
}
|
||||
if (e instanceof SocketTimeoutException) {
|
||||
return false;
|
||||
}
|
||||
Throwable cause = e.getCause();
|
||||
if (cause != null && cause != e) {
|
||||
return isCancelled(cause);
|
||||
}
|
||||
String msg = e.getMessage();
|
||||
return msg != null && (
|
||||
msg.toLowerCase().contains("cancel")
|
||||
|| msg.toLowerCase().contains("abort")
|
||||
|| msg.toLowerCase().contains("closed")
|
||||
);
|
||||
}
|
||||
|
||||
private String login(String baseUrl, String password) throws IOException {
|
||||
URL url = new URL(baseUrl + "/api/auth/login");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||
trackConnection(conn);
|
||||
try {
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
@@ -92,11 +186,16 @@ final class CyberStrikeAIClient {
|
||||
throw new IOException("Login response missing token. Check backend address and credentials.");
|
||||
}
|
||||
return token;
|
||||
} finally {
|
||||
releaseConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
private void validate(String baseUrl, String token) throws IOException {
|
||||
URL url = new URL(baseUrl + "/api/auth/validate");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||
trackConnection(conn);
|
||||
try {
|
||||
conn.setRequestMethod("GET");
|
||||
conn.setRequestProperty("Authorization", "Bearer " + token);
|
||||
int code = conn.getResponseCode();
|
||||
@@ -104,6 +203,9 @@ final class CyberStrikeAIClient {
|
||||
if (code < 200 || code >= 300) {
|
||||
throw new IOException("Validate failed (" + code + "): " + resp);
|
||||
}
|
||||
} finally {
|
||||
releaseConnection(conn);
|
||||
}
|
||||
}
|
||||
|
||||
void streamTest(Config cfg, String token, String message, StreamListener listener) {
|
||||
@@ -117,11 +219,12 @@ final class CyberStrikeAIClient {
|
||||
payload.put("orchestration", cfg.agentMode.orchestration);
|
||||
}
|
||||
|
||||
new Thread(() -> {
|
||||
Thread worker = new Thread(() -> {
|
||||
HttpURLConnection conn = null;
|
||||
try {
|
||||
URL url = new URL(urlStr);
|
||||
conn = (HttpURLConnection) url.openConnection();
|
||||
conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, 0);
|
||||
trackConnection(conn);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
@@ -142,6 +245,9 @@ final class CyberStrikeAIClient {
|
||||
try (BufferedReader br = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8))) {
|
||||
String line;
|
||||
while ((line = br.readLine()) != null) {
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
break;
|
||||
}
|
||||
// SSE format: "data: {json}"
|
||||
if (line.startsWith("data:")) {
|
||||
String json = line.substring("data:".length()).trim();
|
||||
@@ -156,15 +262,25 @@ final class CyberStrikeAIClient {
|
||||
}
|
||||
}
|
||||
}
|
||||
listener.onDone();
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
listener.onError("Cancelled.", null);
|
||||
} else {
|
||||
listener.onDone();
|
||||
}
|
||||
} catch (Exception e) {
|
||||
listener.onError(e.getMessage(), e);
|
||||
if (isCancelled(e)) {
|
||||
listener.onError("Cancelled.", e);
|
||||
} else {
|
||||
listener.onError(e.getMessage(), e);
|
||||
}
|
||||
} finally {
|
||||
if (conn != null) {
|
||||
releaseConnection(conn);
|
||||
conn.disconnect();
|
||||
}
|
||||
}
|
||||
}, "CyberStrikeAI-Stream").start();
|
||||
}, "CyberStrikeAI-Stream");
|
||||
worker.start();
|
||||
}
|
||||
|
||||
void cancelByConversationId(String baseUrl, String token, String conversationId) throws IOException {
|
||||
@@ -172,7 +288,7 @@ final class CyberStrikeAIClient {
|
||||
throw new IOException("Missing conversationId.");
|
||||
}
|
||||
URL url = new URL(baseUrl + "/api/agent-loop/cancel");
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
HttpURLConnection conn = SslTrustAll.open(url, AUTH_CONNECT_TIMEOUT_MS, AUTH_READ_TIMEOUT_MS);
|
||||
conn.setRequestMethod("POST");
|
||||
conn.setDoOutput(true);
|
||||
conn.setRequestProperty("Content-Type", "application/json");
|
||||
|
||||
+130
-34
@@ -14,6 +14,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private final JTextField hostField = new JTextField("127.0.0.1");
|
||||
private final JTextField portField = new JTextField("8080");
|
||||
private final JCheckBox useHttpsBox = new JCheckBox("HTTPS", true);
|
||||
private final JPasswordField passwordField = new JPasswordField();
|
||||
private final JComboBox<String> agentModeBox = new JComboBox<>(new String[]{
|
||||
"Native ReAct", "Eino Single (ADK)", "Deep (DeepAgent)", "Plan-Execute", "Supervisor"
|
||||
@@ -29,6 +30,10 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private final JTextArea progressArea = new JTextArea();
|
||||
private final JTextArea finalRawArea = new JTextArea(); // raw final stream / final response
|
||||
private JScrollPane progressScrollPane;
|
||||
private JScrollPane finalRawScrollPane;
|
||||
/** 距底部在此像素内视为「跟随滚动」,否则用户上拉阅读时不抢滚动条 */
|
||||
private static final int SCROLL_FOLLOW_THRESHOLD_PX = 48;
|
||||
private final JEditorPane markdownPane = new JEditorPane("text/html", "");
|
||||
private final CardLayout outputCardsLayout = new CardLayout();
|
||||
private final JPanel outputCards = new JPanel(outputCardsLayout);
|
||||
@@ -41,6 +46,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private final CyberStrikeAIClient client = new CyberStrikeAIClient();
|
||||
private final AtomicReference<String> tokenRef = new AtomicReference<>("");
|
||||
private final AtomicReference<Thread> validateThreadRef = new AtomicReference<>();
|
||||
|
||||
private final DefaultListModel<TestRun> testListModel = new DefaultListModel<>();
|
||||
private final JList<TestRun> testList = new JList<>(testListModel);
|
||||
@@ -107,6 +113,8 @@ final class CyberStrikeAITab implements ITab {
|
||||
row1.add(hostField);
|
||||
row1.add(new JLabel("Port"));
|
||||
row1.add(portField);
|
||||
useHttpsBox.setToolTipText("Use https:// for CyberStrikeAI (self-signed certs are trusted automatically)");
|
||||
row1.add(useHttpsBox);
|
||||
row1.add(new JLabel("Password"));
|
||||
row1.add(passwordField);
|
||||
row1.add(validateButton);
|
||||
@@ -186,15 +194,22 @@ final class CyberStrikeAITab implements ITab {
|
||||
configureTextArea(requestArea, false);
|
||||
configureTextArea(responseArea, false);
|
||||
|
||||
outputCards.add(new JScrollPane(finalRawArea), "raw");
|
||||
finalRawScrollPane = new JScrollPane(finalRawArea);
|
||||
finalRawScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
finalRawScrollPane.getVerticalScrollBar().setUnitIncrement(16);
|
||||
outputCards.add(finalRawScrollPane, "raw");
|
||||
outputCards.add(new JScrollPane(markdownPane), "md");
|
||||
|
||||
outputRoot.add(buildOutputHeader(), BorderLayout.NORTH);
|
||||
outputRoot.add(buildOutputBody(), BorderLayout.CENTER);
|
||||
|
||||
rightTabs.addTab("Output", outputRoot);
|
||||
rightTabs.addTab("Request", new JScrollPane(requestArea));
|
||||
rightTabs.addTab("Response", new JScrollPane(responseArea));
|
||||
JScrollPane requestScroll = new JScrollPane(requestArea);
|
||||
requestScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
rightTabs.addTab("Request", requestScroll);
|
||||
JScrollPane responseScroll = new JScrollPane(responseArea);
|
||||
responseScroll.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
rightTabs.addTab("Response", responseScroll);
|
||||
return rightTabs;
|
||||
}
|
||||
|
||||
@@ -210,12 +225,13 @@ final class CyberStrikeAITab implements ITab {
|
||||
}
|
||||
|
||||
private JComponent buildOutputBody() {
|
||||
JScrollPane progressScroll = new JScrollPane(progressArea);
|
||||
progressScroll.setBorder(BorderFactory.createTitledBorder("Progress"));
|
||||
progressScroll.getVerticalScrollBar().setUnitIncrement(16);
|
||||
progressScrollPane = new JScrollPane(progressArea);
|
||||
progressScrollPane.setBorder(BorderFactory.createTitledBorder("Progress"));
|
||||
progressScrollPane.setHorizontalScrollBarPolicy(ScrollPaneConstants.HORIZONTAL_SCROLLBAR_NEVER);
|
||||
progressScrollPane.getVerticalScrollBar().setUnitIncrement(16);
|
||||
|
||||
JPanel empty = new JPanel();
|
||||
progressContainer.add(progressScroll, "show");
|
||||
progressContainer.add(progressScrollPane, "show");
|
||||
progressContainer.add(empty, "hide");
|
||||
((CardLayout) progressContainer.getLayout()).show(progressContainer, "show");
|
||||
|
||||
@@ -259,10 +275,27 @@ final class CyberStrikeAITab implements ITab {
|
||||
return split;
|
||||
}
|
||||
|
||||
private static boolean isScrollNearBottom(JScrollPane scrollPane) {
|
||||
if (scrollPane == null) {
|
||||
return true;
|
||||
}
|
||||
JScrollBar bar = scrollPane.getVerticalScrollBar();
|
||||
int max = Math.max(0, bar.getMaximum() - bar.getVisibleAmount());
|
||||
return bar.getValue() >= max - SCROLL_FOLLOW_THRESHOLD_PX;
|
||||
}
|
||||
|
||||
private static void scrollPaneToBottom(JScrollPane scrollPane) {
|
||||
if (scrollPane == null) {
|
||||
return;
|
||||
}
|
||||
JScrollBar bar = scrollPane.getVerticalScrollBar();
|
||||
bar.setValue(bar.getMaximum());
|
||||
}
|
||||
|
||||
private static void configureTextArea(JTextArea area, boolean monospaced) {
|
||||
area.setEditable(false);
|
||||
area.setLineWrap(false);
|
||||
area.setWrapStyleWord(false);
|
||||
area.setLineWrap(true);
|
||||
area.setWrapStyleWord(true);
|
||||
if (monospaced) {
|
||||
area.setFont(new Font(Font.MONOSPACED, Font.PLAIN, 12));
|
||||
} else {
|
||||
@@ -381,24 +414,44 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
private void wireActions() {
|
||||
validateButton.addActionListener(e -> {
|
||||
validateButton.setEnabled(false);
|
||||
if ("Cancel".equals(validateButton.getText())) {
|
||||
cancelValidateInProgress();
|
||||
return;
|
||||
}
|
||||
validateButton.setText("Cancel");
|
||||
validateButton.setEnabled(true);
|
||||
stopButton.setEnabled(true);
|
||||
statusLabel.setText("Validating...");
|
||||
log("Validating connection...");
|
||||
new Thread(() -> {
|
||||
log("Validating connection... (max ~10s; click Cancel or Stop to abort)");
|
||||
Thread worker = new Thread(() -> {
|
||||
try {
|
||||
CyberStrikeAIClient.Config cfg = currentConfig();
|
||||
String token = client.loginAndValidate(cfg);
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
return;
|
||||
}
|
||||
tokenRef.set(token);
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("OK (token saved)"));
|
||||
log("Validation OK.");
|
||||
} catch (Exception ex) {
|
||||
tokenRef.set("");
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
||||
log("Validation failed: " + ex.getMessage());
|
||||
if (Thread.currentThread().isInterrupted()) {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Cancelled"));
|
||||
log("Validation cancelled.");
|
||||
} else {
|
||||
SwingUtilities.invokeLater(() -> statusLabel.setText("Failed: " + ex.getMessage()));
|
||||
log("Validation failed: " + ex.getMessage());
|
||||
}
|
||||
} finally {
|
||||
SwingUtilities.invokeLater(() -> validateButton.setEnabled(true));
|
||||
validateThreadRef.set(null);
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
validateButton.setText("Validate");
|
||||
validateButton.setEnabled(true);
|
||||
});
|
||||
}
|
||||
}, "CyberStrikeAI-Validate").start();
|
||||
}, "CyberStrikeAI-Validate");
|
||||
validateThreadRef.set(worker);
|
||||
worker.start();
|
||||
});
|
||||
|
||||
clearButton.addActionListener(e -> {
|
||||
@@ -435,10 +488,23 @@ final class CyberStrikeAITab implements ITab {
|
||||
});
|
||||
|
||||
stopButton.addActionListener(e -> {
|
||||
if ("Cancel".equals(validateButton.getText())) {
|
||||
cancelValidateInProgress();
|
||||
return;
|
||||
}
|
||||
|
||||
String runId = selectedRunId;
|
||||
if (runId != null && client.hasActiveRequest()) {
|
||||
client.cancelActiveRequest();
|
||||
appendProgressToRun(runId, "\n[info] Stream stopped.\n");
|
||||
setRunStatus(runId, "cancelled");
|
||||
return;
|
||||
}
|
||||
|
||||
if (runId == null) return;
|
||||
TestRun run = runs.get(runId);
|
||||
if (run == null) return;
|
||||
|
||||
String token = getToken();
|
||||
if (token == null || token.trim().isEmpty()) {
|
||||
appendProgressToRun(runId, "\n[error] Not validated.\n");
|
||||
@@ -483,7 +549,8 @@ final class CyberStrikeAITab implements ITab {
|
||||
String host = hostField.getText().trim();
|
||||
String port = portField.getText().trim();
|
||||
String password = new String(passwordField.getPassword());
|
||||
String baseUrl = "http://" + host + ":" + port;
|
||||
String scheme = useHttpsBox.isSelected() ? "https" : "http";
|
||||
String baseUrl = scheme + "://" + host + ":" + port;
|
||||
int idx = agentModeBox.getSelectedIndex();
|
||||
CyberStrikeAIClient.AgentMode mode = (idx >= 0 && idx < AGENT_MODES.length)
|
||||
? AGENT_MODES[idx]
|
||||
@@ -567,10 +634,31 @@ final class CyberStrikeAITab implements ITab {
|
||||
run.progressBuffer.append(s);
|
||||
}
|
||||
if (runId.equals(selectedRunId)) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.append(s);
|
||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> appendProgressUi(s, false));
|
||||
}
|
||||
}
|
||||
|
||||
private void appendProgressUi(String s, boolean forceFollow) {
|
||||
JScrollBar bar = progressScrollPane != null ? progressScrollPane.getVerticalScrollBar() : null;
|
||||
int scrollBefore = bar != null ? bar.getValue() : 0;
|
||||
boolean follow = forceFollow || isScrollNearBottom(progressScrollPane);
|
||||
progressArea.append(s);
|
||||
if (follow) {
|
||||
scrollPaneToBottom(progressScrollPane);
|
||||
} else if (bar != null) {
|
||||
bar.setValue(scrollBefore);
|
||||
}
|
||||
}
|
||||
|
||||
private void appendFinalUi(String s, boolean forceFollow) {
|
||||
JScrollBar bar = finalRawScrollPane != null ? finalRawScrollPane.getVerticalScrollBar() : null;
|
||||
int scrollBefore = bar != null ? bar.getValue() : 0;
|
||||
boolean follow = forceFollow || isScrollNearBottom(finalRawScrollPane);
|
||||
finalRawArea.append(s);
|
||||
if (follow) {
|
||||
scrollPaneToBottom(finalRawScrollPane);
|
||||
} else if (bar != null) {
|
||||
bar.setValue(scrollBefore);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -620,10 +708,7 @@ final class CyberStrikeAITab implements ITab {
|
||||
run.finalBuffer.append(s);
|
||||
}
|
||||
if (runId.equals(selectedRunId)) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
finalRawArea.append(s);
|
||||
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> appendFinalUi(s, false));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -656,9 +741,9 @@ final class CyberStrikeAITab implements ITab {
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.setText(progress);
|
||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||
scrollPaneToBottom(progressScrollPane);
|
||||
finalRawArea.setText(fin);
|
||||
finalRawArea.setCaretPosition(finalRawArea.getDocument().getLength());
|
||||
scrollPaneToBottom(finalRawScrollPane);
|
||||
requestArea.setText(run.requestRaw == null ? "" : run.requestRaw);
|
||||
responseArea.setText(run.responseRaw == null ? "" : run.responseRaw);
|
||||
refreshOutputView();
|
||||
@@ -682,25 +767,36 @@ final class CyberStrikeAITab implements ITab {
|
||||
|
||||
void clearAndShowStreamHeader(String title) {
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.setText("");
|
||||
finalRawArea.setText(title + "\n\n");
|
||||
progressArea.setText("[*] " + title + "\n\n");
|
||||
finalRawArea.setText("");
|
||||
markdownPane.setText("");
|
||||
});
|
||||
}
|
||||
|
||||
// Legacy helpers kept for Validate logging
|
||||
void appendStreamLine(String s) {
|
||||
if (s == null) return;
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
progressArea.append(s);
|
||||
progressArea.append("\n");
|
||||
progressArea.setCaretPosition(progressArea.getDocument().getLength());
|
||||
});
|
||||
SwingUtilities.invokeLater(() -> appendProgressUi(s + "\n", false));
|
||||
}
|
||||
|
||||
private void log(String s) {
|
||||
appendStreamLine("[*] " + s);
|
||||
}
|
||||
|
||||
private void cancelValidateInProgress() {
|
||||
client.cancelActiveRequest();
|
||||
Thread t = validateThreadRef.getAndSet(null);
|
||||
if (t != null) {
|
||||
t.interrupt();
|
||||
}
|
||||
SwingUtilities.invokeLater(() -> {
|
||||
statusLabel.setText("Cancelled");
|
||||
validateButton.setText("Validate");
|
||||
validateButton.setEnabled(true);
|
||||
});
|
||||
log("Validation cancelled.");
|
||||
}
|
||||
|
||||
private void applyFilter() {
|
||||
String q = searchField.getText();
|
||||
if (q == null) q = "";
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package burp;
|
||||
|
||||
import javax.net.ssl.HostnameVerifier;
|
||||
import javax.net.ssl.HttpsURLConnection;
|
||||
import javax.net.ssl.SSLSocketFactory;
|
||||
import javax.net.ssl.SSLContext;
|
||||
import javax.net.ssl.TrustManager;
|
||||
import javax.net.ssl.X509TrustManager;
|
||||
import java.io.IOException;
|
||||
import java.net.HttpURLConnection;
|
||||
import java.net.InetSocketAddress;
|
||||
import java.net.Socket;
|
||||
import java.net.URL;
|
||||
import java.security.cert.X509Certificate;
|
||||
|
||||
/**
|
||||
* Opens HTTPS connections without validating server certificates (self-signed / local dev).
|
||||
* Applied per-connection only; does not change JVM-wide defaults for other Burp components.
|
||||
*/
|
||||
final class SslTrustAll {
|
||||
|
||||
private static volatile SSLSocketFactory socketFactory;
|
||||
private static final HostnameVerifier TRUST_ALL_HOSTS = (hostname, session) -> true;
|
||||
|
||||
private SslTrustAll() {
|
||||
}
|
||||
|
||||
static HttpURLConnection open(URL url) throws IOException {
|
||||
return open(url, 5_000, 30_000);
|
||||
}
|
||||
|
||||
static HttpURLConnection open(URL url, int connectTimeoutMs, int readTimeoutMs) throws IOException {
|
||||
HttpURLConnection conn = (HttpURLConnection) url.openConnection();
|
||||
conn.setConnectTimeout(connectTimeoutMs);
|
||||
conn.setReadTimeout(readTimeoutMs);
|
||||
if (conn instanceof HttpsURLConnection) {
|
||||
HttpsURLConnection https = (HttpsURLConnection) conn;
|
||||
https.setSSLSocketFactory(new TimeoutSslSocketFactory(socketFactory(), connectTimeoutMs, readTimeoutMs));
|
||||
https.setHostnameVerifier(TRUST_ALL_HOSTS);
|
||||
}
|
||||
return conn;
|
||||
}
|
||||
|
||||
private static SSLSocketFactory socketFactory() {
|
||||
SSLSocketFactory sf = socketFactory;
|
||||
if (sf != null) {
|
||||
return sf;
|
||||
}
|
||||
synchronized (SslTrustAll.class) {
|
||||
sf = socketFactory;
|
||||
if (sf != null) {
|
||||
return sf;
|
||||
}
|
||||
try {
|
||||
TrustManager[] trustAll = new TrustManager[]{
|
||||
new X509TrustManager() {
|
||||
@Override
|
||||
public X509Certificate[] getAcceptedIssuers() {
|
||||
return new X509Certificate[0];
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkClientTrusted(X509Certificate[] chain, String authType) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void checkServerTrusted(X509Certificate[] chain, String authType) {
|
||||
}
|
||||
}
|
||||
};
|
||||
SSLContext ctx = SSLContext.getInstance("TLS");
|
||||
ctx.init(null, trustAll, new java.security.SecureRandom());
|
||||
sf = ctx.getSocketFactory();
|
||||
socketFactory = sf;
|
||||
return sf;
|
||||
} catch (Exception e) {
|
||||
throw new RuntimeException("Failed to initialize trust-all TLS", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Ensures TCP connect + socket read respect timeouts (plain HttpURLConnection SSL can hang longer). */
|
||||
private static final class TimeoutSslSocketFactory extends SSLSocketFactory {
|
||||
private final SSLSocketFactory delegate;
|
||||
private final int connectTimeoutMs;
|
||||
private final int readTimeoutMs;
|
||||
|
||||
TimeoutSslSocketFactory(SSLSocketFactory delegate, int connectTimeoutMs, int readTimeoutMs) {
|
||||
this.delegate = delegate;
|
||||
this.connectTimeoutMs = connectTimeoutMs;
|
||||
this.readTimeoutMs = readTimeoutMs;
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getDefaultCipherSuites() {
|
||||
return delegate.getDefaultCipherSuites();
|
||||
}
|
||||
|
||||
@Override
|
||||
public String[] getSupportedCipherSuites() {
|
||||
return delegate.getSupportedCipherSuites();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket() throws IOException {
|
||||
return tune(delegate.createSocket());
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(Socket s, String host, int port, boolean autoClose) throws IOException {
|
||||
return tune(delegate.createSocket(s, host, port, autoClose));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, host, port, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(String host, int port, java.net.InetAddress localHost, int localPort) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.bind(new InetSocketAddress(localHost, localPort));
|
||||
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, host, port, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(java.net.InetAddress host, int port) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.connect(new InetSocketAddress(host, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, host.getHostName(), port, true));
|
||||
}
|
||||
|
||||
@Override
|
||||
public Socket createSocket(java.net.InetAddress address, int port, java.net.InetAddress localAddress, int localPort) throws IOException {
|
||||
Socket plain = new Socket();
|
||||
plain.bind(new InetSocketAddress(localAddress, localPort));
|
||||
plain.connect(new InetSocketAddress(address, port), connectTimeoutMs);
|
||||
return tune(delegate.createSocket(plain, address.getHostName(), port, true));
|
||||
}
|
||||
|
||||
private Socket tune(Socket socket) throws IOException {
|
||||
socket.setSoTimeout(readTimeoutMs);
|
||||
return socket;
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
Binary file not shown.
BIN
Binary file not shown.
+4
@@ -1,12 +1,16 @@
|
||||
burp/SslTrustAll.class
|
||||
burp/SslTrustAll$TimeoutSslSocketFactory.class
|
||||
burp/CyberStrikeAIClient$StreamListener.class
|
||||
burp/CyberStrikeAIClient$Config.class
|
||||
burp/CyberStrikeAIClient$AgentMode.class
|
||||
burp/MarkdownRenderer.class
|
||||
burp/SimpleJson.class
|
||||
burp/CyberStrikeAIClient.class
|
||||
burp/CyberStrikeAIClient$1.class
|
||||
burp/CyberStrikeAITab$DotIcon.class
|
||||
burp/CyberStrikeAITab.class
|
||||
burp/CyberStrikeAITab$1.class
|
||||
burp/SslTrustAll$1.class
|
||||
burp/BurpExtender$1.class
|
||||
burp/BurpExtender.class
|
||||
burp/CyberStrikeAITab$TestRun.class
|
||||
|
||||
+1
@@ -4,3 +4,4 @@
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/HttpMessageFormatter.java
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/MarkdownRenderer.java
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SimpleJson.java
|
||||
/Users/temp/Downloads/CyberStrikeAI-main/plugins/burp-suite/cyberstrikeai-burp-extension/src/main/java/burp/SslTrustAll.java
|
||||
|
||||
+28
-3
@@ -857,10 +857,35 @@
|
||||
background: var(--c2-surface);
|
||||
border-radius: var(--c2-radius);
|
||||
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 {
|
||||
text-align: left;
|
||||
@@ -1261,7 +1286,7 @@
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
z-index: 10050;
|
||||
padding: 24px;
|
||||
animation: c2-fade-in 0.15s ease-out;
|
||||
}
|
||||
|
||||
+1200
-23
File diff suppressed because it is too large
Load Diff
@@ -1306,6 +1306,35 @@
|
||||
"noCallsYet": "No calls yet",
|
||||
"unknownTool": "Unknown tool",
|
||||
"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",
|
||||
"columnStatus": "Status",
|
||||
"columnStartTime": "Start time",
|
||||
@@ -1486,6 +1515,11 @@
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"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",
|
||||
"clear": "Clear",
|
||||
"vulnId": "Vuln ID",
|
||||
|
||||
@@ -1295,6 +1295,35 @@
|
||||
"noCallsYet": "暂无调用",
|
||||
"unknownTool": "未知工具",
|
||||
"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": "工具",
|
||||
"columnStatus": "状态",
|
||||
"columnStartTime": "开始时间",
|
||||
@@ -1475,6 +1504,11 @@
|
||||
},
|
||||
"vulnerabilityPage": {
|
||||
"statTotal": "总漏洞数",
|
||||
"statClickAll": "查看全部(清除严重度筛选)",
|
||||
"statClickFilter": "点击按此严重度筛选;再次点击清除",
|
||||
"advancedFilters": "高级筛选",
|
||||
"activeFilters": "已选条件",
|
||||
"chipRemove": "移除",
|
||||
"filter": "筛选",
|
||||
"clear": "清除",
|
||||
"vulnId": "漏洞ID",
|
||||
|
||||
+48
-20
@@ -151,6 +151,25 @@
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
/** 任务列表操作按钮(查看/取消/删除)— 事件委托 */
|
||||
function bindC2TaskActionDelegation() {
|
||||
if (document.documentElement.dataset.c2TaskActionsBound === '1') return;
|
||||
document.documentElement.dataset.c2TaskActionsBound = '1';
|
||||
document.addEventListener('click', function(e) {
|
||||
const btn = e.target.closest('[data-c2-task-action]');
|
||||
if (!btn) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
const action = btn.getAttribute('data-c2-task-action');
|
||||
const id = btn.getAttribute('data-task-id');
|
||||
if (!id) return;
|
||||
if (action === 'view') C2.viewTask(id);
|
||||
else if (action === 'cancel') C2.cancelTask(id);
|
||||
else if (action === 'delete') C2.deleteTaskById(id);
|
||||
});
|
||||
}
|
||||
bindC2TaskActionDelegation();
|
||||
|
||||
/** 监听器表单:Malleable Profile 下拉选项 HTML(value / 文本已转义) */
|
||||
function listenerProfileSelectHtml(selectedProfileId) {
|
||||
const sel = selectedProfileId ? String(selectedProfileId) : '';
|
||||
@@ -1293,14 +1312,17 @@
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = tasks.map(t => `
|
||||
container.innerHTML = tasks.map(t => {
|
||||
const rawId = t.id || '';
|
||||
return `
|
||||
<div class="c2-task-item-compact">
|
||||
<span class="c2-task-status-dot ${t.status}"></span>
|
||||
<span class="c2-task-type">${t.taskType}</span>
|
||||
<span class="c2-task-status-dot ${escapeHtml(t.status || '')}"></span>
|
||||
<span class="c2-task-type">${escapeHtml(t.taskType || '')}</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>
|
||||
`).join('');
|
||||
`;
|
||||
}).join('');
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1334,13 +1356,12 @@
|
||||
<th>${escapeHtml(c2t('c2.tasks.colStatus'))}</th>
|
||||
<th>${escapeHtml(c2t('c2.tasks.colDuration'))}</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>
|
||||
</thead>
|
||||
<tbody>
|
||||
${C2.tasks.map(t => {
|
||||
const rawId = t.id || '';
|
||||
const idJson = JSON.stringify(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' : '-';
|
||||
return `
|
||||
@@ -1356,12 +1377,14 @@
|
||||
<td><span class="c2-status-badge ${escapeHtml(t.status || '')}">${escapeHtml(taskStatusLabel(t.status))}</span></td>
|
||||
<td>${formatDuration(t.durationMs)}</td>
|
||||
<td>${formatTime(t.createdAt)}</td>
|
||||
<td>
|
||||
<button type="button" class="btn-ghost btn-sm" onclick="C2.viewTask(${idJson})">${escapeHtml(c2t('c2.tasks.view'))}</button>
|
||||
<td class="c2-task-table-col-actions">
|
||||
<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'
|
||||
? `<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>
|
||||
</tr>
|
||||
`;
|
||||
@@ -1387,10 +1410,10 @@
|
||||
</div>
|
||||
<div class="c2-modal-body">
|
||||
<div class="c2-task-detail">
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelId'))}:</strong> ${t.id}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${t.sessionId}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelType'))}:</strong> ${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.labelId'))}:</strong> ${escapeHtml(t.id || '')}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelSession'))}:</strong> ${escapeHtml(t.sessionId || '')}</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 ${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.labelSent'))}:</strong> ${formatTime(t.sentAt)}</div>
|
||||
<div><strong>${escapeHtml(c2t('c2.tasks.labelCompleted'))}:</strong> ${formatTime(t.completedAt)}</div>
|
||||
@@ -1416,19 +1439,24 @@
|
||||
renderTaskModal(local);
|
||||
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);
|
||||
});
|
||||
else showToast(c2t('c2.tasks.emptyAll'), 'warn');
|
||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||
};
|
||||
|
||||
C2.cancelTask = function(id) {
|
||||
apiRequest('POST', `${API_BASE}/tasks/${id}/cancel`, {}).then(data => {
|
||||
if (data.error) showToast(data.error, 'error');
|
||||
apiRequest('POST', `${API_BASE}/tasks/${encodeURIComponent(id)}/cancel`, {}).then(data => {
|
||||
if (data.error) showToast(String(data.error), 'error');
|
||||
else {
|
||||
showToast(c2t('c2.tasks.toastCancelled'), 'success');
|
||||
C2.loadTasks(C2.tasksPage || 1);
|
||||
}
|
||||
});
|
||||
}).catch(err => showToast(err.message || String(err), 'error'));
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
|
||||
+544
-46
@@ -2982,6 +2982,9 @@ async function applyMonitorFilters() {
|
||||
const toolFilter = document.getElementById('monitor-tool-filter');
|
||||
const status = statusFilter ? statusFilter.value : 'all';
|
||||
const tool = toolFilter ? (toolFilter.value.trim() || 'all') : 'all';
|
||||
if (toolFilter) {
|
||||
toolFilter.classList.toggle('is-filter-active', tool !== 'all');
|
||||
}
|
||||
// 当筛选条件改变时,从后端重新获取数据
|
||||
await refreshMonitorPanelWithFilter(status, tool);
|
||||
}
|
||||
@@ -3045,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) {
|
||||
const container = document.getElementById('monitor-stats');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
const entries = Object.values(statsMap);
|
||||
const entries = normalizeMonitorStatsEntries(statsMap);
|
||||
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>';
|
||||
const subtitle = document.getElementById('monitor-stats-subtitle');
|
||||
if (subtitle) subtitle.hidden = true;
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算总体汇总
|
||||
const totals = entries.reduce(
|
||||
(acc, item) => {
|
||||
acc.total += item.totalCalls || 0;
|
||||
@@ -3073,59 +3466,154 @@ function renderMonitorStats(statsMap = {}, lastFetchedAt = null) {
|
||||
{ total: 0, success: 0, failed: 0, lastCallTime: null }
|
||||
);
|
||||
|
||||
const successRate = totals.total > 0 ? ((totals.success / totals.total) * 100).toFixed(1) : '0.0';
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : undefined;
|
||||
const lastUpdatedText = lastFetchedAt ? (lastFetchedAt.toLocaleString ? lastFetchedAt.toLocaleString(locale || 'en-US') : String(lastFetchedAt)) : 'N/A';
|
||||
const noCallsYet = typeof window.t === 'function' ? window.t('mcpMonitor.noCallsYet') : '暂无调用';
|
||||
const lastCallText = totals.lastCallTime ? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale || 'en-US') : String(totals.lastCallTime)) : noCallsYet;
|
||||
const totalCallsLabel = typeof window.t === 'function' ? window.t('mcpMonitor.totalCalls') : '总调用次数';
|
||||
const successFailedLabel = typeof window.t === 'function' ? window.t('mcpMonitor.successFailed', { success: totals.success, failed: totals.failed }) : `成功 ${totals.success} / 失败 ${totals.failed}`;
|
||||
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') : '最后刷新时间';
|
||||
const successRateNum = totals.total > 0 ? (totals.success / totals.total) * 100 : 0;
|
||||
const successRate = successRateNum.toFixed(1);
|
||||
const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
|
||||
const noCallsYet = mcpMonitorT('noCallsYet') || '暂无调用';
|
||||
const lastCallText = totals.lastCallTime
|
||||
? (totals.lastCallTime.toLocaleString ? totals.lastCallTime.toLocaleString(locale) : String(totals.lastCallTime))
|
||||
: noCallsYet;
|
||||
|
||||
let html = `
|
||||
<div class="monitor-stat-card">
|
||||
<h4>${escapeHtml(totalCallsLabel)}</h4>
|
||||
<div class="monitor-stat-value">${totals.total}</div>
|
||||
<div class="monitor-stat-meta">${escapeHtml(successFailedLabel)}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<h4>${escapeHtml(successRateLabel)}</h4>
|
||||
<div class="monitor-stat-value">${successRate}%</div>
|
||||
<div class="monitor-stat-meta">${escapeHtml(statsFromAll)}</div>
|
||||
</div>
|
||||
<div class="monitor-stat-card">
|
||||
<h4>${escapeHtml(lastCallLabel)}</h4>
|
||||
<div class="monitor-stat-value" style="font-size:1rem;">${escapeHtml(lastCallText)}</div>
|
||||
<div class="monitor-stat-meta">${escapeHtml(lastRefreshLabel)}:${escapeHtml(lastUpdatedText)}</div>
|
||||
</div>
|
||||
`;
|
||||
const totalCallsLabel = mcpMonitorT('totalCalls') || '总调用次数';
|
||||
const successRateLabel = mcpMonitorT('successRate') || '成功率';
|
||||
const lastCallLabel = mcpMonitorT('lastCall') || '最近一次调用';
|
||||
const statsFromAll = mcpMonitorT('statsFromAllTools') || '统计自全部工具调用';
|
||||
const successPill = mcpMonitorT('successCount', { n: totals.success }) || `成功 ${totals.success}`;
|
||||
const failedPill = mcpMonitorT('failedCount', { n: totals.failed }) || `失败 ${totals.failed}`;
|
||||
const rateTone = getMcpStatsRateTone(successRateNum);
|
||||
let rateSubText = mcpMonitorT('rateHealthy') || '运行平稳';
|
||||
if (successRateNum < 80) rateSubText = mcpMonitorT('rateCritical') || '失败率偏高';
|
||||
else if (successRateNum < 95) rateSubText = mcpMonitorT('rateWarning') || '存在失败调用';
|
||||
|
||||
const toolFilterEl = document.getElementById('monitor-tool-filter');
|
||||
const activeToolFilter = toolFilterEl ? toolFilterEl.value.trim() : '';
|
||||
|
||||
// 显示最多前4个工具的统计(过滤掉 totalCalls 为 0 的工具)
|
||||
const topTools = entries
|
||||
.filter(tool => (tool.totalCalls || 0) > 0)
|
||||
.slice()
|
||||
.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') : '未知工具';
|
||||
topTools.forEach(tool => {
|
||||
const toolSuccessRate = tool.totalCalls > 0 ? ((tool.successCalls || 0) / tool.totalCalls * 100).toFixed(1) : '0.0';
|
||||
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}%`;
|
||||
html += `
|
||||
<div class="monitor-stat-card">
|
||||
<h4>${escapeHtml(tool.toolName || unknownToolLabel)}</h4>
|
||||
<div class="monitor-stat-value">${tool.totalCalls || 0}</div>
|
||||
<div class="monitor-stat-meta">
|
||||
${escapeHtml(toolMeta)}
|
||||
</div>
|
||||
</div>
|
||||
const maxToolCalls = topTools.length > 0 ? (topTools[0].totalCalls || 0) : 0;
|
||||
const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具';
|
||||
const topToolsTitle = mcpMonitorT('topToolsTitle', { n: MCP_STATS_TOP_N }) || `工具调用 Top ${MCP_STATS_TOP_N}`;
|
||||
const toolsHint = mcpMonitorT('clickToFilterTool') || '点击行筛选下方执行记录';
|
||||
const barLegend = mcpMonitorT('barVolumeLegend') || '条长表示相对调用量';
|
||||
const successRateAria = mcpMonitorT('successRateAria', { rate: successRate }) || `成功率 ${successRate}%`;
|
||||
|
||||
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>';
|
||||
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>';
|
||||
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>';
|
||||
|
||||
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') {
|
||||
@@ -3622,4 +4110,14 @@ document.addEventListener('languagechange', function () {
|
||||
updateBatchActionsState();
|
||||
loadActiveTasks();
|
||||
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;
|
||||
|
||||
+518
-35
@@ -61,6 +61,24 @@ let vulnerabilityPagination = {
|
||||
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= 同步筛选(通知/对话菜单/任务管理联动)
|
||||
function syncVulnerabilityFiltersFromLocationHash() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
@@ -74,23 +92,31 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
const tid = (params.get('task_id') || '').trim();
|
||||
const sev = (params.get('severity') || '').trim();
|
||||
const st = (params.get('status') || '').trim();
|
||||
if (!vid && !cid && !tid && !sev && !st) {
|
||||
const convTag = (params.get('conversation_tag') || '').trim();
|
||||
const taskTag = (params.get('task_tag') || '').trim();
|
||||
if (!vid && !cid && !tid && !sev && !st && !convTag && !taskTag) {
|
||||
return;
|
||||
}
|
||||
|
||||
vulnerabilityFilters.id = '';
|
||||
vulnerabilityFilters.conversation_id = '';
|
||||
vulnerabilityFilters.task_id = '';
|
||||
vulnerabilityFilters.conversation_tag = '';
|
||||
vulnerabilityFilters.task_tag = '';
|
||||
vulnerabilityFilters.severity = '';
|
||||
vulnerabilityFilters.status = '';
|
||||
const idEl = document.getElementById('vulnerability-id-filter');
|
||||
const convEl = document.getElementById('vulnerability-conversation-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 (convEl) convEl.value = '';
|
||||
if (taskEl) taskEl.value = '';
|
||||
if (convTagEl) convTagEl.value = '';
|
||||
if (taskTagEl) taskTagEl.value = '';
|
||||
if (sevEl) sevEl.value = '';
|
||||
if (stEl) stEl.value = '';
|
||||
|
||||
@@ -106,6 +132,14 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
vulnerabilityFilters.task_id = 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;
|
||||
@@ -115,17 +149,457 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
if (stEl) stEl.value = st;
|
||||
}
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
if (hasVulnerabilityAdvancedFiltersActive()) {
|
||||
setVulnerabilityAdvancedFiltersOpen(true, false);
|
||||
}
|
||||
syncVulnerabilityStatCardActiveState();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
}
|
||||
|
||||
// 初始化漏洞管理页面
|
||||
function initVulnerabilityPage() {
|
||||
// 从localStorage加载每页条数设置
|
||||
vulnerabilityPagination.pageSize = getVulnerabilityPageSize();
|
||||
initVulnerabilityStatCards();
|
||||
initVulnerabilityFilterPanel();
|
||||
syncVulnerabilityFiltersFromLocationHash();
|
||||
updateVulnerabilityFilterPanelState();
|
||||
renderVulnerabilityFilterChips();
|
||||
loadVulnerabilityFilterOptions();
|
||||
loadVulnerabilityStats();
|
||||
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() {
|
||||
try {
|
||||
@@ -169,15 +643,33 @@ function updateVulnerabilityStats(stats) {
|
||||
by_status: {}
|
||||
};
|
||||
}
|
||||
|
||||
document.getElementById('stat-total').textContent = stats.total || 0;
|
||||
|
||||
|
||||
const total = stats.total || 0;
|
||||
const bySeverity = stats.by_severity || {};
|
||||
document.getElementById('stat-critical').textContent = bySeverity.critical || 0;
|
||||
document.getElementById('stat-high').textContent = bySeverity.high || 0;
|
||||
document.getElementById('stat-medium').textContent = bySeverity.medium || 0;
|
||||
document.getElementById('stat-low').textContent = bySeverity.low || 0;
|
||||
document.getElementById('stat-info').textContent = bySeverity.info || 0;
|
||||
|
||||
const totalEl = document.getElementById('stat-total');
|
||||
if (totalEl) {
|
||||
totalEl.textContent = String(total);
|
||||
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();
|
||||
}
|
||||
|
||||
// 加载漏洞列表
|
||||
@@ -591,32 +1083,26 @@ function closeVulnerabilityModal() {
|
||||
currentVulnerabilityId = null;
|
||||
}
|
||||
|
||||
// 筛选漏洞
|
||||
// 筛选漏洞(应用当前表单条件)
|
||||
function filterVulnerabilities() {
|
||||
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;
|
||||
|
||||
// 重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
// 清除筛选
|
||||
function clearVulnerabilityFilters() {
|
||||
document.getElementById('vulnerability-id-filter').value = '';
|
||||
document.getElementById('vulnerability-conversation-filter').value = '';
|
||||
document.getElementById('vulnerability-task-filter').value = '';
|
||||
document.getElementById('vulnerability-conversation-tag-filter').value = '';
|
||||
document.getElementById('vulnerability-task-tag-filter').value = '';
|
||||
document.getElementById('vulnerability-severity-filter').value = '';
|
||||
document.getElementById('vulnerability-status-filter').value = '';
|
||||
const fields = [
|
||||
'vulnerability-id-filter',
|
||||
'vulnerability-conversation-filter',
|
||||
'vulnerability-task-filter',
|
||||
'vulnerability-conversation-tag-filter',
|
||||
'vulnerability-task-tag-filter',
|
||||
'vulnerability-severity-filter',
|
||||
'vulnerability-status-filter'
|
||||
];
|
||||
fields.forEach(function (id) {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.value = '';
|
||||
});
|
||||
|
||||
vulnerabilityFilters = {
|
||||
id: '',
|
||||
@@ -628,11 +1114,7 @@ function clearVulnerabilityFilters() {
|
||||
status: ''
|
||||
};
|
||||
|
||||
// 重置到第一页
|
||||
vulnerabilityPagination.currentPage = 1;
|
||||
|
||||
loadVulnerabilityStats();
|
||||
loadVulnerabilities();
|
||||
applyVulnerabilityFilters();
|
||||
}
|
||||
|
||||
// 刷新漏洞
|
||||
@@ -908,6 +1390,7 @@ window.onclick = function(event) {
|
||||
document.addEventListener('languagechange', function () {
|
||||
const page = document.getElementById('page-vulnerabilities');
|
||||
if (page && page.classList.contains('active')) {
|
||||
renderVulnerabilityFilterChips();
|
||||
loadVulnerabilities();
|
||||
}
|
||||
});
|
||||
|
||||
+97
-59
@@ -1082,10 +1082,13 @@
|
||||
<div class="page-content">
|
||||
<div class="monitor-sections">
|
||||
<section class="monitor-section monitor-overview">
|
||||
<div class="section-header">
|
||||
<h3 data-i18n="mcp.execStats">执行统计</h3>
|
||||
<div class="section-header monitor-stats-section-header">
|
||||
<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 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>
|
||||
</section>
|
||||
@@ -1385,89 +1388,124 @@
|
||||
<div class="page-header">
|
||||
<h2 data-i18n="vulnerability.title">漏洞管理</h2>
|
||||
<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-primary" onclick="showAddVulnerabilityModal()" data-i18n="vulnerability.addVuln">添加漏洞</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="page-content">
|
||||
<!-- 统计看板 -->
|
||||
<!-- 统计看板:点击卡片筛选严重度,与下方下拉/地址栏 hash 同步 -->
|
||||
<div class="vulnerability-dashboard" id="vulnerability-dashboard">
|
||||
<div class="dashboard-stats">
|
||||
<div class="stat-card">
|
||||
<div class="dashboard-stats" id="vulnerability-stat-cards" role="group" aria-label="漏洞严重度统计">
|
||||
<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-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 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-value" id="stat-critical">-</div>
|
||||
<div class="stat-pct" id="stat-critical-pct" aria-hidden="true">—</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-value" id="stat-high">-</div>
|
||||
<div class="stat-pct" id="stat-high-pct" aria-hidden="true">—</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-value" id="stat-medium">-</div>
|
||||
<div class="stat-pct" id="stat-medium-pct" aria-hidden="true">—</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-value" id="stat-low">-</div>
|
||||
<div class="stat-pct" id="stat-low-pct" aria-hidden="true">—</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-value" id="stat-info">-</div>
|
||||
<div class="stat-pct" id="stat-info-pct" aria-hidden="true">—</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div class="vulnerability-controls">
|
||||
<div class="vulnerability-filters">
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
||||
<input type="text" id="vulnerability-id-filter" data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞ID" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.conversationId">会话ID</span>
|
||||
<input type="text" id="vulnerability-conversation-filter" data-i18n="vulnerabilityPage.filterConversation" data-i18n-attr="placeholder" placeholder="筛选特定会话" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.taskOrQueueId">任务ID/队列ID</span>
|
||||
<input type="text" id="vulnerability-task-filter" data-i18n="vulnerabilityPage.filterTaskOrQueue" data-i18n-attr="placeholder" placeholder="筛选任务ID或队列ID" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.conversationTag">对话标签</span>
|
||||
<input type="text" id="vulnerability-conversation-tag-filter" data-i18n="vulnerabilityPage.filterConversationTag" data-i18n-attr="placeholder" placeholder="筛选对话标签" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.taskTag">任务标签</span>
|
||||
<input type="text" id="vulnerability-task-tag-filter" data-i18n="vulnerabilityPage.filterTaskTag" data-i18n-attr="placeholder" placeholder="筛选任务标签" />
|
||||
</label>
|
||||
<label>
|
||||
<span data-i18n="vulnerabilityPage.severity">严重程度</span>
|
||||
<select id="vulnerability-severity-filter">
|
||||
<option value="" data-i18n="knowledgePage.all">全部</option>
|
||||
<option value="critical" data-i18n="dashboard.severityCritical">严重</option>
|
||||
<option value="high" data-i18n="dashboard.severityHigh">高危</option>
|
||||
<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 class="vulnerability-controls" id="vulnerability-filter-panel">
|
||||
<div class="vulnerability-filter-toolbar">
|
||||
<div class="vulnerability-filter-primary">
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--grow">
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.vulnId">漏洞ID</span>
|
||||
<input type="search" id="vulnerability-id-filter" autocomplete="off"
|
||||
data-i18n="vulnerabilityPage.searchVulnId" data-i18n-attr="placeholder" placeholder="搜索漏洞 ID,回车筛选" />
|
||||
</label>
|
||||
<label class="vulnerability-filter-field vulnerability-filter-field--status">
|
||||
<span class="sr-only" data-i18n="vulnerabilityPage.status">状态</span>
|
||||
<select id="vulnerability-status-filter" data-i18n-attr="title" title="状态">
|
||||
<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 type="button" class="vulnerability-filter-clear-btn" onclick="clearVulnerabilityFilters()" data-i18n="vulnerabilityPage.clear">清除</button>
|
||||
</div>
|
||||
<select id="vulnerability-severity-filter" class="vulnerability-severity-sync" hidden aria-hidden="true" tabindex="-1">
|
||||
<option value=""></option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="high">high</option>
|
||||
<option value="medium">medium</option>
|
||||
<option value="low">low</option>
|
||||
<option value="info">info</option>
|
||||
</select>
|
||||
</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>
|
||||
|
||||
<!-- 漏洞列表 -->
|
||||
@@ -3515,7 +3553,7 @@
|
||||
<script src="/static/js/terminal.js"></script>
|
||||
<script src="/static/js/knowledge.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/chat-files.js"></script>
|
||||
<script src="/static/js/tasks.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user