mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-05-18 14:04:52 +02:00
Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f6bb455313 | |||
| fc64356282 | |||
| 3d4fce9b89 | |||
| 3e41a47abf | |||
| 5b942c7bc8 |
+1
-1
@@ -10,7 +10,7 @@
|
||||
# ============================================
|
||||
|
||||
# 前端显示的版本号(可选,不填则显示默认版本)
|
||||
version: "v1.5.13"
|
||||
version: "v1.5.14"
|
||||
# 服务器配置
|
||||
server:
|
||||
host: 0.0.0.0 # 监听地址,0.0.0.0 表示监听所有网络接口
|
||||
|
||||
@@ -318,6 +318,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
}
|
||||
monitorHandler := handler.NewMonitorHandler(mcpServer, executor, db, log.Logger)
|
||||
monitorHandler.SetExternalMCPManager(externalMCPMgr) // 设置外部MCP管理器,以便获取外部MCP执行记录
|
||||
notificationHandler := handler.NewNotificationHandler(db, agentHandler, log.Logger)
|
||||
groupHandler := handler.NewGroupHandler(db, log.Logger)
|
||||
authHandler := handler.NewAuthHandler(authManager, cfg, configPath, log.Logger)
|
||||
attackChainHandler := handler.NewAttackChainHandler(db, &cfg.OpenAI, log.Logger)
|
||||
@@ -434,6 +435,7 @@ func New(cfg *config.Config, log *logger.Logger) (*App, error) {
|
||||
authHandler,
|
||||
agentHandler,
|
||||
monitorHandler,
|
||||
notificationHandler,
|
||||
conversationHandler,
|
||||
robotHandler,
|
||||
groupHandler,
|
||||
@@ -600,6 +602,7 @@ func setupRoutes(
|
||||
authHandler *handler.AuthHandler,
|
||||
agentHandler *handler.AgentHandler,
|
||||
monitorHandler *handler.MonitorHandler,
|
||||
notificationHandler *handler.NotificationHandler,
|
||||
conversationHandler *handler.ConversationHandler,
|
||||
robotHandler *handler.RobotHandler,
|
||||
groupHandler *handler.GroupHandler,
|
||||
@@ -728,6 +731,8 @@ func setupRoutes(
|
||||
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
|
||||
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
|
||||
protected.GET("/monitor/stats", monitorHandler.GetStats)
|
||||
protected.GET("/notifications/summary", notificationHandler.GetSummary)
|
||||
protected.POST("/notifications/read", notificationHandler.MarkRead)
|
||||
|
||||
// 配置管理
|
||||
protected.GET("/config", configHandler.GetConfig)
|
||||
|
||||
+21
-11
@@ -85,7 +85,7 @@ CREATE TABLE IF NOT EXISTS hitl_conversation_configs (
|
||||
enabled INTEGER NOT NULL DEFAULT 0,
|
||||
mode TEXT NOT NULL DEFAULT 'off',
|
||||
sensitive_tools TEXT NOT NULL DEFAULT '[]',
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 300,
|
||||
timeout_seconds INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at DATETIME NOT NULL
|
||||
);`)
|
||||
if err != nil {
|
||||
@@ -133,7 +133,8 @@ func (m *HITLManager) ActivateConversation(conversationID string, req *HITLReque
|
||||
tools[n] = struct{}{}
|
||||
}
|
||||
}
|
||||
timeout := 5 * time.Minute
|
||||
// timeout <= 0 means wait forever (no timeout).
|
||||
timeout := time.Duration(0)
|
||||
if req.TimeoutSeconds > 0 {
|
||||
timeout = time.Duration(req.TimeoutSeconds) * time.Second
|
||||
}
|
||||
@@ -275,8 +276,8 @@ func (m *HITLManager) ensureConversationHITLModePersisted(conversationID, interr
|
||||
}
|
||||
cfg.Enabled = true
|
||||
cfg.Mode = nm
|
||||
if cfg.TimeoutSeconds <= 0 {
|
||||
cfg.TimeoutSeconds = 300
|
||||
if cfg.TimeoutSeconds < 0 {
|
||||
cfg.TimeoutSeconds = 0
|
||||
}
|
||||
return m.SaveConversationConfig(conversationID, cfg)
|
||||
}
|
||||
@@ -341,7 +342,7 @@ func (m *HITLManager) SaveConversationConfig(conversationID string, req *HITLReq
|
||||
return errors.New("conversationId is required")
|
||||
}
|
||||
if req == nil {
|
||||
req = &HITLRequest{Enabled: false, Mode: "off", TimeoutSeconds: 300}
|
||||
req = &HITLRequest{Enabled: false, Mode: "off", TimeoutSeconds: 0}
|
||||
}
|
||||
mode := normalizeHitlMode(req.Mode)
|
||||
if !req.Enabled {
|
||||
@@ -349,8 +350,8 @@ func (m *HITLManager) SaveConversationConfig(conversationID string, req *HITLReq
|
||||
}
|
||||
tools, _ := json.Marshal(req.SensitiveTools)
|
||||
timeout := req.TimeoutSeconds
|
||||
if timeout <= 0 {
|
||||
timeout = 300
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
_, err := m.db.Exec(`INSERT INTO hitl_conversation_configs
|
||||
(conversation_id, enabled, mode, sensitive_tools, timeout_seconds, updated_at)
|
||||
@@ -368,11 +369,14 @@ func (m *HITLManager) LoadConversationConfig(conversationID string) (*HITLReques
|
||||
err := m.db.QueryRow(`SELECT enabled, mode, sensitive_tools, timeout_seconds FROM hitl_conversation_configs WHERE conversation_id = ?`, conversationID).
|
||||
Scan(&enabledInt, &mode, &toolsJSON, &timeout)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return &HITLRequest{Enabled: false, Mode: "off", SensitiveTools: []string{}, TimeoutSeconds: 300}, nil
|
||||
return &HITLRequest{Enabled: false, Mode: "off", SensitiveTools: []string{}, TimeoutSeconds: 0}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if timeout < 0 {
|
||||
timeout = 0
|
||||
}
|
||||
tools := make([]string, 0)
|
||||
_ = json.Unmarshal([]byte(toolsJSON), &tools)
|
||||
return &HITLRequest{
|
||||
@@ -389,6 +393,12 @@ func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, tim
|
||||
delete(m.pending, p.InterruptID)
|
||||
m.mu.Unlock()
|
||||
}()
|
||||
var timeoutCh <-chan time.Time
|
||||
if timeout > 0 {
|
||||
timer := time.NewTimer(timeout)
|
||||
defer timer.Stop()
|
||||
timeoutCh = timer.C
|
||||
}
|
||||
select {
|
||||
case d := <-p.decideCh:
|
||||
// 只有 review_edit 模式允许改参;其他模式一律忽略 edited arguments
|
||||
@@ -398,7 +408,7 @@ func (m *HITLManager) waitDecision(ctx context.Context, p *pendingInterrupt, tim
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='decided', decision=?, decision_comment=?, decided_at=? WHERE id=?`,
|
||||
d.Decision, d.Comment, time.Now(), p.InterruptID)
|
||||
return d, nil
|
||||
case <-time.After(timeout):
|
||||
case <-timeoutCh:
|
||||
_, _ = m.db.Exec(`UPDATE hitl_interrupts SET status='timeout', decision='approve', decision_comment='timeout auto approve', decided_at=? WHERE id=?`,
|
||||
time.Now(), p.InterruptID)
|
||||
return hitlDecision{Decision: "approve", Comment: "timeout auto approve"}, nil
|
||||
@@ -718,8 +728,8 @@ func (h *AgentHandler) GetHITLConversationConfig(c *gin.Context) {
|
||||
cfg2 := *cfg
|
||||
cfg2.Enabled = true
|
||||
cfg2.Mode = normalizeHitlMode(pendMode)
|
||||
if cfg2.TimeoutSeconds <= 0 {
|
||||
cfg2.TimeoutSeconds = 300
|
||||
if cfg2.TimeoutSeconds < 0 {
|
||||
cfg2.TimeoutSeconds = 0
|
||||
}
|
||||
cfg = &cfg2
|
||||
}
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"cyberstrike-ai/internal/database"
|
||||
"github.com/gin-gonic/gin"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// NotificationHandler 聚合通知(Phase 2:服务端统一计算)
|
||||
type NotificationHandler struct {
|
||||
db *database.DB
|
||||
agentHandler *AgentHandler
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
const notificationReadMaxRows = 150
|
||||
|
||||
// NotificationSummaryItem 通知项
|
||||
type NotificationSummaryItem struct {
|
||||
ID string `json:"id"`
|
||||
Level string `json:"level"` // p0/p1/p2
|
||||
Type string `json:"type"`
|
||||
Title string `json:"title"`
|
||||
Desc string `json:"desc"`
|
||||
Ts string `json:"ts"` // RFC3339
|
||||
Count int `json:"count,omitempty"`
|
||||
Actionable bool `json:"actionable"`
|
||||
Read bool `json:"read"`
|
||||
// 以下字段用于前端深链跳转(通知即入口)
|
||||
ConversationID string `json:"conversationId,omitempty"`
|
||||
VulnerabilityID string `json:"vulnerabilityId,omitempty"`
|
||||
ExecutionID string `json:"executionId,omitempty"`
|
||||
InterruptID string `json:"interruptId,omitempty"`
|
||||
}
|
||||
|
||||
// NotificationSummaryResponse 聚合响应
|
||||
type NotificationSummaryResponse struct {
|
||||
SinceMs int64 `json:"sinceMs"`
|
||||
GeneratedAt string `json:"generatedAt"`
|
||||
P0Count int `json:"p0Count"`
|
||||
UnreadCount int `json:"unreadCount"`
|
||||
Counts map[string]int `json:"counts"`
|
||||
Items []NotificationSummaryItem `json:"items"`
|
||||
}
|
||||
|
||||
func NewNotificationHandler(db *database.DB, agentHandler *AgentHandler, logger *zap.Logger) *NotificationHandler {
|
||||
return &NotificationHandler{
|
||||
db: db,
|
||||
agentHandler: agentHandler,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func parseSinceMs(raw string) int64 {
|
||||
v := strings.TrimSpace(raw)
|
||||
if v == "" {
|
||||
return 0
|
||||
}
|
||||
if ms, err := strconv.ParseInt(v, 10, 64); err == nil && ms > 0 {
|
||||
return ms
|
||||
}
|
||||
if t, err := time.Parse(time.RFC3339, v); err == nil {
|
||||
return t.UnixMilli()
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func unixSecToRFC3339(sec int64) string {
|
||||
if sec <= 0 {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
return time.Unix(sec, 0).UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func normalizedSinceSec(sinceMs int64) int64 {
|
||||
sec := sinceMs / 1000
|
||||
// SQLite 默认时间精度到秒;给 1s 回看窗口,避免“同秒内新增”被漏算。
|
||||
if sec > 0 {
|
||||
return sec - 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func normalizeSinceMs(raw int64) int64 {
|
||||
if raw > 0 {
|
||||
return raw
|
||||
}
|
||||
// 默认仅看最近 24 小时,避免首次打开拉全量历史噪音。
|
||||
return time.Now().Add(-24 * time.Hour).UnixMilli()
|
||||
}
|
||||
|
||||
func levelBySeverity(sev string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(sev)) {
|
||||
case "critical", "high":
|
||||
return "p0"
|
||||
case "medium":
|
||||
return "p1"
|
||||
default:
|
||||
return "p2"
|
||||
}
|
||||
}
|
||||
|
||||
func requestWantsEnglish(c *gin.Context) bool {
|
||||
if c == nil {
|
||||
return false
|
||||
}
|
||||
lang := strings.ToLower(strings.TrimSpace(c.Query("lang")))
|
||||
if lang == "" {
|
||||
lang = strings.ToLower(strings.TrimSpace(c.GetHeader("Accept-Language")))
|
||||
}
|
||||
return strings.HasPrefix(lang, "en")
|
||||
}
|
||||
|
||||
func i18nText(english bool, zh string, en string) string {
|
||||
if english {
|
||||
return en
|
||||
}
|
||||
return zh
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) loadPendingHITLItems(limit int, english bool) ([]NotificationSummaryItem, error) {
|
||||
rows, err := h.db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
conversation_id,
|
||||
tool_name,
|
||||
COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0)
|
||||
FROM hitl_interrupts
|
||||
WHERE status = 'pending'
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]NotificationSummaryItem, 0, limit)
|
||||
for rows.Next() {
|
||||
var id, conversationID, toolName string
|
||||
var createdSec int64
|
||||
if err := rows.Scan(&id, &conversationID, &toolName, &createdSec); err != nil {
|
||||
continue
|
||||
}
|
||||
desc := i18nText(english, "会话 "+conversationID+" 的审批中断待处理", "Conversation "+conversationID+" has pending HITL approval")
|
||||
if strings.TrimSpace(toolName) != "" {
|
||||
desc = i18nText(english, "工具 "+toolName+" 等待审批", "Tool "+toolName+" is waiting for approval")
|
||||
}
|
||||
items = append(items, NotificationSummaryItem{
|
||||
ID: "hitl:" + id,
|
||||
Level: "p0",
|
||||
Type: "hitl_pending",
|
||||
Title: i18nText(english, "HITL 待审批", "HITL Pending Approval"),
|
||||
Desc: desc,
|
||||
Ts: unixSecToRFC3339(createdSec),
|
||||
Count: 1,
|
||||
Actionable: true,
|
||||
Read: false,
|
||||
ConversationID: conversationID,
|
||||
InterruptID: id,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) loadVulnerabilityItems(sinceMs int64, limit int, english bool) ([]NotificationSummaryItem, map[string]int, error) {
|
||||
sinceSec := normalizedSinceSec(sinceMs)
|
||||
rows, err := h.db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
title,
|
||||
severity,
|
||||
conversation_id,
|
||||
COALESCE(CAST(strftime('%s', created_at) AS INTEGER), 0)
|
||||
FROM vulnerabilities
|
||||
WHERE CAST(strftime('%s', created_at) AS INTEGER) > ?
|
||||
ORDER BY created_at DESC
|
||||
LIMIT ?
|
||||
`, sinceSec, limit)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]NotificationSummaryItem, 0, limit)
|
||||
counts := map[string]int{
|
||||
"newCriticalVulns": 0,
|
||||
"newHighVulns": 0,
|
||||
"newMediumVulns": 0,
|
||||
"newLowVulns": 0,
|
||||
"newInfoVulns": 0,
|
||||
}
|
||||
for rows.Next() {
|
||||
var id, title, severity, conversationID string
|
||||
var createdSec int64
|
||||
if err := rows.Scan(&id, &title, &severity, &conversationID, &createdSec); err != nil {
|
||||
continue
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(severity)) {
|
||||
case "critical":
|
||||
counts["newCriticalVulns"]++
|
||||
case "high":
|
||||
counts["newHighVulns"]++
|
||||
case "medium":
|
||||
counts["newMediumVulns"]++
|
||||
case "low":
|
||||
counts["newLowVulns"]++
|
||||
default:
|
||||
counts["newInfoVulns"]++
|
||||
}
|
||||
sevUpper := strings.ToUpper(strings.TrimSpace(severity))
|
||||
if sevUpper == "" {
|
||||
sevUpper = "INFO"
|
||||
}
|
||||
finalTitle := i18nText(english, "新漏洞("+sevUpper+")", "New Vulnerability ("+sevUpper+")")
|
||||
finalDesc := strings.TrimSpace(title)
|
||||
if finalDesc == "" {
|
||||
finalDesc = i18nText(english, "(无标题)", "(Untitled)")
|
||||
}
|
||||
items = append(items, NotificationSummaryItem{
|
||||
ID: "vuln:" + id,
|
||||
Level: levelBySeverity(severity),
|
||||
Type: "vulnerability_created",
|
||||
Title: finalTitle,
|
||||
Desc: finalDesc,
|
||||
Ts: unixSecToRFC3339(createdSec),
|
||||
Count: 1,
|
||||
Actionable: false,
|
||||
Read: false,
|
||||
ConversationID: conversationID,
|
||||
VulnerabilityID: id,
|
||||
})
|
||||
}
|
||||
return items, counts, nil
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) loadFailedExecutionItems(sinceMs int64, limit int, english bool) ([]NotificationSummaryItem, int, error) {
|
||||
sinceSec := normalizedSinceSec(sinceMs)
|
||||
rows, err := h.db.Query(`
|
||||
SELECT
|
||||
id,
|
||||
tool_name,
|
||||
COALESCE(CAST(strftime('%s', start_time) AS INTEGER), 0)
|
||||
FROM tool_executions
|
||||
WHERE status = 'failed'
|
||||
AND CAST(strftime('%s', start_time) AS INTEGER) > ?
|
||||
ORDER BY start_time DESC
|
||||
LIMIT ?
|
||||
`, sinceSec, limit)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
defer rows.Close()
|
||||
items := make([]NotificationSummaryItem, 0, limit)
|
||||
count := 0
|
||||
for rows.Next() {
|
||||
var id, toolName string
|
||||
var startSec int64
|
||||
if err := rows.Scan(&id, &toolName, &startSec); err != nil {
|
||||
continue
|
||||
}
|
||||
count++
|
||||
if strings.TrimSpace(toolName) == "" {
|
||||
toolName = i18nText(english, "未知工具", "unknown")
|
||||
}
|
||||
items = append(items, NotificationSummaryItem{
|
||||
ID: "exec_failed:" + id,
|
||||
Level: "p0",
|
||||
Type: "task_failed",
|
||||
Title: i18nText(english, "任务执行失败", "Task Execution Failed"),
|
||||
Desc: i18nText(english, "工具 "+toolName+" 执行失败", "Tool "+toolName+" execution failed"),
|
||||
Ts: unixSecToRFC3339(startSec),
|
||||
Count: 1,
|
||||
Actionable: false,
|
||||
Read: false,
|
||||
ExecutionID: id,
|
||||
})
|
||||
}
|
||||
return items, count, nil
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) summarizeLongRunningTasks(threshold time.Duration, english bool) ([]NotificationSummaryItem, int) {
|
||||
if h.agentHandler == nil || h.agentHandler.tasks == nil {
|
||||
return nil, 0
|
||||
}
|
||||
tasks := h.agentHandler.tasks.GetActiveTasks()
|
||||
now := time.Now()
|
||||
items := make([]NotificationSummaryItem, 0, len(tasks))
|
||||
for _, t := range tasks {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if now.Sub(t.StartedAt) >= threshold {
|
||||
items = append(items, NotificationSummaryItem{
|
||||
ID: "task_long:" + t.ConversationID,
|
||||
Level: "p1",
|
||||
Type: "long_running_tasks",
|
||||
Title: i18nText(english, "长时间运行任务", "Long Running Task"),
|
||||
Desc: i18nText(english, "会话 "+t.ConversationID+" 运行超过 15 分钟", "Conversation "+t.ConversationID+" has been running over 15 minutes"),
|
||||
Ts: t.StartedAt.UTC().Format(time.RFC3339),
|
||||
Count: 1,
|
||||
Actionable: true,
|
||||
Read: false,
|
||||
ConversationID: t.ConversationID,
|
||||
})
|
||||
}
|
||||
}
|
||||
return items, len(items)
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) summarizeCompletedTasksSince(sinceMs int64, limit int, english bool) ([]NotificationSummaryItem, int) {
|
||||
if h.agentHandler == nil || h.agentHandler.tasks == nil {
|
||||
return nil, 0
|
||||
}
|
||||
since := time.UnixMilli(sinceMs)
|
||||
completed := h.agentHandler.tasks.GetCompletedTasks()
|
||||
items := make([]NotificationSummaryItem, 0, limit)
|
||||
for _, t := range completed {
|
||||
if t == nil {
|
||||
continue
|
||||
}
|
||||
if t.CompletedAt.After(since) {
|
||||
items = append(items, NotificationSummaryItem{
|
||||
ID: "task_completed:" + t.ConversationID + ":" + strconv.FormatInt(t.CompletedAt.Unix(), 10),
|
||||
Level: "p2",
|
||||
Type: "task_completed",
|
||||
Title: i18nText(english, "任务完成", "Task Completed"),
|
||||
Desc: i18nText(english, "会话 "+t.ConversationID+" 已完成", "Conversation "+t.ConversationID+" completed"),
|
||||
Ts: t.CompletedAt.UTC().Format(time.RFC3339),
|
||||
Count: 1,
|
||||
Actionable: false,
|
||||
Read: false,
|
||||
ConversationID: t.ConversationID,
|
||||
})
|
||||
if len(items) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return items, len(items)
|
||||
}
|
||||
|
||||
func buildPlaceholders(n int) string {
|
||||
if n <= 0 {
|
||||
return ""
|
||||
}
|
||||
out := make([]string, 0, n)
|
||||
for i := 0; i < n; i++ {
|
||||
out = append(out, "?")
|
||||
}
|
||||
return strings.Join(out, ",")
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) readStatesByIDs(ids []string) (map[string]bool, error) {
|
||||
result := make(map[string]bool, len(ids))
|
||||
if len(ids) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
holders := buildPlaceholders(len(ids))
|
||||
query := "SELECT event_id FROM notification_reads WHERE event_id IN (" + holders + ")"
|
||||
args := make([]interface{}, 0, len(ids))
|
||||
for _, id := range ids {
|
||||
args = append(args, id)
|
||||
}
|
||||
rows, err := h.db.Query(query, args...)
|
||||
if err != nil {
|
||||
return result, err
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
var id string
|
||||
if err := rows.Scan(&id); err != nil {
|
||||
continue
|
||||
}
|
||||
result[id] = true
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (h *NotificationHandler) applyReadStates(items []NotificationSummaryItem) ([]NotificationSummaryItem, error) {
|
||||
markableIDs := make([]string, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Actionable {
|
||||
continue
|
||||
}
|
||||
markableIDs = append(markableIDs, item.ID)
|
||||
}
|
||||
readMap, err := h.readStatesByIDs(markableIDs)
|
||||
if err != nil {
|
||||
return items, err
|
||||
}
|
||||
for i := range items {
|
||||
if items[i].Actionable {
|
||||
items[i].Read = false
|
||||
continue
|
||||
}
|
||||
items[i].Read = readMap[items[i].ID]
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
func filterVisibleItems(items []NotificationSummaryItem) []NotificationSummaryItem {
|
||||
out := make([]NotificationSummaryItem, 0, len(items))
|
||||
for _, item := range items {
|
||||
if item.Actionable || !item.Read {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func countP0(items []NotificationSummaryItem) int {
|
||||
total := 0
|
||||
for _, item := range items {
|
||||
if item.Level == "p0" {
|
||||
if item.Count > 0 {
|
||||
total += item.Count
|
||||
} else {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func countUnread(items []NotificationSummaryItem) int {
|
||||
total := 0
|
||||
for _, item := range items {
|
||||
if item.Actionable || !item.Read {
|
||||
if item.Count > 0 {
|
||||
total += item.Count
|
||||
} else {
|
||||
total++
|
||||
}
|
||||
}
|
||||
}
|
||||
return total
|
||||
}
|
||||
|
||||
func createNotificationReadTableIfNeeded(db *database.DB) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("db is nil")
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS notification_reads (
|
||||
event_id TEXT PRIMARY KEY,
|
||||
read_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
`)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, idxErr := db.Exec(`CREATE INDEX IF NOT EXISTS idx_notification_reads_read_at ON notification_reads(read_at DESC);`)
|
||||
return idxErr
|
||||
}
|
||||
|
||||
func pruneNotificationReads(db *database.DB, maxRows int) error {
|
||||
if db == nil {
|
||||
return fmt.Errorf("db is nil")
|
||||
}
|
||||
if maxRows <= 0 {
|
||||
return nil
|
||||
}
|
||||
_, err := db.Exec(`
|
||||
DELETE FROM notification_reads
|
||||
WHERE event_id NOT IN (
|
||||
SELECT event_id
|
||||
FROM notification_reads
|
||||
ORDER BY read_at DESC, rowid DESC
|
||||
LIMIT ?
|
||||
)
|
||||
`, maxRows)
|
||||
return err
|
||||
}
|
||||
|
||||
type markReadRequest struct {
|
||||
EventIDs []string `json:"eventIds"`
|
||||
}
|
||||
|
||||
func normalizeMarkableEventID(id string) (string, bool) {
|
||||
v := strings.TrimSpace(id)
|
||||
if v == "" {
|
||||
return "", false
|
||||
}
|
||||
// 仅允许“可读后隐藏”的信息类事件;Actionable 事件不参与 read 标记。
|
||||
allowedPrefixes := []string{
|
||||
"vuln:",
|
||||
"exec_failed:",
|
||||
"task_completed:",
|
||||
}
|
||||
for _, prefix := range allowedPrefixes {
|
||||
if strings.HasPrefix(v, prefix) {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// MarkRead 按事件 ID 标记已读
|
||||
func (h *NotificationHandler) MarkRead(c *gin.Context) {
|
||||
if err := createNotificationReadTableIfNeeded(h.db); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare notification read table"})
|
||||
return
|
||||
}
|
||||
var req markReadRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
|
||||
return
|
||||
}
|
||||
if len(req.EventIDs) == 0 {
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "marked": 0})
|
||||
return
|
||||
}
|
||||
tx, err := h.db.Begin()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to begin transaction"})
|
||||
return
|
||||
}
|
||||
defer func() {
|
||||
_ = tx.Rollback()
|
||||
}()
|
||||
stmt, err := tx.Prepare(`
|
||||
INSERT INTO notification_reads(event_id, read_at)
|
||||
VALUES(?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(event_id) DO UPDATE SET read_at = CURRENT_TIMESTAMP
|
||||
`)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to prepare statement"})
|
||||
return
|
||||
}
|
||||
defer stmt.Close()
|
||||
marked := 0
|
||||
for _, raw := range req.EventIDs {
|
||||
id, ok := normalizeMarkableEventID(raw)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if _, err := stmt.Exec(id); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to mark read"})
|
||||
return
|
||||
}
|
||||
marked++
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to commit read marks"})
|
||||
return
|
||||
}
|
||||
if err := pruneNotificationReads(h.db, notificationReadMaxRows); err != nil {
|
||||
h.logger.Warn("裁剪通知已读记录失败", zap.Error(err))
|
||||
}
|
||||
c.JSON(http.StatusOK, gin.H{"ok": true, "marked": marked})
|
||||
}
|
||||
|
||||
// GetSummary 返回通知聚合视图(用于头部铃铛)
|
||||
func (h *NotificationHandler) GetSummary(c *gin.Context) {
|
||||
if h.db == nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "database unavailable"})
|
||||
return
|
||||
}
|
||||
|
||||
if err := createNotificationReadTableIfNeeded(h.db); err != nil {
|
||||
h.logger.Warn("初始化通知已读表失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to initialize notification read table"})
|
||||
return
|
||||
}
|
||||
|
||||
english := requestWantsEnglish(c)
|
||||
sinceMs := normalizeSinceMs(parseSinceMs(c.Query("since")))
|
||||
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("limit", "50")))
|
||||
if limit <= 0 {
|
||||
limit = 50
|
||||
}
|
||||
if limit > 200 {
|
||||
limit = 200
|
||||
}
|
||||
|
||||
hitlItems, err := h.loadPendingHITLItems(limit, english)
|
||||
if err != nil {
|
||||
h.logger.Warn("加载 HITL 通知失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to summarize hitl notifications"})
|
||||
return
|
||||
}
|
||||
|
||||
vulnItems, vulnCounts, err := h.loadVulnerabilityItems(sinceMs, limit, english)
|
||||
if err != nil {
|
||||
h.logger.Warn("加载漏洞通知失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to summarize vulnerabilities"})
|
||||
return
|
||||
}
|
||||
|
||||
longRunningItems, longRunningCount := h.summarizeLongRunningTasks(15*time.Minute, english)
|
||||
completedItems, completedCount := h.summarizeCompletedTasksSince(sinceMs, limit, english)
|
||||
|
||||
items := make([]NotificationSummaryItem, 0, len(hitlItems)+len(vulnItems)+len(longRunningItems)+len(completedItems))
|
||||
items = append(items, hitlItems...)
|
||||
items = append(items, vulnItems...)
|
||||
items = append(items, longRunningItems...)
|
||||
items = append(items, completedItems...)
|
||||
|
||||
items, err = h.applyReadStates(items)
|
||||
if err != nil {
|
||||
h.logger.Warn("加载通知已读状态失败", zap.Error(err))
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "failed to load notification read states"})
|
||||
return
|
||||
}
|
||||
items = filterVisibleItems(items)
|
||||
|
||||
sort.Slice(items, func(i, j int) bool {
|
||||
ti, errI := time.Parse(time.RFC3339, items[i].Ts)
|
||||
tj, errJ := time.Parse(time.RFC3339, items[j].Ts)
|
||||
if errI != nil || errJ != nil {
|
||||
return i < j
|
||||
}
|
||||
return ti.After(tj)
|
||||
})
|
||||
|
||||
p0Count := countP0(items)
|
||||
unreadCount := countUnread(items)
|
||||
c.JSON(http.StatusOK, NotificationSummaryResponse{
|
||||
SinceMs: sinceMs,
|
||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
||||
P0Count: p0Count,
|
||||
UnreadCount: unreadCount,
|
||||
Counts: map[string]int{
|
||||
"hitlPending": len(hitlItems),
|
||||
"newCriticalVulns": vulnCounts["newCriticalVulns"],
|
||||
"newHighVulns": vulnCounts["newHighVulns"],
|
||||
"newMediumVulns": vulnCounts["newMediumVulns"],
|
||||
"newLowVulns": vulnCounts["newLowVulns"],
|
||||
"newInfoVulns": vulnCounts["newInfoVulns"],
|
||||
"failedExecutions": 0,
|
||||
"longRunningTasks": longRunningCount,
|
||||
"completedTasks": completedCount,
|
||||
},
|
||||
Items: items,
|
||||
})
|
||||
}
|
||||
@@ -805,6 +805,185 @@ header {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-menu-container {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.notification-btn {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notification-btn:hover,
|
||||
.notification-btn.active {
|
||||
background: var(--bg-tertiary);
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.notification-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.notification-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
transform: translate(32%, -32%);
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
border-radius: 9999px;
|
||||
padding: 0 4px;
|
||||
background: #ef4444;
|
||||
color: #fff;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-sizing: border-box;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
text-align: center;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 1px 2px rgba(15, 23, 42, 0.18);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.notification-badge-text {
|
||||
display: inline-block;
|
||||
transform: translateY(1.5px);
|
||||
}
|
||||
|
||||
.notification-dropdown {
|
||||
position: absolute;
|
||||
top: calc(100% + 8px);
|
||||
right: 0;
|
||||
width: 340px;
|
||||
max-height: 420px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 10px;
|
||||
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.16);
|
||||
z-index: 1000;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-dropdown-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-mark-read-btn {
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--accent-color);
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.notification-list {
|
||||
max-height: 360px;
|
||||
overflow-y: auto;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.notification-item {
|
||||
display: block;
|
||||
padding: 10px 12px;
|
||||
border-left: 2px solid transparent;
|
||||
}
|
||||
|
||||
.notification-item + .notification-item {
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.notification-item.notification-level-p0 {
|
||||
border-left-color: #ef4444;
|
||||
}
|
||||
|
||||
.notification-item.notification-level-p1 {
|
||||
border-left-color: #f59e0b;
|
||||
}
|
||||
|
||||
.notification-item.notification-level-p2 {
|
||||
border-left-color: #3b82f6;
|
||||
}
|
||||
|
||||
.notification-item-title {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.notification-item-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-item-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.notification-item-action-btn {
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-secondary);
|
||||
border-radius: 6px;
|
||||
font-size: 0.6875rem;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.notification-item-action-btn:hover {
|
||||
border-color: var(--accent-color);
|
||||
color: var(--accent-color);
|
||||
}
|
||||
|
||||
.notification-item-desc {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary);
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.notification-item-time {
|
||||
margin-top: 4px;
|
||||
font-size: 0.6875rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.notification-empty {
|
||||
padding: 16px 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
|
||||
.user-avatar-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -827,6 +1006,8 @@ header {
|
||||
}
|
||||
|
||||
.user-avatar-btn svg {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
stroke: currentColor;
|
||||
}
|
||||
|
||||
@@ -3607,6 +3788,17 @@ header {
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.active-task-item-clickable {
|
||||
cursor: pointer;
|
||||
transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.12s ease;
|
||||
}
|
||||
|
||||
.active-task-item-clickable:hover {
|
||||
border-color: rgba(0, 102, 255, 0.45);
|
||||
box-shadow: inset 0 1px 1px rgba(0, 0, 0, 0.03), 0 2px 8px rgba(0, 102, 255, 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.active-task-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -13425,6 +13617,11 @@ header {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
/* Keep action buttons visually aligned in vulnerability filters */
|
||||
.vulnerability-filters .btn-primary {
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.vulnerabilities-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
"version": "Current version",
|
||||
"toggleSidebar": "Collapse/expand sidebar"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"empty": "No new events",
|
||||
"markAllRead": "Mark all read",
|
||||
"markSingleRead": "Read",
|
||||
"itemDefaultTitle": "Notification"
|
||||
},
|
||||
"login": {
|
||||
"title": "Sign in to CyberStrikeAI",
|
||||
"subtitle": "Enter the access password from config",
|
||||
@@ -239,7 +246,20 @@
|
||||
},
|
||||
"hitl": {
|
||||
"pageTitle": "HITL approvals",
|
||||
"pendingTitle": "Pending approvals"
|
||||
"pendingTitle": "Pending approvals",
|
||||
"loading": "Loading...",
|
||||
"emptyState": "No pending approvals",
|
||||
"dismiss": "Dismiss",
|
||||
"conversationLabel": "Conversation:",
|
||||
"reviewEditHelp": "Review & edit mode: provide a JSON object to override tool arguments. Example: {\"command\":\"ls -la\"}",
|
||||
"approvalHelp": "Approval mode: only approve/reject, argument editing is disabled.",
|
||||
"commentHelp": "Comment (optional): briefly note the approval reason.",
|
||||
"commentPlaceholder": "e.g. allow read-only command",
|
||||
"reject": "Reject",
|
||||
"approve": "Approve",
|
||||
"loadFailed": "Failed to load",
|
||||
"invalidJson": "Invalid JSON arguments",
|
||||
"submitFailedPrefix": "Submit failed:"
|
||||
},
|
||||
"progress": {
|
||||
"callingAI": "Calling AI model...",
|
||||
|
||||
@@ -33,6 +33,13 @@
|
||||
"version": "当前版本",
|
||||
"toggleSidebar": "折叠/展开侧边栏"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "事件通知",
|
||||
"empty": "暂无新事件",
|
||||
"markAllRead": "标记已读",
|
||||
"markSingleRead": "已读",
|
||||
"itemDefaultTitle": "通知"
|
||||
},
|
||||
"login": {
|
||||
"title": "登录 CyberStrikeAI",
|
||||
"subtitle": "请输入配置中的访问密码",
|
||||
@@ -239,7 +246,20 @@
|
||||
},
|
||||
"hitl": {
|
||||
"pageTitle": "人机协同审批",
|
||||
"pendingTitle": "待处理审批"
|
||||
"pendingTitle": "待处理审批",
|
||||
"loading": "加载中...",
|
||||
"emptyState": "暂无待审批项",
|
||||
"dismiss": "忽略",
|
||||
"conversationLabel": "会话:",
|
||||
"reviewEditHelp": "审查编辑模式:可填写 JSON 对象覆盖参数。示例:{\"command\":\"ls -la\"}",
|
||||
"approvalHelp": "审批模式:仅通过/拒绝,不支持改参。",
|
||||
"commentHelp": "备注(可选):建议写审批依据。",
|
||||
"commentPlaceholder": "例如:允许只读命令",
|
||||
"reject": "拒绝",
|
||||
"approve": "通过",
|
||||
"loadFailed": "加载失败",
|
||||
"invalidJson": "JSON 参数格式错误",
|
||||
"submitFailedPrefix": "提交失败:"
|
||||
},
|
||||
"progress": {
|
||||
"callingAI": "正在调用AI模型...",
|
||||
|
||||
+42
-16
@@ -7,6 +7,19 @@ function hitlModeNormalize(m) {
|
||||
return allowed.indexOf(v) >= 0 ? v : 'off';
|
||||
}
|
||||
|
||||
function hitlT(key, fallback, params) {
|
||||
const fullKey = 'hitl.' + key;
|
||||
try {
|
||||
if (typeof window.t === 'function') {
|
||||
const translated = window.t(fullKey, params || {});
|
||||
if (typeof translated === 'string' && translated && translated !== fullKey) {
|
||||
return translated;
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function hitlEffectiveEnabled(cfg) {
|
||||
if (!cfg) return false;
|
||||
if (cfg.enabled === true) return true;
|
||||
@@ -36,6 +49,18 @@ function hitlSensitiveToolsToArray(config) {
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeHitlTimeoutSeconds(v, fallback) {
|
||||
const n = Number(v);
|
||||
if (Number.isFinite(n)) {
|
||||
return n > 0 ? Math.floor(n) : 0;
|
||||
}
|
||||
const f = Number(fallback);
|
||||
if (Number.isFinite(f)) {
|
||||
return f > 0 ? Math.floor(f) : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function getCurrentConversationIdForHitl() {
|
||||
if (typeof window.currentConversationId === 'string' && window.currentConversationId) {
|
||||
return window.currentConversationId;
|
||||
@@ -84,6 +109,7 @@ async function saveHitlConversationConfig(conversationId, config) {
|
||||
const mode = hitlModeNormalize(config.mode || 'off');
|
||||
const enabled = typeof config.enabled === 'boolean' ? config.enabled : (mode !== 'off');
|
||||
const sensitiveTools = hitlSensitiveToolsToArray(config);
|
||||
const timeoutSeconds = normalizeHitlTimeoutSeconds(config.timeoutSeconds, 0);
|
||||
const resp = await hitlApiFetch('/api/hitl/config', {
|
||||
method: 'PUT',
|
||||
credentials: 'same-origin',
|
||||
@@ -93,7 +119,7 @@ async function saveHitlConversationConfig(conversationId, config) {
|
||||
enabled: enabled,
|
||||
mode: mode,
|
||||
sensitiveTools: sensitiveTools,
|
||||
timeoutSeconds: config.timeoutSeconds || 300
|
||||
timeoutSeconds: timeoutSeconds
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
@@ -126,7 +152,7 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
enabled: true,
|
||||
mode: localMode,
|
||||
sensitiveTools: localToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: cfg.timeoutSeconds || 300
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: localMode,
|
||||
@@ -146,7 +172,7 @@ async function syncHitlConfigFromServer(conversationId) {
|
||||
enabled: true,
|
||||
mode: glMode,
|
||||
sensitiveTools: glToolsStr.split(/[,\n\r]+/).map(function (s) { return s.trim(); }).filter(Boolean),
|
||||
timeoutSeconds: cfg.timeoutSeconds || 300
|
||||
timeoutSeconds: normalizeHitlTimeoutSeconds(cfg.timeoutSeconds, 0)
|
||||
};
|
||||
saveHitlConversationConfig(conversationId, {
|
||||
mode: glMode,
|
||||
@@ -265,7 +291,7 @@ async function followAgentRunAfterHitlDecision(conversationId) {
|
||||
async function refreshHitlPending() {
|
||||
const container = document.getElementById('hitl-pending-list');
|
||||
if (!container) return;
|
||||
container.innerHTML = '<div class="loading-spinner">Loading...</div>';
|
||||
container.innerHTML = '<div class="loading-spinner">' + escapeHtml(hitlT('loading', 'Loading...')) + '</div>';
|
||||
try {
|
||||
const resp = await hitlApiFetch('/api/hitl/pending', { credentials: 'same-origin' });
|
||||
if (!resp.ok) {
|
||||
@@ -274,7 +300,7 @@ async function refreshHitlPending() {
|
||||
const data = await resp.json();
|
||||
const items = Array.isArray(data.items) ? data.items : [];
|
||||
if (!items.length) {
|
||||
container.innerHTML = '<div class="empty-state">暂无待审批项</div>';
|
||||
container.innerHTML = '<div class="empty-state">' + escapeHtml(hitlT('emptyState', 'No pending approvals')) + '</div>';
|
||||
return;
|
||||
}
|
||||
container.innerHTML = items.map(function (item) {
|
||||
@@ -292,25 +318,25 @@ async function refreshHitlPending() {
|
||||
'<span class="hitl-tool-badge">' + escapeHtml(item.toolName || '-') + '</span>' +
|
||||
'<span class="hitl-mode-tag hitl-mode-tag--' + escapeHtml(mode) + '">' + escapeHtml(item.mode || '-') + '</span>' +
|
||||
'</div>' +
|
||||
'<button class="hitl-dismiss-btn" title="忽略" onclick="dismissHitlItem(' + qId + ')">×</button>' +
|
||||
'<button class="hitl-dismiss-btn" title="' + escapeHtml(hitlT('dismiss', 'Dismiss')) + '" onclick="dismissHitlItem(' + qId + ')">×</button>' +
|
||||
'</div>' +
|
||||
'<div class="hitl-pending-meta">会话:' + escapeHtml(item.conversationId || '-') + '</div>' +
|
||||
'<div class="hitl-pending-meta">' + escapeHtml(hitlT('conversationLabel', 'Conversation:')) + ' ' + escapeHtml(item.conversationId || '-') + '</div>' +
|
||||
'<pre class="hitl-pending-payload">' + escapeHtml(preview) + '</pre>' +
|
||||
(allowEdit
|
||||
? ('<div class="hitl-input-help">审查编辑模式:可填写 JSON 对象覆盖参数。示例:{"command":"ls -la"}</div>' +
|
||||
? ('<div class="hitl-input-help">' + escapeHtml(hitlT('reviewEditHelp', 'Review & edit mode: provide a JSON object to override tool arguments. Example: {"command":"ls -la"}')) + '</div>' +
|
||||
'<textarea id="hitl-edit-' + escId + '" class="hitl-edit-args" placeholder=\'{"command":"ls -la"}\'></textarea>')
|
||||
: '<div class="hitl-input-help">审批模式:仅通过/拒绝,不支持改参。</div>') +
|
||||
'<div class="hitl-input-help">备注(可选):建议写审批依据。</div>' +
|
||||
'<input id="hitl-comment-' + escId + '" class="hitl-config-input hitl-inline-comment" type="text" placeholder="例如:允许只读命令">' +
|
||||
: '<div class="hitl-input-help">' + escapeHtml(hitlT('approvalHelp', 'Approval mode: only approve/reject, argument editing is disabled.')) + '</div>') +
|
||||
'<div class="hitl-input-help">' + escapeHtml(hitlT('commentHelp', 'Comment (optional): briefly note the approval reason.')) + '</div>' +
|
||||
'<input id="hitl-comment-' + escId + '" class="hitl-config-input hitl-inline-comment" type="text" placeholder="' + escapeHtml(hitlT('commentPlaceholder', 'e.g. allow read-only command')) + '">' +
|
||||
'<div class="hitl-pending-actions">' +
|
||||
'<button class="btn-secondary" onclick="submitHitlDecision(' + qId + ',"reject",' + qConv + ')">拒绝</button>' +
|
||||
'<button class="btn-primary" onclick="submitHitlDecision(' + qId + ',"approve",' + qConv + ')">通过</button>' +
|
||||
'<button class="btn-secondary" onclick="submitHitlDecision(' + qId + ',"reject",' + qConv + ')">' + escapeHtml(hitlT('reject', 'Reject')) + '</button>' +
|
||||
'<button class="btn-primary" onclick="submitHitlDecision(' + qId + ',"approve",' + qConv + ')">' + escapeHtml(hitlT('approve', 'Approve')) + '</button>' +
|
||||
'</div>' +
|
||||
'</div>'
|
||||
);
|
||||
}).join('');
|
||||
} catch (e) {
|
||||
container.innerHTML = '<div class="empty-state">加载失败</div>';
|
||||
container.innerHTML = '<div class="empty-state">' + escapeHtml(hitlT('loadFailed', 'Failed to load')) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -323,7 +349,7 @@ async function submitHitlDecision(interruptId, decision, conversationIdOpt) {
|
||||
try {
|
||||
editedArguments = JSON.parse(editBox.value.trim());
|
||||
} catch (e) {
|
||||
alert('JSON 参数格式错误');
|
||||
alert(hitlT('invalidJson', 'Invalid JSON arguments'));
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -344,7 +370,7 @@ async function submitHitlDecisionWithPayload(interruptId, decision, comment, edi
|
||||
await dismissHitlItem(interruptId, true);
|
||||
return true;
|
||||
}
|
||||
alert('提交失败:' + errText);
|
||||
alert(hitlT('submitFailedPrefix', 'Submit failed:') + ' ' + errText);
|
||||
return false;
|
||||
}
|
||||
refreshHitlPending();
|
||||
|
||||
@@ -2348,9 +2348,28 @@ function renderActiveTasks(tasks) {
|
||||
bar.style.display = 'flex';
|
||||
bar.innerHTML = '';
|
||||
|
||||
function openActiveTaskConversation(conversationId) {
|
||||
if (!conversationId) return;
|
||||
if (typeof switchPage === 'function') {
|
||||
switchPage('chat');
|
||||
}
|
||||
if (typeof window.loadConversation === 'function') {
|
||||
setTimeout(function () {
|
||||
window.loadConversation(conversationId);
|
||||
}, 120);
|
||||
return;
|
||||
}
|
||||
window.location.hash = 'chat?conversation=' + encodeURIComponent(conversationId);
|
||||
}
|
||||
|
||||
normalizedTasks.forEach(task => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'active-task-item';
|
||||
item.className = 'active-task-item active-task-item-clickable';
|
||||
if (task && task.conversationId) {
|
||||
item.title = (typeof window.t === 'function' ? window.t('tasks.viewConversation') : '查看会话');
|
||||
item.setAttribute('role', 'button');
|
||||
item.onclick = () => openActiveTaskConversation(task.conversationId);
|
||||
}
|
||||
|
||||
const startedTime = task.startedAt ? new Date(task.startedAt) : null;
|
||||
const taskTimeLocale = getCurrentTimeLocale();
|
||||
@@ -2388,7 +2407,10 @@ function renderActiveTasks(tasks) {
|
||||
if (!isFinalStatus) {
|
||||
const cancelBtn = item.querySelector('.active-task-cancel');
|
||||
if (cancelBtn) {
|
||||
cancelBtn.onclick = () => cancelActiveTask(task.conversationId, cancelBtn);
|
||||
cancelBtn.onclick = (evt) => {
|
||||
evt.stopPropagation();
|
||||
cancelActiveTask(task.conversationId, cancelBtn);
|
||||
};
|
||||
if (task.status === 'cancelling') {
|
||||
cancelBtn.disabled = true;
|
||||
cancelBtn.textContent = typeof window.t === 'function' ? window.t('tasks.cancelling') : '取消中...';
|
||||
|
||||
@@ -0,0 +1,321 @@
|
||||
(function () {
|
||||
const STORAGE_LAST_SEEN_KEY = 'cyberstrike-notification-last-seen-at';
|
||||
const POLL_INTERVAL_ACTIVE_MS = 15000;
|
||||
const POLL_INTERVAL_HIDDEN_MS = 60000;
|
||||
const MAX_RENDER_ITEMS = 20;
|
||||
|
||||
const state = {
|
||||
inFlight: false,
|
||||
timerId: null,
|
||||
dropdownOpen: false,
|
||||
lastSeenAt: readLastSeenAt(),
|
||||
items: [],
|
||||
unreadCount: 0,
|
||||
};
|
||||
|
||||
function readLastSeenAt() {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_LAST_SEEN_KEY);
|
||||
const n = Number(raw);
|
||||
if (Number.isFinite(n) && n > 0) return n;
|
||||
} catch (e) {
|
||||
console.warn('读取通知已读时间失败:', e);
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function persistLastSeenAt(ts) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_LAST_SEEN_KEY, String(ts));
|
||||
} catch (e) {
|
||||
console.warn('保存通知已读时间失败:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function getTimeMs(value) {
|
||||
if (!value) return 0;
|
||||
const d = new Date(value);
|
||||
const ms = d.getTime();
|
||||
return Number.isFinite(ms) ? ms : 0;
|
||||
}
|
||||
|
||||
function getLocale() {
|
||||
if (typeof window !== 'undefined') {
|
||||
if (typeof window.__locale === 'string' && window.__locale) {
|
||||
return window.__locale;
|
||||
}
|
||||
if (typeof window.currentLang === 'string' && window.currentLang) {
|
||||
return window.currentLang;
|
||||
}
|
||||
}
|
||||
return 'zh-CN';
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
const ms = getTimeMs(value);
|
||||
if (!ms) return '-';
|
||||
return new Date(ms).toLocaleString(getLocale());
|
||||
}
|
||||
|
||||
function htmlEscape(value) {
|
||||
if (typeof window.escapeHtml === 'function') {
|
||||
return window.escapeHtml(value == null ? '' : String(value));
|
||||
}
|
||||
const div = document.createElement('div');
|
||||
div.textContent = value == null ? '' : String(value);
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function t(key, fallback, params) {
|
||||
if (typeof window !== 'undefined' && typeof window.t === 'function') {
|
||||
try {
|
||||
const translated = window.t(key, params || {});
|
||||
if (translated && translated !== key) return translated;
|
||||
} catch (_ignored) {}
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
async function apiJson(url, options) {
|
||||
if (typeof window.apiFetch !== 'function') return null;
|
||||
const res = await window.apiFetch(url, options || {});
|
||||
if (!res.ok) return null;
|
||||
return res.json();
|
||||
}
|
||||
|
||||
async function fetchNotificationSummary() {
|
||||
const url = '/api/notifications/summary?since='
|
||||
+ encodeURIComponent(String(state.lastSeenAt || 0))
|
||||
+ '&limit=80&lang=' + encodeURIComponent(getLocale());
|
||||
try {
|
||||
const summary = await apiJson(url);
|
||||
if (summary && typeof summary === 'object') {
|
||||
return summary;
|
||||
}
|
||||
} catch (_ignored) {}
|
||||
return null;
|
||||
}
|
||||
|
||||
function renderBadge(count) {
|
||||
const badge = document.getElementById('notification-badge');
|
||||
const btn = document.getElementById('notification-bell-btn');
|
||||
if (!badge || !btn) return;
|
||||
if (count <= 0) {
|
||||
badge.style.display = 'none';
|
||||
btn.classList.remove('has-alert');
|
||||
return;
|
||||
}
|
||||
const text = count > 99 ? '99+' : String(count);
|
||||
badge.innerHTML = '<span class="notification-badge-text">' + htmlEscape(text) + '</span>';
|
||||
badge.style.display = 'inline-block';
|
||||
btn.classList.add('has-alert');
|
||||
}
|
||||
|
||||
function countP0(items) {
|
||||
return (Array.isArray(items) ? items : []).reduce((acc, item) => {
|
||||
if (!item || item.level !== 'p0') return acc;
|
||||
if (typeof item.count === 'number' && item.count > 0) return acc + item.count;
|
||||
return acc + 1;
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function markableItems(items) {
|
||||
return (Array.isArray(items) ? items : []).filter(item => item && item.actionable !== true && item.id);
|
||||
}
|
||||
|
||||
function hasAction(item) {
|
||||
if (!item || !item.type) return false;
|
||||
if (item.type === 'vulnerability_created' && item.vulnerabilityId) return true;
|
||||
if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) return true;
|
||||
if (item.type === 'task_failed' && item.executionId) return true;
|
||||
if (item.type === 'hitl_pending') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function openNotificationTarget(item) {
|
||||
if (!item || !item.type) return;
|
||||
if (item.type === 'vulnerability_created' && item.vulnerabilityId) {
|
||||
window.location.hash = 'vulnerabilities?id=' + encodeURIComponent(item.vulnerabilityId);
|
||||
return;
|
||||
}
|
||||
if ((item.type === 'task_completed' || item.type === 'long_running_tasks') && item.conversationId) {
|
||||
window.location.hash = 'chat?conversation=' + encodeURIComponent(item.conversationId);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'task_failed' && item.executionId) {
|
||||
window.location.hash = 'mcp-monitor';
|
||||
setTimeout(function () {
|
||||
if (typeof showMCPDetail === 'function') {
|
||||
showMCPDetail(item.executionId);
|
||||
}
|
||||
}, 450);
|
||||
return;
|
||||
}
|
||||
if (item.type === 'hitl_pending') {
|
||||
window.location.hash = 'hitl';
|
||||
}
|
||||
}
|
||||
|
||||
async function markItemsRead(eventIds) {
|
||||
if (!Array.isArray(eventIds) || !eventIds.length) return true;
|
||||
const payload = { eventIds: eventIds };
|
||||
try {
|
||||
const result = await apiJson('/api/notifications/read', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
return !!result;
|
||||
} catch (_ignored) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function renderNotificationList(items) {
|
||||
const list = document.getElementById('notification-list');
|
||||
if (!list) return;
|
||||
const renderItems = Array.isArray(items) ? items.slice(0, MAX_RENDER_ITEMS) : [];
|
||||
if (!renderItems.length) {
|
||||
list.innerHTML = '<div class="notification-empty">' + htmlEscape(t('notifications.empty', '暂无新事件')) + '</div>';
|
||||
return;
|
||||
}
|
||||
const html = renderItems.map(item => {
|
||||
const canMarkRead = item.actionable !== true && !!item.id;
|
||||
const canView = hasAction(item);
|
||||
return `
|
||||
<div class="notification-item notification-level-${htmlEscape(item.level || 'p2')}">
|
||||
<div class="notification-item-header">
|
||||
<div class="notification-item-title">${htmlEscape(item.title || t('notifications.itemDefaultTitle', '通知'))}</div>
|
||||
<div class="notification-item-actions">
|
||||
${canView ? `<button class="notification-item-action-btn notification-item-view-btn" type="button" data-action-id="${htmlEscape(item.id || '')}">${htmlEscape(t('common.view', '查看'))}</button>` : ''}
|
||||
${canMarkRead ? `<button class="notification-item-action-btn notification-item-read-btn" type="button" data-notification-id="${htmlEscape(item.id)}">${htmlEscape(t('notifications.markSingleRead', '已读'))}</button>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-item-desc">${htmlEscape(item.desc || '')}</div>
|
||||
<div class="notification-item-time">${htmlEscape(formatTime(item.ts))}</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
list.innerHTML = html;
|
||||
const viewButtons = list.querySelectorAll('.notification-item-view-btn');
|
||||
viewButtons.forEach(btn => {
|
||||
btn.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const eventID = btn.getAttribute('data-action-id') || '';
|
||||
if (!eventID) return;
|
||||
const item = state.items.find(it => it && it.id === eventID);
|
||||
if (!item) return;
|
||||
openNotificationTarget(item);
|
||||
closeDropdown();
|
||||
});
|
||||
});
|
||||
const readButtons = list.querySelectorAll('.notification-item-read-btn');
|
||||
readButtons.forEach(btn => {
|
||||
btn.addEventListener('click', async function (event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const eventID = btn.getAttribute('data-notification-id') || '';
|
||||
if (!eventID) return;
|
||||
const ok = await markItemsRead([eventID]);
|
||||
if (ok) {
|
||||
await refreshNotifications();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function closeDropdown() {
|
||||
const dropdown = document.getElementById('notification-dropdown');
|
||||
const bellBtn = document.getElementById('notification-bell-btn');
|
||||
if (dropdown) dropdown.style.display = 'none';
|
||||
if (bellBtn) bellBtn.classList.remove('active');
|
||||
state.dropdownOpen = false;
|
||||
}
|
||||
|
||||
function markSeenNow() {
|
||||
state.lastSeenAt = Date.now();
|
||||
persistLastSeenAt(state.lastSeenAt);
|
||||
}
|
||||
|
||||
async function refreshNotifications() {
|
||||
if (state.inFlight) return;
|
||||
state.inFlight = true;
|
||||
try {
|
||||
const summary = await fetchNotificationSummary();
|
||||
const items = summary && Array.isArray(summary.items) ? summary.items : [];
|
||||
state.items = items;
|
||||
const unreadCount = summary && Number.isFinite(Number(summary.unreadCount))
|
||||
? Number(summary.unreadCount)
|
||||
: countP0(items);
|
||||
state.unreadCount = Math.max(0, unreadCount);
|
||||
renderBadge(state.unreadCount);
|
||||
renderNotificationList(items);
|
||||
} catch (e) {
|
||||
console.warn('刷新通知失败:', e);
|
||||
} finally {
|
||||
state.inFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleNextPoll() {
|
||||
if (state.timerId) {
|
||||
window.clearTimeout(state.timerId);
|
||||
state.timerId = null;
|
||||
}
|
||||
const interval = document.hidden ? POLL_INTERVAL_HIDDEN_MS : POLL_INTERVAL_ACTIVE_MS;
|
||||
state.timerId = window.setTimeout(async function () {
|
||||
await refreshNotifications();
|
||||
scheduleNextPoll();
|
||||
}, interval);
|
||||
}
|
||||
|
||||
function handleDocumentClick(event) {
|
||||
const container = document.querySelector('.notification-menu-container');
|
||||
if (!container) return;
|
||||
if (!container.contains(event.target)) {
|
||||
closeDropdown();
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDropdown() {
|
||||
const dropdown = document.getElementById('notification-dropdown');
|
||||
const bellBtn = document.getElementById('notification-bell-btn');
|
||||
if (!dropdown || !bellBtn) return;
|
||||
const isOpen = dropdown.style.display !== 'none';
|
||||
if (isOpen) {
|
||||
closeDropdown();
|
||||
return;
|
||||
}
|
||||
dropdown.style.display = 'block';
|
||||
bellBtn.classList.add('active');
|
||||
state.dropdownOpen = true;
|
||||
await refreshNotifications();
|
||||
}
|
||||
|
||||
async function markAllSeen() {
|
||||
const ids = markableItems(state.items).map(item => item.id);
|
||||
const ok = await markItemsRead(ids);
|
||||
if (ok) {
|
||||
markSeenNow();
|
||||
await refreshNotifications();
|
||||
}
|
||||
}
|
||||
|
||||
function initNotifications() {
|
||||
const bellBtn = document.getElementById('notification-bell-btn');
|
||||
if (!bellBtn) return;
|
||||
document.addEventListener('click', handleDocumentClick);
|
||||
document.addEventListener('visibilitychange', scheduleNextPoll);
|
||||
document.addEventListener('languagechange', function () {
|
||||
refreshNotifications();
|
||||
});
|
||||
refreshNotifications();
|
||||
scheduleNextPoll();
|
||||
}
|
||||
|
||||
window.toggleNotificationDropdown = toggleDropdown;
|
||||
window.markAllNotificationsSeen = markAllSeen;
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initNotifications);
|
||||
})();
|
||||
@@ -61,7 +61,7 @@ let vulnerabilityPagination = {
|
||||
totalPages: 1
|
||||
};
|
||||
|
||||
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= 同步筛选(对话菜单、任务管理联动)
|
||||
// 从地址栏 #vulnerabilities?conversation_id= / ?task_id= / ?id= 同步筛选(通知/对话菜单/任务管理联动)
|
||||
function syncVulnerabilityFiltersFromLocationHash() {
|
||||
const hash = window.location.hash.slice(1);
|
||||
const hashParts = hash.split('?');
|
||||
@@ -69,19 +69,27 @@ function syncVulnerabilityFiltersFromLocationHash() {
|
||||
return;
|
||||
}
|
||||
const params = new URLSearchParams(hashParts.slice(1).join('?'));
|
||||
const vid = (params.get('id') || '').trim();
|
||||
const cid = (params.get('conversation_id') || '').trim();
|
||||
const tid = (params.get('task_id') || '').trim();
|
||||
if (!cid && !tid) {
|
||||
if (!vid && !cid && !tid) {
|
||||
return;
|
||||
}
|
||||
|
||||
vulnerabilityFilters.id = '';
|
||||
vulnerabilityFilters.conversation_id = '';
|
||||
vulnerabilityFilters.task_id = '';
|
||||
const idEl = document.getElementById('vulnerability-id-filter');
|
||||
const convEl = document.getElementById('vulnerability-conversation-filter');
|
||||
const taskEl = document.getElementById('vulnerability-task-filter');
|
||||
if (idEl) idEl.value = '';
|
||||
if (convEl) convEl.value = '';
|
||||
if (taskEl) taskEl.value = '';
|
||||
|
||||
if (vid) {
|
||||
vulnerabilityFilters.id = vid;
|
||||
if (idEl) idEl.value = vid;
|
||||
}
|
||||
if (cid) {
|
||||
vulnerabilityFilters.conversation_id = cid;
|
||||
if (convEl) convEl.value = cid;
|
||||
@@ -334,6 +342,13 @@ function renderVulnerabilities(vulnerabilities) {
|
||||
if (typeof window.applyTranslations === 'function') {
|
||||
window.applyTranslations(listContainer);
|
||||
}
|
||||
|
||||
// 如果通过漏洞ID筛选且只返回一条记录,自动展开详情(提升“点击查看”的用户体验)
|
||||
if (vulnerabilities.length === 1 && vulnerabilityFilters.id && vulnerabilityFilters.id === vulnerabilities[0].id) {
|
||||
setTimeout(() => {
|
||||
toggleVulnerabilityDetails(vulnerabilities[0].id);
|
||||
}, 300);
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染分页控件
|
||||
|
||||
@@ -63,6 +63,24 @@
|
||||
<div class="lang-option" data-lang="en-US" onclick="onLanguageSelect('en-US')">English</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="notification-menu-container">
|
||||
<button class="notification-btn" id="notification-bell-btn" onclick="toggleNotificationDropdown()" data-i18n="notifications.title" data-i18n-attr="title" data-i18n-skip-text="true" title="事件通知">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M18 8a6 6 0 0 0-12 0c0 7-3 9-3 9h18s-3-2-3-9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M13.73 21a2 2 0 0 1-3.46 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span class="notification-badge" id="notification-badge" style="display: none;">0</span>
|
||||
</button>
|
||||
<div id="notification-dropdown" class="notification-dropdown" style="display: none;">
|
||||
<div class="notification-dropdown-header">
|
||||
<span id="notification-dropdown-title" data-i18n="notifications.title">事件通知</span>
|
||||
<button class="notification-mark-read-btn" id="notification-mark-all-read-btn" type="button" onclick="markAllNotificationsSeen()" data-i18n="notifications.markAllRead">标记已读</button>
|
||||
</div>
|
||||
<div id="notification-list" class="notification-list">
|
||||
<div class="notification-empty" data-i18n="notifications.empty">暂无新事件</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="user-menu-container">
|
||||
<button class="user-avatar-btn" onclick="toggleUserMenu()" data-i18n="header.userMenu" data-i18n-attr="title" data-i18n-skip-text="true" title="用户菜单">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
@@ -2832,6 +2850,7 @@
|
||||
<script src="/static/js/i18n.js"></script>
|
||||
<script src="/static/js/builtin-tools.js"></script>
|
||||
<script src="/static/js/auth.js"></script>
|
||||
<script src="/static/js/notifications.js"></script>
|
||||
<script src="/static/js/info-collect.js"></script>
|
||||
<script src="/static/js/router.js"></script>
|
||||
<script src="/static/js/agents.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user