Compare commits

...

6 Commits

Author SHA1 Message Date
公明 6a2a445f32 Update config.yaml 2026-04-30 01:56:47 +08:00
公明 6aaa21d3e0 Add files via upload 2026-04-30 01:55:23 +08:00
公明 5c57d358ef Add files via upload 2026-04-30 01:53:46 +08:00
公明 65a3475c02 Add files via upload 2026-04-30 01:52:11 +08:00
公明 516ebf7a65 Add files via upload 2026-04-29 22:40:17 +08:00
公明 2558be3d7d Add files via upload 2026-04-29 22:38:14 +08:00
9 changed files with 3114 additions and 262 deletions
+1 -1
View File
@@ -10,7 +10,7 @@
# ============================================
# 前端显示的版本号(可选,不填则显示默认版本)
version: "v1.5.14"
version: "v1.5.15"
# 服务器配置
server:
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
+16 -8
View File
@@ -752,25 +752,33 @@ func isClaudeProvider(cfg *config.OpenAIConfig) bool {
// Eino HTTP Client Bridge
// ============================================================
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个支持 Claude 自动桥接的 http.Client。
// 当 cfg.Provider 为 claude 时,会拦截 /chat/completions 请求,透明转换为 Anthropic Messages API。
// NewEinoHTTPClient 为 einoopenai.ChatModelConfig 返回一个 http.Client,包含两层 transport 包装:
// 1. 当 cfg.Provider 为 claude 时,最内层套 claudeRoundTripper,把 OpenAI /chat/completions 透明
// 桥接为 Anthropic /v1/messages(并把 Claude SSE 翻译回 OpenAI SSE 格式)。
// 2. 最外层无条件套 einoSSESanitizingRoundTripper,吞掉中转站发的 SSE 心跳/注释/控制行
// (": keepalive" / "event: ping" / "retry: 3000" 等),避免 Eino 用的 meguminnnnnnnnn/go-openai
// SDK 在累计超过 300 个非 "data:" 行后抛 "stream has sent too many empty messages"。
//
// 两层都对调用方完全透明:普通 JSON 响应原样透传,仅当响应 Content-Type 为 text/event-stream 时
// sanitizer 才会接管 bodydata: payload (含 [DONE]、{"error":...}) 一字节不改。
func NewEinoHTTPClient(cfg *config.OpenAIConfig, base *http.Client) *http.Client {
if base == nil {
base = http.DefaultClient
}
if !isClaudeProvider(cfg) {
return base
}
cloned := *base
transport := base.Transport
if transport == nil {
transport = http.DefaultTransport
}
cloned.Transport = &claudeRoundTripper{
base: transport,
config: cfg,
if isClaudeProvider(cfg) {
transport = &claudeRoundTripper{
base: transport,
config: cfg,
}
}
transport = &einoSSESanitizingRoundTripper{base: transport}
cloned.Transport = transport
return &cloned
}
+149
View File
@@ -0,0 +1,149 @@
package openai
// eino_sse_sanitizer.go 解决 Eino 走 meguminnnnnnnnn/go-openai SDK 时,
// 中转站心跳/SSE 控制行累计 > 300 行触发 ErrTooManyEmptyStreamMessages
// (报错文案: "stream has sent too many empty messages")的问题。
//
// 触发链路:
// einoopenai.NewChatModel
// → eino-ext/libs/acl/openai → meguminnnnnnnnn/go-openai
// → streamReader.processLines() 对所有非 "data:" 行计数, > 300 即抛错。
//
// 中转站常见的非 data: 行(合法 SSE 但 SDK 不接受):
// ":" / ": keepalive" / ": ping" / "event: ping" / "retry: 3000"
// 以及思考型模型 prefill 期间穿插的大量心跳。
//
// 兜底策略: 在 HTTP transport 层把响应 Body 包一层 reader, 只放行 "data:"
// 开头的行, 把心跳/注释/事件类型行就地吞掉。下游 SDK 永远见不到非 data: 行,
// 计数器始终为 0, 该错误不可能再发生。
//
// 该层对调用方完全透明:
// - 仅当响应 Content-Type 是 text/event-stream 时介入;普通 JSON 响应原样透传
// - data: payload (含 [DONE] 与 {"error":...}) 一字节不改
// - 上游真断流 (EOF / connection reset / context cancel) 原样透传
import (
"bufio"
"bytes"
"io"
"net/http"
"strings"
)
const (
// einoSSEReaderBufSize 给 bufio 一个较大的初始缓冲, 避免单行大 JSON chunk
// (含工具调用 arguments / reasoning_content) 频繁触发缓冲区扩容。
einoSSEReaderBufSize = 64 * 1024
)
// einoSSESanitizingRoundTripper 包装下游 RoundTripper, 对 SSE 响应做行级清洗。
type einoSSESanitizingRoundTripper struct {
base http.RoundTripper
}
func (rt *einoSSESanitizingRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
resp, err := rt.base.RoundTrip(req)
if err != nil || resp == nil {
return resp, err
}
if !isSSEResponse(resp) {
return resp, nil
}
resp.Body = newEinoSSESanitizingBody(resp.Body)
return resp, nil
}
// isSSEResponse 仅对 200 + text/event-stream 的响应做清洗;
// 错误响应 (4xx/5xx 通常是 application/json) 不动, 由 SDK 走原错误路径。
func isSSEResponse(resp *http.Response) bool {
if resp.StatusCode != http.StatusOK {
return false
}
ct := resp.Header.Get("Content-Type")
if ct == "" {
return false
}
ct = strings.ToLower(strings.TrimSpace(ct))
// 兼容 "text/event-stream", "text/event-stream; charset=utf-8" 等。
return strings.HasPrefix(ct, "text/event-stream")
}
// einoSSESanitizingBody 是包装后的响应体: 只放行 data: 行, 其它行吞掉。
type einoSSESanitizingBody struct {
upstream io.ReadCloser
reader *bufio.Reader
pending []byte // 已清洗、待返回给下游的字节 (永远以 \n 结尾的完整 data: 行)
err error // upstream 终态错误 (io.EOF 或网络错误)
}
func newEinoSSESanitizingBody(body io.ReadCloser) *einoSSESanitizingBody {
return &einoSSESanitizingBody{
upstream: body,
reader: bufio.NewReaderSize(body, einoSSEReaderBufSize),
}
}
func (b *einoSSESanitizingBody) Read(p []byte) (int, error) {
if len(p) == 0 {
return 0, nil
}
if len(b.pending) > 0 {
n := copy(p, b.pending)
b.pending = b.pending[n:]
return n, nil
}
// 从上游读, 直到攒出一行 data: 或拿到终态。
// 单次循环可能丢弃任意多行心跳, 但只放行至多一行 data: 后退出,
// 避免一次 Read 阻塞过久 / pending 缓冲过大。
for b.err == nil {
line, err := b.reader.ReadBytes('\n')
if len(line) > 0 {
if isPassThroughSSELine(line) {
if line[len(line)-1] != '\n' {
line = append(line, '\n')
}
b.pending = line
if err != nil {
b.err = err
}
break
}
// 非 data: 行 (空行 / ":" 注释 / event: / retry: / id: / 任何裸文本)
// 全部吞掉, 不向下游透出, 继续循环读下一行。
}
if err != nil {
b.err = err
break
}
}
if len(b.pending) > 0 {
n := copy(p, b.pending)
b.pending = b.pending[n:]
return n, nil
}
return 0, b.err
}
func (b *einoSSESanitizingBody) Close() error {
return b.upstream.Close()
}
// isPassThroughSSELine 判定该行是否需要原样放行给下游 SDK。
// 仅 "data:" (大小写不敏感, 可有任意前导空白) 开头的行需要保留。
// 注意: 不能用 TrimSpace 去尾部换行后再判, 否则 " data: x" 会被误判;
// 我们只 trim 前导空白, 与 SDK 内部 TrimSpace 后再正则 ^data:\s* 的语义一致。
func isPassThroughSSELine(line []byte) bool {
trimmed := bytes.TrimLeft(line, " \t")
if len(trimmed) < 5 {
return false
}
// 大小写不敏感比较前 5 字节是否为 "data:"。SSE 规范要求字段名小写,
// 但宽松匹配可以兼容个别中转站的非规范实现。
return (trimmed[0] == 'd' || trimmed[0] == 'D') &&
(trimmed[1] == 'a' || trimmed[1] == 'A') &&
(trimmed[2] == 't' || trimmed[2] == 'T') &&
(trimmed[3] == 'a' || trimmed[3] == 'A') &&
trimmed[4] == ':'
}
+303
View File
@@ -0,0 +1,303 @@
package openai
import (
"bufio"
"bytes"
"errors"
"io"
"net/http"
"net/http/httptest"
"regexp"
"strings"
"testing"
)
// 复现 meguminnnnnnnnn/go-openai 的 SSE 行计数算法 (默认 limit=300):
// - 逐行读
// - 非 "data:" 行 (空行 / ":" 注释 / event: / retry:) 累计 emptyMessagesCount
// - > 300 抛 ErrTooManyEmptyStreamMessages
// - 遇到 data: 行 reset, 返回 payload
//
// 这一算法与上游 SDK 的 stream_reader.go processLines() 严格一致 (验证依据见
// /Users/temp/go/pkg/mod/github.com/meguminnnnnnnnn/go-openai@v0.1.2/stream_reader.go)。
// 测试中只复刻 "限制触发" 这一行为, 用来回归验证 sanitizer 的根因修复。
var errTooManyEmptyStreamMessages = errors.New("stream has sent too many empty messages")
func sdkLikeRecvAll(body io.Reader, limit uint) ([]string, error) {
headerData := regexp.MustCompile(`^data:\s*`)
r := bufio.NewReader(body)
var payloads []string
for {
var emptyMessagesCount uint
var payload []byte
for {
line, err := r.ReadBytes('\n')
if err != nil {
if err == io.EOF {
return payloads, nil
}
return payloads, err
}
noSpace := bytes.TrimSpace(line)
if !headerData.Match(noSpace) {
emptyMessagesCount++
if emptyMessagesCount > limit {
return payloads, errTooManyEmptyStreamMessages
}
continue
}
payload = headerData.ReplaceAll(noSpace, nil)
break
}
if string(payload) == "[DONE]" {
return payloads, nil
}
payloads = append(payloads, string(payload))
}
}
func newSSEServer(t *testing.T, body string, contentType string, status int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
if contentType != "" {
w.Header().Set("Content-Type", contentType)
}
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
}
func sanitizingClient(base *http.Client) *http.Client {
if base == nil {
base = &http.Client{}
}
cloned := *base
transport := base.Transport
if transport == nil {
transport = http.DefaultTransport
}
cloned.Transport = &einoSSESanitizingRoundTripper{base: transport}
return &cloned
}
func readAll(t *testing.T, body io.ReadCloser) string {
t.Helper()
defer body.Close()
out, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read body: %v", err)
}
return string(out)
}
// 1) 仅 data: 行 → 一字节不改地透传。
func TestSSESanitizer_PassesDataLinesUnchanged(t *testing.T) {
body := "data: {\"a\":1}\ndata: {\"b\":2}\ndata: [DONE]\n"
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("body mismatch:\nwant %q\ngot %q", body, got)
}
}
// 2) 心跳/注释/事件类型行被吞掉, 仅保留 data: 行。
func TestSSESanitizer_DropsHeartbeatsAndControlLines(t *testing.T) {
body := strings.Join([]string{
": keepalive",
"",
"event: ping",
"retry: 3000",
"id: 42",
"data: {\"x\":1}",
": ping",
"",
"data: {\"x\":2}",
"data: [DONE]",
"",
}, "\n")
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
want := "data: {\"x\":1}\ndata: {\"x\":2}\ndata: [DONE]\n"
if got != want {
t.Fatalf("sanitized body mismatch:\nwant %q\ngot %q", want, got)
}
}
// 3) 根因回归: 上游堆 500 行心跳后才发 data:, 原始 SDK 算法会抛
// ErrTooManyEmptyStreamMessages, sanitize 之后必须能正常拿到所有 data:。
func TestSSESanitizer_ProtectsAgainstTooManyEmptyMessages(t *testing.T) {
const heartbeats = 500
var buf bytes.Buffer
for i := 0; i < heartbeats; i++ {
buf.WriteString(": keepalive\n")
}
buf.WriteString("data: {\"chunk\":1}\n")
buf.WriteString("data: {\"chunk\":2}\n")
buf.WriteString("data: [DONE]\n")
t.Run("baseline_without_sanitizer_must_fail", func(t *testing.T) {
_, err := sdkLikeRecvAll(bytes.NewReader(buf.Bytes()), 300)
if !errors.Is(err, errTooManyEmptyStreamMessages) {
t.Fatalf("expected ErrTooManyEmptyStreamMessages, got %v", err)
}
})
t.Run("with_sanitizer_must_succeed", func(t *testing.T) {
srv := newSSEServer(t, buf.String(), "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
payloads, err := sdkLikeRecvAll(resp.Body, 300)
if err != nil {
t.Fatalf("sdk-like recv after sanitize: %v", err)
}
want := []string{`{"chunk":1}`, `{"chunk":2}`}
if len(payloads) != len(want) {
t.Fatalf("payload count mismatch: want %d got %d (%v)", len(want), len(payloads), payloads)
}
for i, w := range want {
if payloads[i] != w {
t.Fatalf("payload[%d] mismatch: want %q got %q", i, w, payloads[i])
}
}
})
}
// 4) 心跳穿插在 data: 之间也能正确清洗 (思考型模型 prefill 期间常见)。
func TestSSESanitizer_HeartbeatsInterleavedWithData(t *testing.T) {
var buf bytes.Buffer
buf.WriteString("data: {\"chunk\":1}\n")
for i := 0; i < 400; i++ {
buf.WriteString(": keepalive\n")
}
buf.WriteString("data: {\"chunk\":2}\n")
buf.WriteString("data: [DONE]\n")
srv := newSSEServer(t, buf.String(), "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
defer resp.Body.Close()
payloads, err := sdkLikeRecvAll(resp.Body, 300)
if err != nil {
t.Fatalf("sdk-like recv: %v", err)
}
if got, want := len(payloads), 2; got != want {
t.Fatalf("payload count: want %d got %d", want, got)
}
}
// 5) 非 SSE 响应 (例如非流式 JSON) 不应被 sanitizer 介入。
func TestSSESanitizer_PassesNonSSEResponseUntouched(t *testing.T) {
body := `{"id":"x","object":"chat.completion","choices":[]}`
srv := newSSEServer(t, body, "application/json", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("non-SSE body must be untouched:\nwant %q\ngot %q", body, got)
}
}
// 6) 错误响应 (4xx/5xx) 不应被 sanitize, 即使 Content-Type 是 SSE 也不动,
// 避免吞掉类似 "data: " 之外的错误正文。
func TestSSESanitizer_PassesNon200Untouched(t *testing.T) {
body := `{"error":{"message":"rate limit"}}`
srv := newSSEServer(t, body, "text/event-stream", 429)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("error body must be untouched:\nwant %q\ngot %q", body, got)
}
}
// 7) data: 行末尾若缺 \n (异常上游) sanitizer 也补齐, 保证下游按行解析。
func TestSSESanitizer_AppendsTrailingNewlineIfMissing(t *testing.T) {
body := "data: {\"a\":1}"
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
want := "data: {\"a\":1}\n"
if got != want {
t.Fatalf("trailing newline:\nwant %q\ngot %q", want, got)
}
}
// 8) 大 chunk (一行数十 KB) 也能完整透传, 不被切断。
func TestSSESanitizer_LargeDataLinePassesIntact(t *testing.T) {
huge := strings.Repeat("x", 80*1024)
body := "data: {\"big\":\"" + huge + "\"}\ndata: [DONE]\n"
srv := newSSEServer(t, body, "text/event-stream", 200)
defer srv.Close()
resp, err := sanitizingClient(nil).Get(srv.URL)
if err != nil {
t.Fatalf("get: %v", err)
}
got := readAll(t, resp.Body)
if got != body {
t.Fatalf("large body length mismatch: want %d got %d", len(body), len(got))
}
}
// 9) isPassThroughSSELine 单元覆盖。
func TestIsPassThroughSSELine(t *testing.T) {
cases := []struct {
line string
want bool
}{
{"data: {\"a\":1}\n", true},
{"DATA: x\n", true},
{" data: x\n", true},
{"data:\n", true},
{"\n", false},
{"\r\n", false},
{": keepalive\n", false},
{":\n", false},
{"event: ping\n", false},
{"retry: 3000\n", false},
{"id: 42\n", false},
{"datax: y\n", false},
{"da", false},
}
for _, c := range cases {
if got := isPassThroughSSELine([]byte(c.line)); got != c.want {
t.Errorf("isPassThroughSSELine(%q) = %v, want %v", c.line, got, c.want)
}
}
}
+1109 -71
View File
File diff suppressed because it is too large Load Diff
+82 -2
View File
@@ -20,7 +20,13 @@
"copied": "Copied",
"copyFailed": "Copy failed",
"view": "View",
"actions": "Actions"
"actions": "Actions",
"loadFailed": "Load failed",
"untitled": "Untitled",
"justNow": "Just now",
"minutesAgo": "{{n}} min ago",
"hoursAgo": "{{n}} h ago",
"daysAgo": "{{n}} d ago"
},
"header": {
"title": "CyberStrikeAI",
@@ -88,6 +94,7 @@
"severityMedium": "Medium",
"severityLow": "Low",
"severityInfo": "Info",
"totalVulns": "Total vulnerabilities",
"runOverview": "Run overview",
"batchQueues": "Batch task queues",
"pending": "Pending",
@@ -114,7 +121,80 @@
"toUse": "To use",
"active": "Active",
"highFreq": "High frequency",
"noCallData": "No call data"
"noCallData": "No call data",
"lastUpdated": "Last updated",
"viewAll": "View all →",
"recentVulns": "Recent vulnerabilities",
"noVulnYet": "No vulnerabilities yet — start your first scan",
"capabilities": "Capabilities",
"mcpTools": "MCP tools",
"rolesLabel": "Roles",
"agentsLabel": "Agents",
"webshellLabel": "WebShell",
"pendingCountLabel": "{{count}} pending",
"highCountLabel": "High {{count}}",
"toolsCountLabel_one": "{{count}} tool",
"toolsCountLabel_other": "{{count}} tools",
"failedNCalls_one": "{{count}} failed",
"failedNCalls_other": "{{count}} failed",
"noCallYet": "No calls yet",
"allClear": "No new risks",
"allIdle": "System idle",
"executingNow": "Running",
"healthyStatus": "Healthy",
"normalStatus": "Mostly OK",
"degradedStatus": "Needs attention",
"alertTitle": "Heads up",
"alertWarningTitle": "Needs attention",
"alertDangerTitle": "Action required",
"alertCriticalReason_one": "{{count}} open critical vulnerability — please review immediately",
"alertCriticalReason_other": "{{count}} open critical vulnerabilities — please review immediately",
"alertFailedReason_one": "Tool success rate is low ({{count}} failed call) — check MCP monitor",
"alertFailedReason_other": "Tool success rate is low ({{count}} failed calls) — check MCP monitor",
"alertHitlReason_one": "{{count}} HITL request pending — Agent is waiting for your decision",
"alertHitlReason_other": "{{count}} HITL requests pending — Agent is waiting for your decision",
"alertMcpDownReason_one": "{{count}} External MCP server is down — related tools are unavailable",
"alertMcpDownReason_other": "{{count}} External MCP servers are down — related tools are unavailable",
"alertDismiss": "Dismiss (this session)",
"openHighCountLabel": "Open high {{count}}",
"allHandled": "All high severity handled",
"viewVulns": "View vulnerabilities",
"viewMonitor": "View monitor",
"viewHitl": "Approve",
"viewMcpManagement": "Manage MCP",
"statusOpen": "Open",
"statusConfirmed": "Confirmed",
"statusFixed": "Fixed",
"statusFalsePositive": "False positive",
"fixRate": "Fix rate",
"dataStale": "Data may be stale — please refresh",
"recommendedActions": "Recommended Actions",
"recommendedActionsHint": "Generated based on current state",
"recoFixCritical_one": "Fix {{count}} open critical vulnerability",
"recoFixCritical_other": "Fix {{count}} open critical vulnerabilities",
"recoFixCriticalDesc": "Critical-level vulnerabilities should be addressed first",
"recoApproveHitl_one": "Approve {{count}} HITL request",
"recoApproveHitl_other": "Approve {{count}} HITL requests",
"recoApproveHitlDesc": "Agent needs your decision to proceed",
"recoRestartMcp_one": "Check {{count}} stopped External MCP",
"recoRestartMcp_other": "Check {{count}} stopped External MCPs",
"recoRestartMcpDesc": "Related tools are unavailable until MCP recovers",
"recoCheckMonitor_one": "Investigate {{count}} failed tool call",
"recoCheckMonitor_other": "Investigate {{count}} failed tool calls",
"recoCheckMonitorDesc": "View failed request details in MCP monitor",
"recoSetupMcp": "Configure your first MCP tool",
"recoSetupMcpDesc": "Install MCP server before Agent can invoke specific capabilities",
"recoStartScan": "Start your first scan",
"recoStartScanDesc": "Describe your target in chat, AI will help execute",
"recentEvents": "Recent Events",
"eventUntitled": "Event",
"externalMcpServers": "External MCP",
"mcpAllRunning": "All running",
"mcpPartialDown_one": "{{count}} stopped",
"mcpPartialDown_other": "{{count}} stopped",
"mcpAllDown": "All stopped",
"noVulnDesc": "System looks safe — start a scan to discover potential issues",
"startScanBtn": "Go to chat to scan"
},
"chat": {
"newChat": "New chat",
+71 -2
View File
@@ -20,7 +20,13 @@
"copied": "已复制",
"copyFailed": "复制失败",
"view": "查看",
"actions": "操作"
"actions": "操作",
"loadFailed": "加载失败",
"untitled": "未命名",
"justNow": "刚刚",
"minutesAgo": "{{n}} 分钟前",
"hoursAgo": "{{n}} 小时前",
"daysAgo": "{{n}} 天前"
},
"header": {
"title": "CyberStrikeAI",
@@ -88,6 +94,7 @@
"severityMedium": "中危",
"severityLow": "低危",
"severityInfo": "信息",
"totalVulns": "总漏洞数",
"runOverview": "运行概览",
"batchQueues": "批量任务队列",
"pending": "待执行",
@@ -114,7 +121,69 @@
"toUse": "待使用",
"active": "活跃",
"highFreq": "高频",
"noCallData": "暂无调用数据"
"noCallData": "暂无调用数据",
"lastUpdated": "上次更新",
"viewAll": "查看全部 →",
"recentVulns": "最近漏洞",
"noVulnYet": "暂无漏洞,开始你的第一次扫描吧",
"capabilities": "能力总览",
"mcpTools": "MCP 工具",
"rolesLabel": "角色",
"agentsLabel": "Agents",
"webshellLabel": "WebShell",
"pendingCountLabel": "{{count}} 待执行",
"highCountLabel": "高危 {{count}}",
"toolsCountLabel": "{{count}} 个工具",
"failedNCalls": "{{count}} 次失败",
"noCallYet": "暂无调用",
"allClear": "暂无新增风险",
"allIdle": "系统空闲",
"executingNow": "正在执行",
"healthyStatus": "运行平稳",
"normalStatus": "基本正常",
"degradedStatus": "需要关注",
"alertTitle": "需要关注",
"alertWarningTitle": "需要关注",
"alertDangerTitle": "需要立即处理",
"alertCriticalReason": "存在 {{count}} 个待处理的严重漏洞,建议立即处置",
"alertFailedReason": "工具调用成功率偏低({{count}} 次失败),请检查 MCP 监控",
"alertHitlReason": "有 {{count}} 个待审批的人机协同请求,Agent 正在等待你的决策",
"alertMcpDownReason": "External MCP 服务器有 {{count}} 个未运行,相关工具不可用",
"alertDismiss": "忽略此提醒(仅本次会话)",
"openHighCountLabel": "待处理高危 {{count}}",
"allHandled": "高严重度已全部处置",
"viewVulns": "查看漏洞",
"viewMonitor": "查看监控",
"viewHitl": "前往审批",
"viewMcpManagement": "管理 MCP",
"statusOpen": "待处理",
"statusConfirmed": "已确认",
"statusFixed": "已修复",
"statusFalsePositive": "误报",
"fixRate": "修复率",
"dataStale": "数据可能已过期,请手动刷新",
"recommendedActions": "推荐操作",
"recommendedActionsHint": "基于当前状态自动生成",
"recoFixCritical": "修复 {{count}} 个待处理严重漏洞",
"recoFixCriticalDesc": "严重等级的漏洞应优先处置",
"recoApproveHitl": "审批 {{count}} 个 HITL 请求",
"recoApproveHitlDesc": "Agent 正在等待你的决策才能继续",
"recoRestartMcp": "检查 {{count}} 个未运行的 External MCP",
"recoRestartMcpDesc": "相关工具在 MCP 服务恢复前不可用",
"recoCheckMonitor": "排查 {{count}} 次工具调用失败",
"recoCheckMonitorDesc": "在 MCP 监控中查看失败的请求详情",
"recoSetupMcp": "配置首个 MCP 工具",
"recoSetupMcpDesc": "安装 MCP 服务后 Agent 才能调用具体能力",
"recoStartScan": "开始第一次扫描",
"recoStartScanDesc": "在对话中描述目标,让 AI 协助执行",
"recentEvents": "最近事件",
"eventUntitled": "事件",
"externalMcpServers": "External MCP",
"mcpAllRunning": "全部运行",
"mcpPartialDown": "{{count}} 个未运行",
"mcpAllDown": "全部未运行",
"noVulnDesc": "系统目前安全,开始一次扫描可以发现潜在问题",
"startScanBtn": "前往对话发起扫描"
},
"chat": {
"newChat": "新对话",
+1117 -98
View File
File diff suppressed because it is too large Load Diff
+266 -80
View File
@@ -305,41 +305,207 @@
<div class="page-header">
<h2 data-i18n="dashboard.title">仪表盘</h2>
<div class="page-header-actions">
<span class="dashboard-last-updated" id="dashboard-last-updated" aria-live="polite">
<svg class="dashboard-last-updated-icon" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>
<span data-i18n="dashboard.lastUpdated">上次更新</span>
<span class="dashboard-last-updated-time" id="dashboard-last-updated-time">-</span>
<span class="dashboard-last-updated-stale" id="dashboard-last-updated-stale" hidden data-i18n="dashboard.dataStale" data-i18n-attr="title" title="数据可能已过期">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</span>
</span>
<button class="btn-secondary" onclick="refreshDashboard()" data-i18n="dashboard.refreshData" data-i18n-attr="title" title="刷新数据"><span data-i18n="common.refresh">刷新</span></button>
</div>
</div>
<div class="dashboard-content">
<!-- 第一行:核心 KPI(仪表盘最佳实践:关键指标置顶) -->
<!-- 关键提醒条(仅当存在严重风险时渲染,默认 hidden);右侧 × 可在 session 内忽略 -->
<div class="dashboard-alert-banner" id="dashboard-alert-banner" hidden>
<span class="dashboard-alert-icon" aria-hidden="true">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
</span>
<div class="dashboard-alert-content">
<div class="dashboard-alert-title" id="dashboard-alert-title" data-i18n="dashboard.alertTitle">需要关注</div>
<div class="dashboard-alert-desc" id="dashboard-alert-desc"></div>
</div>
<div class="dashboard-alert-actions" id="dashboard-alert-actions"></div>
<button type="button" class="dashboard-alert-close" id="dashboard-alert-close" data-i18n="dashboard.alertDismiss" data-i18n-attr="title" data-i18n-skip-text="true" title="忽略此提醒(仅本次会话)" aria-label="dismiss">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- 第一行:核心 KPI(关键指标置顶 + 副标徽章承载次级信息) -->
<div class="dashboard-kpi-row" id="dashboard-cards">
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }" data-i18n="dashboard.clickToViewTasks" data-i18n-attr="title" title="点击查看任务管理"> <div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.runningTasks">运行中任务</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" data-i18n="dashboard.clickToViewVuln" data-i18n-attr="title" title="点击查看漏洞管理"><div class="dashboard-kpi-value" id="dashboard-vuln-total">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.vulnTotal">漏洞总数</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控"><div class="dashboard-kpi-value" id="dashboard-kpi-tools-calls">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.toolCalls">工具调用次数</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控"><div class="dashboard-kpi-value" id="dashboard-kpi-success-rate">-</div><div class="dashboard-kpi-label" data-i18n="dashboard.successRate">工具执行成功率</div></div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }" data-i18n="dashboard.clickToViewTasks" data-i18n-attr="title" title="点击查看任务管理">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.runningTasks">运行中任务</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-tasks" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2v4"/><path d="M12 18v4"/><path d="M4.93 4.93l2.83 2.83"/><path d="M16.24 16.24l2.83 2.83"/><path d="M2 12h4"/><path d="M18 12h4"/><path d="M4.93 19.07l2.83-2.83"/><path d="M16.24 7.76l2.83-2.83"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-running-tasks">-</div>
<div class="dashboard-kpi-sub" id="dashboard-kpi-tasks-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-tasks-sub-text">-</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }" data-i18n="dashboard.clickToViewVuln" data-i18n-attr="title" title="点击查看漏洞管理">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.vulnTotal">漏洞总数</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-vuln" aria-hidden="true"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-vuln-total">-</div>
<div class="dashboard-kpi-sub" id="dashboard-kpi-vuln-sub">
<span class="dashboard-kpi-sub-badge dashboard-kpi-sub-badge-critical" id="dashboard-kpi-vuln-critical-badge" hidden>
<span class="dashboard-kpi-sub-badge-dot"></span>
<span data-i18n="dashboard.severityCritical">严重</span>
<span id="dashboard-kpi-vuln-critical-count">0</span>
</span>
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-vuln-sub-text" data-i18n="dashboard.allClear">暂无新增风险</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.toolCalls">工具调用次数</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-calls" aria-hidden="true"><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></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-kpi-tools-calls">-</div>
<div class="dashboard-kpi-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-tools-sub-text">-</span>
</div>
</div>
<div class="dashboard-kpi-card" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }" data-i18n="dashboard.clickToViewMCP" data-i18n-attr="title" title="点击查看 MCP 监控">
<div class="dashboard-kpi-head">
<div class="dashboard-kpi-label" data-i18n="dashboard.successRate">工具执行成功率</div>
<span class="dashboard-kpi-icon dashboard-kpi-icon-rate" aria-hidden="true"><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></span>
</div>
<div class="dashboard-kpi-value" id="dashboard-kpi-success-rate">-</div>
<div class="dashboard-kpi-sub">
<span class="dashboard-kpi-sub-text" id="dashboard-kpi-rate-sub-text" data-i18n="dashboard.healthyStatus">运行平稳</span>
</div>
</div>
</div>
<!-- 两列主内容区 -->
<div class="dashboard-grid">
<div class="dashboard-main">
<section class="dashboard-section dashboard-section-chart">
<h3 class="dashboard-section-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</h3>
<div class="dashboard-chart-wrap">
<div class="dashboard-stacked-bar" id="dashboard-stacked-bar">
<span class="dashboard-bar-seg seg-critical" id="dashboard-bar-critical" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-high" id="dashboard-bar-high" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-medium" id="dashboard-bar-medium" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-low" id="dashboard-bar-low" style="width: 0%"></span>
<span class="dashboard-bar-seg seg-info" id="dashboard-bar-info" style="width: 0%"></span>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.severityDistribution">漏洞严重程度分布</h3>
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-severity-wrap">
<div class="dashboard-severity-chart">
<svg class="dashboard-severity-donut" id="dashboard-severity-donut" viewBox="0 0 480 260" preserveAspectRatio="xMidYMid meet" aria-hidden="true">
<g id="dashboard-severity-donut-track"></g>
<g id="dashboard-severity-donut-segments"></g>
<g id="dashboard-severity-donut-labels"></g>
</svg>
<div class="dashboard-severity-center">
<div class="dashboard-severity-center-value" id="dashboard-severity-total">0</div>
<div class="dashboard-severity-center-label" data-i18n="dashboard.totalVulns">总漏洞数</div>
</div>
</div>
<div class="dashboard-legend" id="dashboard-vuln-bars">
<div class="dashboard-legend-item"><span class="dashboard-legend-dot critical"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityCritical">严重</span><span class="dashboard-legend-value" id="dashboard-severity-critical">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot high"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityHigh">高危</span><span class="dashboard-legend-value" id="dashboard-severity-high">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot medium"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityMedium">中危</span><span class="dashboard-legend-value" id="dashboard-severity-medium">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot low"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityLow">低危</span><span class="dashboard-legend-value" id="dashboard-severity-low">0</span></div>
<div class="dashboard-legend-item"><span class="dashboard-legend-dot info"></span><span class="dashboard-legend-label" data-i18n="dashboard.severityInfo">信息</span><span class="dashboard-legend-value" id="dashboard-severity-info">0</span></div>
<div class="dashboard-severity-legend" id="dashboard-vuln-bars">
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot critical"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityCritical">严重</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-critical">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-critical-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot high"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityHigh">高危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-high">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-high-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot medium"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityMedium">中危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-medium">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-medium-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot low"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityLow">低危</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-low">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-low-pct">0%</span>
</div>
<div class="dashboard-severity-legend-item">
<span class="dashboard-severity-legend-dot info"></span>
<span class="dashboard-severity-legend-label" data-i18n="dashboard.severityInfo">信息</span>
<span class="dashboard-severity-legend-value" id="dashboard-severity-info">0</span>
<span class="dashboard-severity-legend-pct" id="dashboard-severity-info-pct">0%</span>
</div>
</div>
</div>
<!-- 处置状态 + 修复进度(利用 by_status 数据,避免下半部分留白) -->
<div class="dashboard-severity-status">
<div class="dashboard-severity-status-grid">
<div class="dashboard-severity-status-cell s-open" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><line x1="12" y1="8" x2="12" y2="12"/><line x1="12" y1="16" x2="12.01" y2="16"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-open">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusOpen">待处理</span>
</div>
</div>
<div class="dashboard-severity-status-cell s-confirmed" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-confirmed">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusConfirmed">已确认</span>
</div>
</div>
<div class="dashboard-severity-status-cell s-fixed" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><polyline points="9 12 11 14 15 10"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-fixed">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusFixed">已修复</span>
</div>
</div>
<div class="dashboard-severity-status-cell s-fp" role="button" tabindex="0" onclick="switchPage('vulnerabilities')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('vulnerabilities'); }">
<span class="dashboard-severity-status-icon" aria-hidden="true">
<svg width="16" height="16" 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"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
</span>
<div class="dashboard-severity-status-text">
<span class="dashboard-severity-status-value" id="dashboard-status-fp">0</span>
<span class="dashboard-severity-status-label" data-i18n="dashboard.statusFalsePositive">误报</span>
</div>
</div>
</div>
<div class="dashboard-severity-progress">
<div class="dashboard-severity-progress-meta">
<span class="dashboard-severity-progress-title" data-i18n="dashboard.fixRate">修复率</span>
<span class="dashboard-severity-progress-value">
<span id="dashboard-fix-rate">0%</span>
<span class="dashboard-severity-progress-detail" id="dashboard-fix-detail">(0 / 0)</span>
</span>
</div>
<div class="dashboard-severity-progress-track" aria-hidden="true">
<div class="dashboard-severity-progress-fixed" id="dashboard-fix-progress-fixed" style="width: 0%"></div>
<div class="dashboard-severity-progress-confirmed" id="dashboard-fix-progress-confirmed" style="width: 0%"></div>
</div>
<div class="dashboard-severity-progress-legend">
<span class="dashboard-severity-progress-legend-item"><span class="dashboard-severity-progress-legend-dot legend-fixed"></span><span data-i18n="dashboard.statusFixed">已修复</span></span>
<span class="dashboard-severity-progress-legend-item"><span class="dashboard-severity-progress-legend-dot legend-confirmed"></span><span data-i18n="dashboard.statusConfirmed">已确认</span></span>
<span class="dashboard-severity-progress-legend-item"><span class="dashboard-severity-progress-legend-dot legend-open"></span><span data-i18n="dashboard.statusOpen">待处理</span></span>
</div>
</div>
</div>
</section>
<section class="dashboard-section dashboard-section-recent-vulns">
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.recentVulns">最近漏洞</h3>
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
<div class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无漏洞,开始你的第一次扫描吧</div>
</div>
</section>
<section class="dashboard-section dashboard-section-overview">
<h3 class="dashboard-section-title" data-i18n="dashboard.runOverview">运行概览</h3>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3>
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-overview-list">
<div class="dashboard-overview-item dashboard-overview-item-batch" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-batch" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7"/><rect x="14" y="3" width="7" height="7"/><rect x="14" y="14" width="7" height="7"/><rect x="3" y="14" width="7" height="7"/></svg></span>
@@ -374,80 +540,100 @@
</div>
</div>
</div>
<div class="dashboard-overview-item dashboard-overview-item-tools" role="button" tabindex="0" onclick="switchPage('mcp-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('mcp-monitor'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-tools" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/></svg></span>
<div class="dashboard-overview-content">
<div class="dashboard-overview-header">
<span class="dashboard-overview-label" data-i18n="dashboard.toolInvocations">工具调用</span>
<span class="dashboard-overview-success-rate" id="dashboard-tools-success-rate">-</span>
</div>
<div class="dashboard-overview-value-group">
<span class="dashboard-overview-value-large" id="dashboard-tools-calls">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.callsUnit">次调用</span>
<span class="dashboard-overview-value-separator">·</span>
<span class="dashboard-overview-value-normal" id="dashboard-tools-count">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.toolsUnit">个工具</span>
</div>
</div>
</div>
<div class="dashboard-overview-item dashboard-overview-item-knowledge" role="button" tabindex="0" onclick="switchPage('knowledge-management')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('knowledge-management'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-knowledge" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"></path><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"></path></svg></span>
<div class="dashboard-overview-content">
<div class="dashboard-overview-header">
<span class="dashboard-overview-label" data-i18n="dashboard.knowledgeLabel">知识</span>
<span class="dashboard-overview-status" id="dashboard-knowledge-status">-</span>
</div>
<div class="dashboard-overview-value-group">
<span class="dashboard-overview-value-large" id="dashboard-knowledge-items">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.knowledgeItems">项知识</span>
<span class="dashboard-overview-value-separator">·</span>
<span class="dashboard-overview-value-normal" id="dashboard-knowledge-categories">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.categoriesUnit">个分类</span>
</div>
</div>
</div>
<div class="dashboard-overview-item dashboard-overview-item-skills" role="button" tabindex="0" onclick="switchPage('skills-monitor')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('skills-monitor'); }">
<span class="dashboard-overview-icon dashboard-overview-icon-skills" aria-hidden="true"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/><line x1="16" y1="13" x2="8" y2="13"/><line x1="16" y1="17" x2="8" y2="17"/></svg></span>
<div class="dashboard-overview-content">
<div class="dashboard-overview-header">
<span class="dashboard-overview-label" data-i18n="dashboard.skillsLabel">Skills</span>
<span class="dashboard-overview-status" id="dashboard-skills-status">-</span>
</div>
<div class="dashboard-overview-value-group">
<span class="dashboard-overview-value-large" id="dashboard-skills-calls">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.callsUnit">次调用</span>
<span class="dashboard-overview-value-separator">·</span>
<span class="dashboard-overview-value-normal" id="dashboard-skills-count">-</span>
<span class="dashboard-overview-value-unit" data-i18n="dashboard.skillUnit">个 Skill</span>
</div>
</div>
</div>
</div>
</section>
<section class="dashboard-section dashboard-section-quick dashboard-quick-inline">
<h3 class="dashboard-section-title" data-i18n="dashboard.quickLinks">快捷入口</h3>
<div class="dashboard-quick-links dashboard-quick-links-row">
<a class="dashboard-quick-link" onclick="switchPage('chat')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg></span><span data-i18n="nav.chat">对话</span></a>
<a class="dashboard-quick-link" onclick="switchPage('tasks')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 11l3 3L22 4"></path><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"></path></svg></span><span data-i18n="nav.tasks">任务管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('vulnerabilities')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"></path></svg></span><span data-i18n="nav.vulnerabilities">漏洞管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('mcp-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"></path></svg></span><span data-i18n="nav.mcpManagement">MCP 管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('knowledge-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z"></path><path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z"></path></svg></span><span data-i18n="nav.knowledgeManagement">知识管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('skills-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"></path><polyline points="14 2 14 8 20 8"></polyline></svg></span><span data-i18n="nav.skillsManagement">Skills 管理</span></a>
<a class="dashboard-quick-link" onclick="switchPage('roles-management')"><span class="dashboard-quick-icon"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"></path><circle cx="9" cy="7" r="4"></circle><path d="M23 21v-2a4 4 0 0 0-3-3.87"></path><path d="M16 3.13a4 4 0 0 1 0 7.75"></path></svg></span><span data-i18n="nav.rolesManagement">角色管理</span></a>
<!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」),
比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 -->
<section class="dashboard-section dashboard-section-recommend" id="dashboard-section-recommend" hidden>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.recommendedActions">推荐操作</h3>
<span class="dashboard-section-hint" data-i18n="dashboard.recommendedActionsHint">基于当前状态自动生成</span>
</div>
<div class="dashboard-recommend-list" id="dashboard-recommend-list"></div>
</section>
</div>
<div class="dashboard-side">
<section class="dashboard-section dashboard-section-tools">
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
<a class="dashboard-section-link" onclick="switchPage('mcp-monitor')" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-tools-chart-wrap">
<div class="dashboard-tools-chart-placeholder" id="dashboard-tools-pie-placeholder" data-i18n="common.noData">暂无数据</div>
<div class="dashboard-tools-bar-chart" id="dashboard-tools-bar-chart"></div>
</div>
</section>
<!-- 最近事件:拉 /api/notifications/summary 取最新 3 条;空时整个隐藏 -->
<section class="dashboard-section dashboard-section-events" id="dashboard-section-events" hidden>
<div class="dashboard-section-header">
<h3 class="dashboard-section-title" data-i18n="dashboard.recentEvents">最近事件</h3>
<a class="dashboard-section-link" onclick="if(typeof toggleNotificationDropdown==='function') toggleNotificationDropdown()" data-i18n="dashboard.viewAll">查看全部 →</a>
</div>
<div class="dashboard-events-list" id="dashboard-events-list"></div>
</section>
<section class="dashboard-section dashboard-section-resources">
<h3 class="dashboard-section-title" data-i18n="dashboard.capabilities">能力总览</h3>
<div class="dashboard-resource-list" id="dashboard-resource-list">
<a class="dashboard-resource-item" onclick="switchPage('mcp-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-mcp" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.mcpTools">MCP 工具</span>
<span class="dashboard-resource-value" id="dashboard-resource-tools">-</span>
</a>
<!-- External MCP 服务器健康度:N 运行 / N 异常;只有配置过 External MCP 才显示 -->
<a class="dashboard-resource-item" id="dashboard-resource-external-mcp-row" onclick="switchPage('mcp-management')" role="button" tabindex="0" hidden>
<span class="dashboard-resource-icon dashboard-resource-icon-external" aria-hidden="true">
<svg width="16" height="16" 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"/><line x1="2" y1="12" x2="22" y2="12"/><path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.externalMcpServers">External MCP</span>
<span class="dashboard-resource-value" id="dashboard-resource-external-mcp">
<span id="dashboard-resource-external-mcp-text">-</span>
<span class="dashboard-resource-health" id="dashboard-resource-external-mcp-health" hidden></span>
</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('skills-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-skills" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2z"/><polyline points="14 2 14 8 20 8"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.skillsLabel">Skills</span>
<span class="dashboard-resource-value" id="dashboard-resource-skills">-</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('knowledge-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-knowledge" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20"/><path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.knowledgeLabel">知识</span>
<span class="dashboard-resource-value" id="dashboard-resource-knowledge">-</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('roles-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-roles" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.rolesLabel">角色</span>
<span class="dashboard-resource-value" id="dashboard-resource-roles">-</span>
</a>
<a class="dashboard-resource-item" onclick="switchPage('agents-management')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-agents" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polygon points="12 2 2 7 12 12 22 7 12 2"/><polyline points="2 17 12 22 22 17"/><polyline points="2 12 12 17 22 12"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span>
<span class="dashboard-resource-value" id="dashboard-resource-agents">-</span>
</a>
<!-- WebShell 连接:渗透落地后建立的 foothold,对安全运维场景非常关键 -->
<a class="dashboard-resource-item" onclick="switchPage('webshell')" role="button" tabindex="0">
<span class="dashboard-resource-icon dashboard-resource-icon-webshell" aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="4 17 10 11 4 5"/><line x1="12" y1="19" x2="20" y2="19"/></svg>
</span>
<span class="dashboard-resource-label" data-i18n="dashboard.webshellLabel">WebShell</span>
<span class="dashboard-resource-value" id="dashboard-resource-webshell">-</span>
</a>
</div>
</section>
</div>
</div>
<div class="dashboard-cta-block">
<!-- "开始你的安全之旅" CTA:默认显示;当用户已经有数据(任务/漏洞/调用)后,由 JS 隐藏避免冗余 -->
<div class="dashboard-cta-block" id="dashboard-cta-block">
<div class="dashboard-cta-content">
<div class="dashboard-cta-icon" aria-hidden="true">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"></path></svg>