mirror of
https://github.com/Ed1s0nZ/CyberStrikeAI.git
synced 2026-06-10 08:13:59 +02:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 07be48ae59 | |||
| 529f94a4f7 | |||
| d2fe023d7e | |||
| 09e858619e | |||
| 9c54291295 | |||
| b3f7b8494b | |||
| 849c644a86 | |||
| 9e0525abc1 | |||
| 6bacac2e6a | |||
| 244307b52c | |||
| faaac5fbd7 | |||
| 3392fefedf | |||
| abef51b805 | |||
| 8143d8f220 | |||
| 73337c5226 | |||
| c9c9ca1eec | |||
| 25f8b610fb | |||
| 6bfa7b8959 | |||
| 99a41d8188 | |||
| 6d04753761 | |||
| a08df7ab79 |
+1
-2
@@ -1075,6 +1075,7 @@ func setupRoutes(
|
|||||||
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
|
protected.DELETE("/vulnerabilities/:id", vulnerabilityHandler.DeleteVulnerability)
|
||||||
|
|
||||||
// 项目管理与事实黑板
|
// 项目管理与事实黑板
|
||||||
|
protected.GET("/projects/dashboard-summary", projectHandler.GetDashboardSummary)
|
||||||
protected.GET("/projects", projectHandler.ListProjects)
|
protected.GET("/projects", projectHandler.ListProjects)
|
||||||
protected.POST("/projects", projectHandler.CreateProject)
|
protected.POST("/projects", projectHandler.CreateProject)
|
||||||
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
|
protected.GET("/projects/:id/stats", projectHandler.GetProjectStats)
|
||||||
@@ -1083,8 +1084,6 @@ func setupRoutes(
|
|||||||
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
protected.PUT("/projects/:id", projectHandler.UpdateProject)
|
||||||
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
protected.DELETE("/projects/:id", projectHandler.DeleteProject)
|
||||||
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
protected.GET("/projects/:id/facts", projectHandler.ListFacts)
|
||||||
protected.GET("/projects/:id/facts/:factId/previous-version", projectHandler.GetFactPreviousVersion)
|
|
||||||
protected.GET("/projects/:id/facts/:factId/versions", projectHandler.ListFactVersions)
|
|
||||||
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
protected.POST("/projects/:id/facts", projectHandler.CreateFact)
|
||||||
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
protected.PUT("/projects/:id/facts/:factId", projectHandler.UpdateFact)
|
||||||
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
|
protected.DELETE("/projects/:id/facts/:factId", projectHandler.DeleteFact)
|
||||||
|
|||||||
@@ -240,6 +240,8 @@ type MultiAgentEinoMiddlewareConfig struct {
|
|||||||
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
SummarizationTriggerRatio float64 `yaml:"summarization_trigger_ratio,omitempty" json:"summarization_trigger_ratio,omitempty"`
|
||||||
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
// SummarizationEmitInternalEvents controls middleware internal event emission (default true).
|
||||||
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
SummarizationEmitInternalEvents *bool `yaml:"summarization_emit_internal_events,omitempty" json:"summarization_emit_internal_events,omitempty"`
|
||||||
|
// SummarizationRetryMaxAttempts is extra retries after the first summarization Generate attempt; 0 = default 3.
|
||||||
|
SummarizationRetryMaxAttempts int `yaml:"summarization_retry_max_attempts,omitempty" json:"summarization_retry_max_attempts,omitempty"`
|
||||||
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
// PlanExecuteUserInputBudgetRatio caps planner/replanner/executor userInput prompt budget ratio (default 0.35).
|
||||||
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
PlanExecuteUserInputBudgetRatio float64 `yaml:"plan_execute_user_input_budget_ratio,omitempty" json:"plan_execute_user_input_budget_ratio,omitempty"`
|
||||||
// PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2).
|
// PlanExecuteExecutedStepsBudgetRatio caps executed_steps prompt budget ratio (default 0.2).
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ func (db *DB) LoadAttackChainNodes(conversationID string) ([]AttackChainNode, er
|
|||||||
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
|
SELECT id, node_type, node_name, tool_execution_id, metadata, risk_score
|
||||||
FROM attack_chain_nodes
|
FROM attack_chain_nodes
|
||||||
WHERE conversation_id = ?
|
WHERE conversation_id = ?
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, rowid ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.Query(query, conversationID)
|
rows, err := db.Query(query, conversationID)
|
||||||
@@ -123,7 +123,7 @@ func (db *DB) LoadAttackChainEdges(conversationID string) ([]AttackChainEdge, er
|
|||||||
SELECT id, source_node_id, target_node_id, edge_type, weight
|
SELECT id, source_node_id, target_node_id, edge_type, weight
|
||||||
FROM attack_chain_edges
|
FROM attack_chain_edges
|
||||||
WHERE conversation_id = ?
|
WHERE conversation_id = ?
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, rowid ASC
|
||||||
`
|
`
|
||||||
|
|
||||||
rows, err := db.Query(query, conversationID)
|
rows, err := db.Query(query, conversationID)
|
||||||
|
|||||||
@@ -840,7 +840,7 @@ func (db *DB) PopQueuedC2Tasks(sessionID string, limit int) ([]*C2Task, error) {
|
|||||||
created_at
|
created_at
|
||||||
FROM c2_tasks
|
FROM c2_tasks
|
||||||
WHERE session_id = ? AND (status = 'queued' AND (approval_status = '' OR approval_status = 'approved'))
|
WHERE session_id = ? AND (status = 'queued' AND (approval_status = '' OR approval_status = 'approved'))
|
||||||
ORDER BY created_at ASC
|
ORDER BY created_at ASC, rowid ASC
|
||||||
LIMIT ?
|
LIMIT ?
|
||||||
`
|
`
|
||||||
rows, err := tx.Query(query, sessionID, limit)
|
rows, err := tx.Query(query, sessionID, limit)
|
||||||
|
|||||||
@@ -361,6 +361,27 @@ func (db *DB) GetConversationLite(id string) (*Conversation, error) {
|
|||||||
return &conv, nil
|
return &conv, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountConversations 统计对话数量。
|
||||||
|
func (db *DB) CountConversations(search string) (int, error) {
|
||||||
|
var count int
|
||||||
|
var err error
|
||||||
|
if search != "" {
|
||||||
|
searchPattern := "%" + search + "%"
|
||||||
|
err = db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM conversations c
|
||||||
|
WHERE c.title LIKE ?
|
||||||
|
OR EXISTS (SELECT 1 FROM messages m WHERE m.conversation_id = c.id AND m.content LIKE ?)`,
|
||||||
|
searchPattern, searchPattern,
|
||||||
|
).Scan(&count)
|
||||||
|
} else {
|
||||||
|
err = db.QueryRow(`SELECT COUNT(*) FROM conversations`).Scan(&count)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("统计对话失败: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListConversations 列出所有对话
|
// ListConversations 列出所有对话
|
||||||
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversation, error) {
|
||||||
var rows *sql.Rows
|
var rows *sql.Rows
|
||||||
@@ -430,6 +451,73 @@ func (db *DB) ListConversations(limit, offset int, search string) ([]*Conversati
|
|||||||
return conversations, nil
|
return conversations, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ungroupedConversationsSQL = `
|
||||||
|
FROM conversations c
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM conversation_group_mappings cgm WHERE cgm.conversation_id = c.id
|
||||||
|
)`
|
||||||
|
|
||||||
|
// CountUngroupedConversations 统计不在任何分组中的对话数量。
|
||||||
|
func (db *DB) CountUngroupedConversations() (int, error) {
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) ` + ungroupedConversationsSQL).Scan(&count); err != nil {
|
||||||
|
return 0, fmt.Errorf("统计未分组对话失败: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListUngroupedConversations 列出不在任何分组中的对话(最近对话侧栏)。
|
||||||
|
func (db *DB) ListUngroupedConversations(limit, offset int) ([]*Conversation, error) {
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT c.id, c.title, COALESCE(c.pinned, 0), c.created_at, c.updated_at, c.project_id `+
|
||||||
|
ungroupedConversationsSQL+`
|
||||||
|
ORDER BY c.updated_at DESC
|
||||||
|
LIMIT ? OFFSET ?`,
|
||||||
|
limit, offset,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询未分组对话失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var conversations []*Conversation
|
||||||
|
for rows.Next() {
|
||||||
|
var conv Conversation
|
||||||
|
var createdAt, updatedAt string
|
||||||
|
var pinned int
|
||||||
|
var projectID sql.NullString
|
||||||
|
|
||||||
|
if err := rows.Scan(&conv.ID, &conv.Title, &pinned, &createdAt, &updatedAt, &projectID); err != nil {
|
||||||
|
return nil, fmt.Errorf("扫描对话失败: %w", err)
|
||||||
|
}
|
||||||
|
if projectID.Valid {
|
||||||
|
conv.ProjectID = strings.TrimSpace(projectID.String)
|
||||||
|
}
|
||||||
|
|
||||||
|
var err1, err2 error
|
||||||
|
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05.999999999-07:00", createdAt)
|
||||||
|
if err1 != nil {
|
||||||
|
conv.CreatedAt, err1 = time.Parse("2006-01-02 15:04:05", createdAt)
|
||||||
|
}
|
||||||
|
if err1 != nil {
|
||||||
|
conv.CreatedAt, _ = time.Parse(time.RFC3339, createdAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05.999999999-07:00", updatedAt)
|
||||||
|
if err2 != nil {
|
||||||
|
conv.UpdatedAt, err2 = time.Parse("2006-01-02 15:04:05", updatedAt)
|
||||||
|
}
|
||||||
|
if err2 != nil {
|
||||||
|
conv.UpdatedAt, _ = time.Parse(time.RFC3339, updatedAt)
|
||||||
|
}
|
||||||
|
|
||||||
|
conv.Pinned = pinned != 0
|
||||||
|
conversations = append(conversations, &conv)
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversations, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
// UpdateConversationTitle 更新对话标题
|
// UpdateConversationTitle 更新对话标题
|
||||||
func (db *DB) UpdateConversationTitle(id, title string) error {
|
func (db *DB) UpdateConversationTitle(id, title string) error {
|
||||||
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
// 注意:不更新 updated_at,因为重命名操作不应该改变对话的更新时间
|
||||||
@@ -604,7 +692,7 @@ func (db *DB) UpdateAssistantMessageFinalize(messageID, content string, mcpExecu
|
|||||||
// GetMessages 获取对话的所有消息
|
// GetMessages 获取对话的所有消息
|
||||||
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
func (db *DB) GetMessages(conversationID string) ([]Message, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
"SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC",
|
"SELECT id, conversation_id, role, content, reasoning_content, mcp_execution_ids, created_at, updated_at FROM messages WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||||
conversationID,
|
conversationID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -799,7 +887,7 @@ func (db *DB) AddProcessDetail(messageID, conversationID, eventType, message str
|
|||||||
// GetProcessDetails 获取消息的过程详情
|
// GetProcessDetails 获取消息的过程详情
|
||||||
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC",
|
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE message_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||||
messageID,
|
messageID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -835,7 +923,7 @@ func (db *DB) GetProcessDetails(messageID string) ([]ProcessDetail, error) {
|
|||||||
// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组)
|
// GetProcessDetailsByConversation 获取对话的所有过程详情(按消息分组)
|
||||||
func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) {
|
func (db *DB) GetProcessDetailsByConversation(conversationID string) (map[string][]ProcessDetail, error) {
|
||||||
rows, err := db.Query(
|
rows, err := db.Query(
|
||||||
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC",
|
"SELECT id, message_id, conversation_id, event_type, message, data, created_at FROM process_details WHERE conversation_id = ? ORDER BY created_at ASC, rowid ASC",
|
||||||
conversationID,
|
conversationID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -334,7 +334,6 @@ func (db *DB) initTables() error {
|
|||||||
source_conversation_id TEXT,
|
source_conversation_id TEXT,
|
||||||
source_message_id TEXT,
|
source_message_id TEXT,
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
pinned INTEGER NOT NULL DEFAULT 0,
|
||||||
supersedes_fact_id TEXT,
|
|
||||||
related_vulnerability_id TEXT,
|
related_vulnerability_id TEXT,
|
||||||
created_at DATETIME NOT NULL,
|
created_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
@@ -342,25 +341,6 @@ func (db *DB) initTables() error {
|
|||||||
UNIQUE(project_id, fact_key)
|
UNIQUE(project_id, fact_key)
|
||||||
);`
|
);`
|
||||||
|
|
||||||
createProjectFactVersionsTable := `
|
|
||||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
fact_id TEXT NOT NULL,
|
|
||||||
project_id TEXT NOT NULL,
|
|
||||||
fact_key TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL DEFAULT 'note',
|
|
||||||
summary TEXT NOT NULL DEFAULT '',
|
|
||||||
body TEXT,
|
|
||||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
|
||||||
source_conversation_id TEXT,
|
|
||||||
source_message_id TEXT,
|
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
|
||||||
related_vulnerability_id TEXT,
|
|
||||||
archived_at DATETIME NOT NULL,
|
|
||||||
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
||||||
);`
|
|
||||||
|
|
||||||
// 创建漏洞表
|
// 创建漏洞表
|
||||||
createVulnerabilitiesTable := `
|
createVulnerabilitiesTable := `
|
||||||
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
CREATE TABLE IF NOT EXISTS vulnerabilities (
|
||||||
@@ -598,7 +578,6 @@ func (db *DB) initTables() error {
|
|||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_project_id ON project_facts(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_confidence ON project_facts(confidence);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id);
|
|
||||||
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
CREATE INDEX IF NOT EXISTS idx_conversations_project_id ON conversations(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
CREATE INDEX IF NOT EXISTS idx_vulnerabilities_project_id ON vulnerabilities(project_id);
|
||||||
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
CREATE INDEX IF NOT EXISTS idx_batch_tasks_queue_id ON batch_tasks(queue_id);
|
||||||
@@ -680,10 +659,6 @@ func (db *DB) initTables() error {
|
|||||||
return fmt.Errorf("创建project_facts表失败: %w", err)
|
return fmt.Errorf("创建project_facts表失败: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
if _, err := db.Exec(createProjectFactVersionsTable); err != nil {
|
|
||||||
return fmt.Errorf("创建project_fact_versions表失败: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
if _, err := db.Exec(createVulnerabilitiesTable); err != nil {
|
||||||
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
return fmt.Errorf("创建vulnerabilities表失败: %w", err)
|
||||||
}
|
}
|
||||||
@@ -754,8 +729,8 @@ func (db *DB) initTables() error {
|
|||||||
if err := db.migrateProjectsTable(); err != nil {
|
if err := db.migrateProjectsTable(); err != nil {
|
||||||
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
db.logger.Warn("迁移projects相关表失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
if err := db.migrateProjectFactVersionsTable(); err != nil {
|
if err := db.dropProjectFactVersionsTable(); err != nil {
|
||||||
db.logger.Warn("迁移project_fact_versions表失败", zap.Error(err))
|
db.logger.Warn("清理project_fact_versions表失败", zap.Error(err))
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := db.migrateWebshellConnectionsTable(); err != nil {
|
if err := db.migrateWebshellConnectionsTable(); err != nil {
|
||||||
@@ -1153,33 +1128,11 @@ func (db *DB) migrateProjectsTable() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// migrateProjectFactVersionsTable 为已有库创建事实版本表。
|
// dropProjectFactVersionsTable 移除已废弃的事实版本归档表。
|
||||||
func (db *DB) migrateProjectFactVersionsTable() error {
|
func (db *DB) dropProjectFactVersionsTable() error {
|
||||||
ddl := `
|
_, err := db.Exec(`DROP TABLE IF EXISTS project_fact_versions`)
|
||||||
CREATE TABLE IF NOT EXISTS project_fact_versions (
|
|
||||||
id TEXT PRIMARY KEY,
|
|
||||||
fact_id TEXT NOT NULL,
|
|
||||||
project_id TEXT NOT NULL,
|
|
||||||
fact_key TEXT NOT NULL,
|
|
||||||
category TEXT NOT NULL DEFAULT 'note',
|
|
||||||
summary TEXT NOT NULL DEFAULT '',
|
|
||||||
body TEXT,
|
|
||||||
confidence TEXT NOT NULL DEFAULT 'tentative',
|
|
||||||
source_conversation_id TEXT,
|
|
||||||
source_message_id TEXT,
|
|
||||||
pinned INTEGER NOT NULL DEFAULT 0,
|
|
||||||
related_vulnerability_id TEXT,
|
|
||||||
archived_at DATETIME NOT NULL,
|
|
||||||
FOREIGN KEY (fact_id) REFERENCES project_facts(id) ON DELETE CASCADE,
|
|
||||||
FOREIGN KEY (project_id) REFERENCES projects(id) ON DELETE CASCADE
|
|
||||||
);`
|
|
||||||
if _, err := db.Exec(ddl); err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_fact_versions_fact_id ON project_fact_versions(fact_id)`)
|
|
||||||
_, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_project_facts_related_vuln ON project_facts(related_vulnerability_id)`)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
// migrateVulnerabilitiesTable 迁移 vulnerabilities 表,补充标签字段
|
||||||
func (db *DB) migrateVulnerabilitiesTable() error {
|
func (db *DB) migrateVulnerabilitiesTable() error {
|
||||||
|
|||||||
@@ -51,7 +51,6 @@ type ProjectFact struct {
|
|||||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
||||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
SourceMessageID string `json:"source_message_id,omitempty"`
|
||||||
Pinned bool `json:"pinned"`
|
Pinned bool `json:"pinned"`
|
||||||
SupersedesFactID string `json:"supersedes_fact_id,omitempty"`
|
|
||||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -112,10 +111,30 @@ func (db *DB) GetProject(id string) (*Project, error) {
|
|||||||
return &p, nil
|
return &p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CountProjects 统计项目数量。
|
||||||
|
func (db *DB) CountProjects(status, search string) (int, error) {
|
||||||
|
query := `SELECT COUNT(*) FROM projects WHERE 1=1`
|
||||||
|
args := []interface{}{}
|
||||||
|
if s := strings.TrimSpace(status); s != "" {
|
||||||
|
query += " AND status = ?"
|
||||||
|
args = append(args, s)
|
||||||
|
}
|
||||||
|
if q := strings.TrimSpace(search); q != "" {
|
||||||
|
pattern := "%" + q + "%"
|
||||||
|
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
||||||
|
args = append(args, pattern, pattern)
|
||||||
|
}
|
||||||
|
var count int
|
||||||
|
if err := db.QueryRow(query, args...).Scan(&count); err != nil {
|
||||||
|
return 0, fmt.Errorf("统计项目失败: %w", err)
|
||||||
|
}
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ListProjects 列出项目。
|
// ListProjects 列出项目。
|
||||||
func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error) {
|
func (db *DB) ListProjects(status, search string, limit, offset int) ([]*Project, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 200
|
limit = 50
|
||||||
}
|
}
|
||||||
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
query := `SELECT id, name, COALESCE(description,''), COALESCE(scope_json,''), status, pinned, created_at, updated_at
|
||||||
FROM projects WHERE 1=1`
|
FROM projects WHERE 1=1`
|
||||||
@@ -124,6 +143,11 @@ func (db *DB) ListProjects(status string, limit, offset int) ([]*Project, error)
|
|||||||
query += " AND status = ?"
|
query += " AND status = ?"
|
||||||
args = append(args, s)
|
args = append(args, s)
|
||||||
}
|
}
|
||||||
|
if q := strings.TrimSpace(search); q != "" {
|
||||||
|
pattern := "%" + q + "%"
|
||||||
|
query += " AND (name LIKE ? OR COALESCE(description,'') LIKE ?)"
|
||||||
|
args = append(args, pattern, pattern)
|
||||||
|
}
|
||||||
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
query += " ORDER BY pinned DESC, updated_at DESC LIMIT ? OFFSET ?"
|
||||||
args = append(args, limit, offset)
|
args = append(args, limit, offset)
|
||||||
|
|
||||||
@@ -215,7 +239,7 @@ func (db *DB) SetConversationProjectID(conversationID, projectID string) error {
|
|||||||
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
|
func (db *DB) ListProjectFactsForIndex(projectID string, includeDeprecated bool) ([]*ProjectFact, error) {
|
||||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||||
FROM project_facts WHERE project_id = ?`
|
FROM project_facts WHERE project_id = ?`
|
||||||
args := []interface{}{projectID}
|
args := []interface{}{projectID}
|
||||||
if !includeDeprecated {
|
if !includeDeprecated {
|
||||||
@@ -237,7 +261,7 @@ func (db *DB) ListProjectFacts(projectID string, filter ProjectFactListFilter, l
|
|||||||
}
|
}
|
||||||
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
query := `SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||||
FROM project_facts WHERE project_id = ?`
|
FROM project_facts WHERE project_id = ?`
|
||||||
args := []interface{}{projectID}
|
args := []interface{}{projectID}
|
||||||
if c := strings.TrimSpace(filter.Category); c != "" {
|
if c := strings.TrimSpace(filter.Category); c != "" {
|
||||||
@@ -276,7 +300,7 @@ func (db *DB) GetProjectFactByKey(projectID, factKey string) (*ProjectFact, erro
|
|||||||
row := db.QueryRow(
|
row := db.QueryRow(
|
||||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||||
FROM project_facts WHERE project_id = ? AND fact_key = ?`,
|
FROM project_facts WHERE project_id = ? AND fact_key = ?`,
|
||||||
projectID, factKey,
|
projectID, factKey,
|
||||||
)
|
)
|
||||||
@@ -288,7 +312,7 @@ func (db *DB) GetProjectFact(id string) (*ProjectFact, error) {
|
|||||||
row := db.QueryRow(
|
row := db.QueryRow(
|
||||||
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
`SELECT id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
||||||
COALESCE(supersedes_fact_id,''), COALESCE(related_vulnerability_id,''), created_at, updated_at
|
COALESCE(related_vulnerability_id,''), created_at, updated_at
|
||||||
FROM project_facts WHERE id = ?`, id,
|
FROM project_facts WHERE id = ?`, id,
|
||||||
)
|
)
|
||||||
return scanProjectFactRow(row)
|
return scanProjectFactRow(row)
|
||||||
@@ -327,24 +351,15 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
|||||||
if strings.TrimSpace(f.Confidence) == "" {
|
if strings.TrimSpace(f.Confidence) == "" {
|
||||||
f.Confidence = existing.Confidence
|
f.Confidence = existing.Confidence
|
||||||
}
|
}
|
||||||
if projectFactContentChanged(existing, f) {
|
|
||||||
versionID, verr := db.InsertProjectFactVersion(existing)
|
|
||||||
if verr != nil {
|
|
||||||
return nil, verr
|
|
||||||
}
|
|
||||||
f.SupersedesFactID = versionID
|
|
||||||
} else if f.SupersedesFactID == "" {
|
|
||||||
f.SupersedesFactID = existing.SupersedesFactID
|
|
||||||
}
|
|
||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
`UPDATE project_facts SET category = ?, summary = ?, body = ?, confidence = ?,
|
||||||
source_conversation_id = COALESCE(?, source_conversation_id),
|
source_conversation_id = COALESCE(?, source_conversation_id),
|
||||||
source_message_id = COALESCE(?, source_message_id),
|
source_message_id = COALESCE(?, source_message_id),
|
||||||
pinned = ?, supersedes_fact_id = ?, related_vulnerability_id = ?, updated_at = ?
|
pinned = ?, related_vulnerability_id = ?, updated_at = ?
|
||||||
WHERE id = ?`,
|
WHERE id = ?`,
|
||||||
f.Category, f.Summary, f.Body, f.Confidence,
|
f.Category, f.Summary, f.Body, f.Confidence,
|
||||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||||
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
|
nullIfEmpty(f.RelatedVulnerabilityID), f.UpdatedAt, f.ID,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("更新事实失败: %w", err)
|
return nil, fmt.Errorf("更新事实失败: %w", err)
|
||||||
@@ -360,12 +375,12 @@ func (db *DB) UpsertProjectFact(f *ProjectFact) (*ProjectFact, error) {
|
|||||||
_, err = db.Exec(
|
_, err = db.Exec(
|
||||||
`INSERT INTO project_facts (
|
`INSERT INTO project_facts (
|
||||||
id, project_id, fact_key, category, summary, body, confidence,
|
id, project_id, fact_key, category, summary, body, confidence,
|
||||||
source_conversation_id, source_message_id, pinned, supersedes_fact_id, related_vulnerability_id,
|
source_conversation_id, source_message_id, pinned, related_vulnerability_id,
|
||||||
created_at, updated_at
|
created_at, updated_at
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||||
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
||||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
||||||
nullIfEmpty(f.SupersedesFactID), nullIfEmpty(f.RelatedVulnerabilityID),
|
nullIfEmpty(f.RelatedVulnerabilityID),
|
||||||
f.CreatedAt, f.UpdatedAt,
|
f.CreatedAt, f.UpdatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -440,7 +455,7 @@ func scanProjectFactRow(row *sql.Row) (*ProjectFact, error) {
|
|||||||
err := row.Scan(
|
err := row.Scan(
|
||||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||||
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == sql.ErrNoRows {
|
if err == sql.ErrNoRows {
|
||||||
@@ -461,7 +476,7 @@ func scanProjectFactFromRows(rows *sql.Rows) (*ProjectFact, error) {
|
|||||||
err := rows.Scan(
|
err := rows.Scan(
|
||||||
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
&f.ID, &f.ProjectID, &f.FactKey, &f.Category, &f.Summary, &f.Body, &f.Confidence,
|
||||||
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
&f.SourceConversationID, &f.SourceMessageID, &pinned,
|
||||||
&f.SupersedesFactID, &f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
&f.RelatedVulnerabilityID, &createdAt, &updatedAt,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
package database
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ProjectDashboardFact 仪表盘跨项目近期事实条目。
|
||||||
|
type ProjectDashboardFact struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
ProjectID string `json:"project_id"`
|
||||||
|
ProjectName string `json:"project_name"`
|
||||||
|
FactKey string `json:"fact_key"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
Confidence string `json:"confidence"`
|
||||||
|
Pinned bool `json:"pinned"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectDashboardTotals 仪表盘项目事实汇总计数。
|
||||||
|
type ProjectDashboardTotals struct {
|
||||||
|
ActiveProjects int `json:"active_projects"`
|
||||||
|
TotalFacts int `json:"total_facts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ProjectDashboardSummary 仪表盘项目情报摘要。
|
||||||
|
type ProjectDashboardSummary struct {
|
||||||
|
RecentFacts []ProjectDashboardFact `json:"recent_facts"`
|
||||||
|
Totals ProjectDashboardTotals `json:"totals"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetProjectDashboardSummary 聚合跨项目近期事实(仅活跃项目、排除 deprecated)。
|
||||||
|
func (db *DB) GetProjectDashboardSummary(factLimit int) (*ProjectDashboardSummary, error) {
|
||||||
|
if factLimit <= 0 {
|
||||||
|
factLimit = 5
|
||||||
|
}
|
||||||
|
if factLimit > 50 {
|
||||||
|
factLimit = 50
|
||||||
|
}
|
||||||
|
|
||||||
|
out := &ProjectDashboardSummary{
|
||||||
|
RecentFacts: []ProjectDashboardFact{},
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.QueryRow(`SELECT COUNT(*) FROM projects WHERE status = 'active'`).Scan(&out.Totals.ActiveProjects); err != nil {
|
||||||
|
return nil, fmt.Errorf("统计活跃项目失败: %w", err)
|
||||||
|
}
|
||||||
|
if err := db.QueryRow(
|
||||||
|
`SELECT COUNT(*) FROM project_facts f
|
||||||
|
INNER JOIN projects p ON p.id = f.project_id
|
||||||
|
WHERE f.confidence != 'deprecated' AND p.status = 'active'`,
|
||||||
|
).Scan(&out.Totals.TotalFacts); err != nil {
|
||||||
|
return nil, fmt.Errorf("统计事实失败: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query(
|
||||||
|
`SELECT f.id, f.project_id, p.name, f.fact_key, f.category, f.summary, f.confidence, f.pinned, f.updated_at
|
||||||
|
FROM project_facts f
|
||||||
|
INNER JOIN projects p ON p.id = f.project_id
|
||||||
|
WHERE f.confidence != 'deprecated' AND p.status = 'active'
|
||||||
|
ORDER BY f.pinned DESC, f.updated_at DESC
|
||||||
|
LIMIT ?`,
|
||||||
|
factLimit,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("查询近期事实失败: %w", err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var item ProjectDashboardFact
|
||||||
|
var pinned int
|
||||||
|
var updatedAt string
|
||||||
|
if err := rows.Scan(
|
||||||
|
&item.ID, &item.ProjectID, &item.ProjectName, &item.FactKey,
|
||||||
|
&item.Category, &item.Summary, &item.Confidence, &pinned, &updatedAt,
|
||||||
|
); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
item.Pinned = pinned != 0
|
||||||
|
item.ProjectName = strings.TrimSpace(item.ProjectName)
|
||||||
|
item.UpdatedAt = parseDBTime(updatedAt)
|
||||||
|
out.RecentFacts = append(out.RecentFacts, item)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -135,54 +135,6 @@ func TestRestoreProjectFact(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestUpsertProjectFact_createsVersionOnContentChange(t *testing.T) {
|
|
||||||
dbPath := filepath.Join(t.TempDir(), "facts.db")
|
|
||||||
db, err := NewDB(dbPath, zap.NewNop())
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
defer db.Close()
|
|
||||||
|
|
||||||
proj, err := db.CreateProject(&Project{Name: "version-test"})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
created, err := db.UpsertProjectFact(&ProjectFact{
|
|
||||||
ProjectID: proj.ID,
|
|
||||||
FactKey: "finding/xss",
|
|
||||||
Category: "finding",
|
|
||||||
Summary: "v1",
|
|
||||||
Body: "body v1",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if created.SupersedesFactID != "" {
|
|
||||||
t.Fatalf("expected no supersedes on create, got %q", created.SupersedesFactID)
|
|
||||||
}
|
|
||||||
|
|
||||||
updated, err := db.UpsertProjectFact(&ProjectFact{
|
|
||||||
ProjectID: proj.ID,
|
|
||||||
FactKey: "finding/xss",
|
|
||||||
Summary: "v2",
|
|
||||||
Body: "body v2",
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if updated.SupersedesFactID == "" {
|
|
||||||
t.Fatal("expected supersedes_fact_id after content change")
|
|
||||||
}
|
|
||||||
prev, err := db.GetProjectFactVersion(updated.SupersedesFactID)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if prev.Summary != "v1" || prev.Body != "body v1" {
|
|
||||||
t.Fatalf("previous version mismatch: summary=%q body=%q", prev.Summary, prev.Body)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestMergeFactBodyOnUpdate(t *testing.T) {
|
func TestMergeFactBodyOnUpdate(t *testing.T) {
|
||||||
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
if got := mergeFactBodyOnUpdate("", "keep"); got != "keep" {
|
||||||
t.Fatalf("empty incoming: got %q", got)
|
t.Fatalf("empty incoming: got %q", got)
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
"strings"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
)
|
|
||||||
|
|
||||||
// ProjectFactVersion 事实历史快照(同 fact_key 更新前归档)。
|
|
||||||
type ProjectFactVersion struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
FactID string `json:"fact_id"`
|
|
||||||
ProjectID string `json:"project_id"`
|
|
||||||
FactKey string `json:"fact_key"`
|
|
||||||
Category string `json:"category"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
Body string `json:"body"`
|
|
||||||
Confidence string `json:"confidence"`
|
|
||||||
SourceConversationID string `json:"source_conversation_id,omitempty"`
|
|
||||||
SourceMessageID string `json:"source_message_id,omitempty"`
|
|
||||||
Pinned bool `json:"pinned"`
|
|
||||||
RelatedVulnerabilityID string `json:"related_vulnerability_id,omitempty"`
|
|
||||||
ArchivedAt time.Time `json:"archived_at"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// InsertProjectFactVersion 将当前事实行快照写入版本表。
|
|
||||||
func (db *DB) InsertProjectFactVersion(f *ProjectFact) (string, error) {
|
|
||||||
if f == nil || f.ID == "" {
|
|
||||||
return "", fmt.Errorf("无效的事实记录")
|
|
||||||
}
|
|
||||||
id := uuid.New().String()
|
|
||||||
now := time.Now()
|
|
||||||
_, err := db.Exec(
|
|
||||||
`INSERT INTO project_fact_versions (
|
|
||||||
id, fact_id, project_id, fact_key, category, summary, body, confidence,
|
|
||||||
source_conversation_id, source_message_id, pinned, related_vulnerability_id, archived_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
||||||
id, f.ID, f.ProjectID, f.FactKey, f.Category, f.Summary, f.Body, f.Confidence,
|
|
||||||
nullIfEmpty(f.SourceConversationID), nullIfEmpty(f.SourceMessageID), boolToInt(f.Pinned),
|
|
||||||
nullIfEmpty(f.RelatedVulnerabilityID), now,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("归档事实版本失败: %w", err)
|
|
||||||
}
|
|
||||||
return id, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetProjectFactVersion 按版本 ID 获取快照。
|
|
||||||
func (db *DB) GetProjectFactVersion(versionID string) (*ProjectFactVersion, error) {
|
|
||||||
row := db.QueryRow(
|
|
||||||
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
|
||||||
COALESCE(related_vulnerability_id,''), archived_at
|
|
||||||
FROM project_fact_versions WHERE id = ?`, versionID,
|
|
||||||
)
|
|
||||||
return scanProjectFactVersionRow(row)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListProjectFactVersions 列出某条事实的全部历史版本(新→旧)。
|
|
||||||
func (db *DB) ListProjectFactVersions(factID string, limit int) ([]*ProjectFactVersion, error) {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = 20
|
|
||||||
}
|
|
||||||
rows, err := db.Query(
|
|
||||||
`SELECT id, fact_id, project_id, fact_key, category, summary, COALESCE(body,''), confidence,
|
|
||||||
COALESCE(source_conversation_id,''), COALESCE(source_message_id,''), pinned,
|
|
||||||
COALESCE(related_vulnerability_id,''), archived_at
|
|
||||||
FROM project_fact_versions WHERE fact_id = ? ORDER BY archived_at DESC LIMIT ?`,
|
|
||||||
factID, limit,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
defer rows.Close()
|
|
||||||
var out []*ProjectFactVersion
|
|
||||||
for rows.Next() {
|
|
||||||
v, err := scanProjectFactVersionFromRows(rows)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
out = append(out, v)
|
|
||||||
}
|
|
||||||
return out, rows.Err()
|
|
||||||
}
|
|
||||||
|
|
||||||
func projectFactContentChanged(existing, incoming *ProjectFact) bool {
|
|
||||||
if existing == nil || incoming == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
mergedBody := mergeFactBodyOnUpdate(incoming.Body, existing.Body)
|
|
||||||
inCat := stringsTrimDefault(incoming.Category, existing.Category)
|
|
||||||
inConf := stringsTrimDefault(incoming.Confidence, existing.Confidence)
|
|
||||||
return existing.Summary != incoming.Summary ||
|
|
||||||
existing.Body != mergedBody ||
|
|
||||||
existing.Category != inCat ||
|
|
||||||
existing.Confidence != inConf
|
|
||||||
}
|
|
||||||
|
|
||||||
func stringsTrimDefault(s, fallback string) string {
|
|
||||||
if strings.TrimSpace(s) == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return strings.TrimSpace(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanProjectFactVersionRow(row *sql.Row) (*ProjectFactVersion, error) {
|
|
||||||
var v ProjectFactVersion
|
|
||||||
var pinned int
|
|
||||||
var archivedAt string
|
|
||||||
err := row.Scan(
|
|
||||||
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
|
|
||||||
&v.SourceConversationID, &v.SourceMessageID, &pinned,
|
|
||||||
&v.RelatedVulnerabilityID, &archivedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
if err == sql.ErrNoRows {
|
|
||||||
return nil, fmt.Errorf("事实版本不存在")
|
|
||||||
}
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
v.Pinned = pinned != 0
|
|
||||||
v.ArchivedAt = parseDBTime(archivedAt)
|
|
||||||
return &v, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func scanProjectFactVersionFromRows(rows *sql.Rows) (*ProjectFactVersion, error) {
|
|
||||||
var v ProjectFactVersion
|
|
||||||
var pinned int
|
|
||||||
var archivedAt string
|
|
||||||
err := rows.Scan(
|
|
||||||
&v.ID, &v.FactID, &v.ProjectID, &v.FactKey, &v.Category, &v.Summary, &v.Body, &v.Confidence,
|
|
||||||
&v.SourceConversationID, &v.SourceMessageID, &pinned,
|
|
||||||
&v.RelatedVulnerabilityID, &archivedAt,
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
v.Pinned = pinned != 0
|
|
||||||
v.ArchivedAt = parseDBTime(archivedAt)
|
|
||||||
return &v, nil
|
|
||||||
}
|
|
||||||
@@ -37,7 +37,7 @@ func TestListProjectFacts_updatedAtJSON(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
projects, err := db.ListProjects("", 1, 0)
|
projects, err := db.ListProjects("", "", 1, 0)
|
||||||
if err != nil || len(projects) == 0 {
|
if err != nil || len(projects) == 0 {
|
||||||
t.Skip("no projects")
|
t.Skip("no projects")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -96,18 +96,44 @@ func (h *ConversationHandler) ListConversations(c *gin.Context) {
|
|||||||
limit, _ := strconv.Atoi(limitStr)
|
limit, _ := strconv.Atoi(limitStr)
|
||||||
offset, _ := strconv.Atoi(offsetStr)
|
offset, _ := strconv.Atoi(offsetStr)
|
||||||
|
|
||||||
if limit <= 0 || limit > 100 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
|
if limit > 1000 {
|
||||||
|
limit = 1000
|
||||||
|
}
|
||||||
|
|
||||||
conversations, err := h.db.ListConversations(limit, offset, search)
|
excludeGrouped := strings.TrimSpace(search) == "" &&
|
||||||
|
(c.Query("exclude_grouped") == "true" || c.Query("exclude_grouped") == "1")
|
||||||
|
|
||||||
|
var conversations []*database.Conversation
|
||||||
|
var total int
|
||||||
|
var err error
|
||||||
|
if excludeGrouped {
|
||||||
|
conversations, err = h.db.ListUngroupedConversations(limit, offset)
|
||||||
|
if err == nil {
|
||||||
|
total, err = h.db.CountUngroupedConversations()
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
conversations, err = h.db.ListConversations(limit, offset, search)
|
||||||
|
if err == nil {
|
||||||
|
total, err = h.db.CountConversations(search)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
h.logger.Error("获取对话列表失败", zap.Error(err))
|
h.logger.Error("获取对话列表失败", zap.Error(err))
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
if conversations == nil {
|
||||||
c.JSON(http.StatusOK, conversations)
|
conversations = []*database.Conversation{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"conversations": conversations,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConversation 获取对话
|
// GetConversation 获取对话
|
||||||
|
|||||||
+41
-41
@@ -61,12 +61,40 @@ func (h *ProjectHandler) CreateProject(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, created)
|
c.JSON(http.StatusOK, created)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetDashboardSummary GET /api/projects/dashboard-summary
|
||||||
|
func (h *ProjectHandler) GetDashboardSummary(c *gin.Context) {
|
||||||
|
limit, _ := strconv.Atoi(strings.TrimSpace(c.DefaultQuery("fact_limit", "5")))
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = 5
|
||||||
|
}
|
||||||
|
if limit > 50 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
summary, err := h.db.GetProjectDashboardSummary(limit)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("获取项目仪表盘摘要失败", zap.Error(err))
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if summary.RecentFacts == nil {
|
||||||
|
summary.RecentFacts = []database.ProjectDashboardFact{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, summary)
|
||||||
|
}
|
||||||
|
|
||||||
// ListProjects GET /api/projects
|
// ListProjects GET /api/projects
|
||||||
func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
||||||
status := c.Query("status")
|
status := c.Query("status")
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "200"))
|
search := c.Query("search")
|
||||||
|
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "50"))
|
||||||
offset, _ := strconv.Atoi(c.Query("offset"))
|
offset, _ := strconv.Atoi(c.Query("offset"))
|
||||||
list, err := h.db.ListProjects(status, limit, offset)
|
if limit <= 0 {
|
||||||
|
limit = 50
|
||||||
|
}
|
||||||
|
if limit > 500 {
|
||||||
|
limit = 500
|
||||||
|
}
|
||||||
|
list, err := h.db.ListProjects(status, search, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
@@ -74,7 +102,17 @@ func (h *ProjectHandler) ListProjects(c *gin.Context) {
|
|||||||
if list == nil {
|
if list == nil {
|
||||||
list = []*database.Project{}
|
list = []*database.Project{}
|
||||||
}
|
}
|
||||||
c.JSON(http.StatusOK, list)
|
total, err := h.db.CountProjects(status, search)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"projects": list,
|
||||||
|
"total": total,
|
||||||
|
"limit": limit,
|
||||||
|
"offset": offset,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetProjectStats GET /api/projects/:id/stats
|
// GetProjectStats GET /api/projects/:id/stats
|
||||||
@@ -240,44 +278,6 @@ func (h *ProjectHandler) ListFacts(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, list)
|
c.JSON(http.StatusOK, list)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetFactPreviousVersion GET /api/projects/:id/facts/:factId/previous-version
|
|
||||||
func (h *ProjectHandler) GetFactPreviousVersion(c *gin.Context) {
|
|
||||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
|
||||||
if err != nil || existing.ProjectID != c.Param("id") {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(existing.SupersedesFactID) == "" {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "无上一版本"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
v, err := h.db.GetProjectFactVersion(existing.SupersedesFactID)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, v)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListFactVersions GET /api/projects/:id/facts/:factId/versions
|
|
||||||
func (h *ProjectHandler) ListFactVersions(c *gin.Context) {
|
|
||||||
existing, err := h.db.GetProjectFact(c.Param("factId"))
|
|
||||||
if err != nil || existing.ProjectID != c.Param("id") {
|
|
||||||
c.JSON(http.StatusNotFound, gin.H{"error": "事实不存在"})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
limit, _ := strconv.Atoi(c.DefaultQuery("limit", "20"))
|
|
||||||
list, err := h.db.ListProjectFactVersions(existing.ID, limit)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if list == nil {
|
|
||||||
list = []*database.ProjectFactVersion{}
|
|
||||||
}
|
|
||||||
c.JSON(http.StatusOK, list)
|
|
||||||
}
|
|
||||||
|
|
||||||
// CreateFact POST /api/projects/:id/facts
|
// CreateFact POST /api/projects/:id/facts
|
||||||
func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
func (h *ProjectHandler) CreateFact(c *gin.Context) {
|
||||||
var req upsertFactRequest
|
var req upsertFactRequest
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ func (h *RobotHandler) resolveProjectByIDOrName(idOrName string) (*database.Proj
|
|||||||
if p, err := h.db.GetProject(idOrName); err == nil {
|
if p, err := h.db.GetProject(idOrName); err == nil {
|
||||||
return p, ""
|
return p, ""
|
||||||
}
|
}
|
||||||
list, err := h.db.ListProjects("", 200, 0)
|
list, err := h.db.ListProjects("", "", 200, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, "查询项目失败: " + err.Error()
|
return nil, "查询项目失败: " + err.Error()
|
||||||
}
|
}
|
||||||
@@ -353,7 +353,7 @@ func (h *RobotHandler) cmdProjects() string {
|
|||||||
if !h.projectsEnabled() {
|
if !h.projectsEnabled() {
|
||||||
return "项目功能未启用(config.project.enabled)。"
|
return "项目功能未启用(config.project.enabled)。"
|
||||||
}
|
}
|
||||||
list, err := h.db.ListProjects("", 50, 0)
|
list, err := h.db.ListProjects("", "", 50, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "获取项目列表失败: " + err.Error()
|
return "获取项目列表失败: " + err.Error()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ func RunEinoSingleChatModelAgent(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||||
|
openai.AttachSummarizationDiagTransport(httpClient, logger)
|
||||||
|
|
||||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||||
APIKey: appCfg.OpenAI.APIKey,
|
APIKey: appCfg.OpenAI.APIKey,
|
||||||
|
|||||||
@@ -9,15 +9,19 @@ import (
|
|||||||
|
|
||||||
"cyberstrike-ai/internal/agent"
|
"cyberstrike-ai/internal/agent"
|
||||||
"cyberstrike-ai/internal/config"
|
"cyberstrike-ai/internal/config"
|
||||||
|
copenai "cyberstrike-ai/internal/openai"
|
||||||
|
|
||||||
"github.com/bytedance/sonic"
|
"github.com/bytedance/sonic"
|
||||||
"github.com/cloudwego/eino/adk"
|
"github.com/cloudwego/eino/adk"
|
||||||
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
"github.com/cloudwego/eino/adk/middlewares/summarization"
|
||||||
"github.com/cloudwego/eino/components/model"
|
"github.com/cloudwego/eino/components/model"
|
||||||
"github.com/cloudwego/eino/schema"
|
"github.com/cloudwego/eino/schema"
|
||||||
|
einoopenai "github.com/cloudwego/eino-ext/components/model/openai"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const defaultSummarizationRetryMax = 3
|
||||||
|
|
||||||
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
// einoSummarizeUserInstruction:压缩历史时保留渗透测试关键信息。
|
||||||
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
const einoSummarizeUserInstruction = `在保持所有关键安全测试信息完整的前提下压缩对话历史。
|
||||||
|
|
||||||
@@ -89,8 +93,32 @@ func newEinoSummarizationMiddleware(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
retryMax := defaultSummarizationRetryMax
|
||||||
|
if mwCfg != nil && mwCfg.SummarizationRetryMaxAttempts > 0 {
|
||||||
|
retryMax = mwCfg.SummarizationRetryMaxAttempts
|
||||||
|
}
|
||||||
|
|
||||||
|
// ModelOptions apply only to summarization Generate (same ChatModel instance as the agent).
|
||||||
|
// Strip thinking/reasoning on this call path; mark requests for empty-choices diagnostics.
|
||||||
|
summaryModelOpts := []model.Option{
|
||||||
|
einoopenai.WithExtraHeader(map[string]string{
|
||||||
|
copenai.SummarizationRequestHeader: "1",
|
||||||
|
}),
|
||||||
|
einoopenai.WithRequestPayloadModifier(func(_ context.Context, in []*schema.Message, rawBody []byte) ([]byte, error) {
|
||||||
|
if logger != nil {
|
||||||
|
logger.Info("eino summarization generate request",
|
||||||
|
zap.Int("input_messages", len(in)),
|
||||||
|
zap.Int("payload_bytes", len(rawBody)),
|
||||||
|
zap.String("model", modelName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return stripReasoningFromSummarizationPayload(rawBody)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
|
||||||
mw, err := summarization.New(ctx, &summarization.Config{
|
mw, err := summarization.New(ctx, &summarization.Config{
|
||||||
Model: summaryModel,
|
Model: summaryModel,
|
||||||
|
ModelOptions: summaryModelOpts,
|
||||||
Trigger: &summarization.TriggerCondition{
|
Trigger: &summarization.TriggerCondition{
|
||||||
ContextTokens: trigger,
|
ContextTokens: trigger,
|
||||||
},
|
},
|
||||||
@@ -102,6 +130,18 @@ func newEinoSummarizationMiddleware(
|
|||||||
Enabled: true,
|
Enabled: true,
|
||||||
MaxTokens: preserveMax,
|
MaxTokens: preserveMax,
|
||||||
},
|
},
|
||||||
|
Retry: &summarization.RetryConfig{
|
||||||
|
MaxRetries: &retryMax,
|
||||||
|
ShouldRetry: func(_ context.Context, _ adk.Message, err error) bool {
|
||||||
|
if err != nil && logger != nil {
|
||||||
|
logger.Warn("eino summarization generate attempt failed, will retry if attempts remain",
|
||||||
|
zap.Error(err),
|
||||||
|
zap.Int("max_retries", retryMax),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return err != nil
|
||||||
|
},
|
||||||
|
},
|
||||||
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
Finalize: func(ctx context.Context, originalMessages []adk.Message, summary adk.Message) ([]adk.Message, error) {
|
||||||
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
return summarizeFinalizeWithRecentAssistantToolTrail(ctx, originalMessages, summary, tokenCounter, recentTrailMax)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stripReasoningFromSummarizationPayload removes thinking / reasoning fields from a
|
||||||
|
// chat-completions JSON body. Applied only to summarization Generate calls via
|
||||||
|
// model.ModelOptions on the shared ChatModel — main-agent requests are unchanged.
|
||||||
|
func stripReasoningFromSummarizationPayload(rawBody []byte) ([]byte, error) {
|
||||||
|
var payload map[string]any
|
||||||
|
if err := sonic.Unmarshal(rawBody, &payload); err != nil {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, key := range []string{
|
||||||
|
"thinking",
|
||||||
|
"reasoning_effort",
|
||||||
|
"output_config",
|
||||||
|
"reasoning",
|
||||||
|
} {
|
||||||
|
if _, ok := payload[key]; ok {
|
||||||
|
delete(payload, key)
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return rawBody, nil
|
||||||
|
}
|
||||||
|
out, err := sonic.Marshal(payload)
|
||||||
|
if err != nil {
|
||||||
|
return rawBody, err
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
package multiagent
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestStripReasoningFromSummarizationPayload(t *testing.T) {
|
||||||
|
in := []byte(`{"model":"deepseek-chat","messages":[],"thinking":{"type":"enabled"},"reasoning_effort":"high"}`)
|
||||||
|
out, err := stripReasoningFromSummarizationPayload(in)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
s := string(out)
|
||||||
|
if strings.Contains(s, "thinking") || strings.Contains(s, "reasoning_effort") {
|
||||||
|
t.Fatalf("expected reasoning fields stripped, got %s", s)
|
||||||
|
}
|
||||||
|
if !strings.Contains(s, `"model":"deepseek-chat"`) {
|
||||||
|
t.Fatalf("expected model preserved, got %s", s)
|
||||||
|
}
|
||||||
|
|
||||||
|
plain := []byte(`{"model":"gpt-4o","messages":[]}`)
|
||||||
|
out2, err := stripReasoningFromSummarizationPayload(plain)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(out2) != string(plain) {
|
||||||
|
t.Fatalf("expected unchanged payload, got %s", out2)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,7 @@ func RunDeepAgent(
|
|||||||
|
|
||||||
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
|
// 若配置为 Claude provider,注入自动桥接 transport,对 Eino 透明走 Anthropic Messages API
|
||||||
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
httpClient = openai.NewEinoHTTPClient(&appCfg.OpenAI, httpClient)
|
||||||
|
openai.AttachSummarizationDiagTransport(httpClient, logger)
|
||||||
|
|
||||||
baseModelCfg := &einoopenai.ChatModelConfig{
|
baseModelCfg := &einoopenai.ChatModelConfig{
|
||||||
APIKey: appCfg.OpenAI.APIKey,
|
APIKey: appCfg.OpenAI.APIKey,
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/bytedance/sonic"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SummarizationRequestHeader marks chat/completion requests issued by Eino summarization
|
||||||
|
// middleware (via model.WithExtraHeader). The diagnostic transport logs empty-choices bodies
|
||||||
|
// only for these requests so main-agent traffic stays quiet.
|
||||||
|
const SummarizationRequestHeader = "X-CyberStrike-Summarization"
|
||||||
|
|
||||||
|
const summarizationDiagBodyMaxBytes = 8192
|
||||||
|
|
||||||
|
// AttachSummarizationDiagTransport wraps client.Transport to log raw API bodies when
|
||||||
|
// summarization receives HTTP 200 with an empty choices array.
|
||||||
|
func AttachSummarizationDiagTransport(client *http.Client, logger *zap.Logger) {
|
||||||
|
if client == nil || logger == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
base := client.Transport
|
||||||
|
if base == nil {
|
||||||
|
base = http.DefaultTransport
|
||||||
|
}
|
||||||
|
client.Transport = &summarizationDiagRoundTripper{base: base, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
type summarizationDiagRoundTripper struct {
|
||||||
|
base http.RoundTripper
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
func (rt *summarizationDiagRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
resp, err := rt.base.RoundTrip(req)
|
||||||
|
if err != nil || resp == nil || resp.Body == nil {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
if !isSummarizationRequest(req) || !strings.Contains(strings.ToLower(resp.Header.Get("Content-Type")), "json") {
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
body, readErr := io.ReadAll(resp.Body)
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
if readErr != nil {
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(nil))
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
resp.Body = io.NopCloser(bytes.NewReader(body))
|
||||||
|
resp.ContentLength = int64(len(body))
|
||||||
|
|
||||||
|
if rt.logger != nil && summarizationResponseEmptyChoices(body) {
|
||||||
|
rt.logger.Warn("eino summarization: API returned empty choices",
|
||||||
|
zap.Int("status", resp.StatusCode),
|
||||||
|
zap.Int("response_bytes", len(body)),
|
||||||
|
zap.String("raw_body", truncateForLog(string(body), summarizationDiagBodyMaxBytes)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return resp, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isSummarizationRequest(req *http.Request) bool {
|
||||||
|
if req == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return strings.TrimSpace(req.Header.Get(SummarizationRequestHeader)) == "1"
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizationResponseEmptyChoices(body []byte) bool {
|
||||||
|
var parsed struct {
|
||||||
|
Choices []any `json:"choices"`
|
||||||
|
}
|
||||||
|
if err := sonic.Unmarshal(body, &parsed); err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return len(parsed.Choices) == 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func truncateForLog(s string, maxBytes int) string {
|
||||||
|
if maxBytes <= 0 || len(s) <= maxBytes {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
return s[:maxBytes] + "…(truncated)"
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package openai
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type staticRoundTripper struct {
|
||||||
|
status int
|
||||||
|
body string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *staticRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||||
|
return &http.Response{
|
||||||
|
StatusCode: s.status,
|
||||||
|
Header: http.Header{"Content-Type": []string{"application/json"}},
|
||||||
|
Body: io.NopCloser(strings.NewReader(s.body)),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizationResponseEmptyChoices(t *testing.T) {
|
||||||
|
if !summarizationResponseEmptyChoices([]byte(`{"choices":[]}`)) {
|
||||||
|
t.Fatal("expected empty choices")
|
||||||
|
}
|
||||||
|
if summarizationResponseEmptyChoices([]byte(`{"choices":[{"index":0}]}`)) {
|
||||||
|
t.Fatal("expected non-empty choices")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSummarizationDiagRoundTripper_SkipsWithoutHeader(t *testing.T) {
|
||||||
|
client := &http.Client{
|
||||||
|
Transport: &summarizationDiagRoundTripper{
|
||||||
|
base: &staticRoundTripper{status: 200, body: `{"choices":[]}`},
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
req, _ := http.NewRequest(http.MethodPost, "https://example.com/v1/chat/completions", nil)
|
||||||
|
resp, err := client.Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
}
|
||||||
+1
-127
@@ -37,7 +37,6 @@
|
|||||||
Form Controls (scoped to C2 pages)
|
Form Controls (scoped to C2 pages)
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
#page-c2 .form-control,
|
|
||||||
#page-c2-listeners .form-control,
|
#page-c2-listeners .form-control,
|
||||||
#page-c2-sessions .form-control,
|
#page-c2-sessions .form-control,
|
||||||
#page-c2-tasks .form-control,
|
#page-c2-tasks .form-control,
|
||||||
@@ -61,7 +60,6 @@
|
|||||||
appearance: none;
|
appearance: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 .form-control:focus,
|
|
||||||
#page-c2-listeners .form-control:focus,
|
#page-c2-listeners .form-control:focus,
|
||||||
#page-c2-sessions .form-control:focus,
|
#page-c2-sessions .form-control:focus,
|
||||||
#page-c2-tasks .form-control:focus,
|
#page-c2-tasks .form-control:focus,
|
||||||
@@ -73,7 +71,6 @@
|
|||||||
box-shadow: 0 0 0 3px var(--c2-accent-dim);
|
box-shadow: 0 0 0 3px var(--c2-accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 select.form-control,
|
|
||||||
#page-c2-payloads select.form-control,
|
#page-c2-payloads select.form-control,
|
||||||
.c2-modal select.form-control {
|
.c2-modal select.form-control {
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%2364748b' d='M2.5 4.5L6 8l3.5-3.5'/%3E%3C/svg%3E");
|
||||||
@@ -85,7 +82,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */
|
/* 原生下拉:避免 appearance:none 在部分浏览器中导致 select 无法正常展开 */
|
||||||
#page-c2 select.form-control.c2-native-select,
|
|
||||||
#page-c2-payloads select.form-control.c2-native-select,
|
#page-c2-payloads select.form-control.c2-native-select,
|
||||||
.c2-modal select.form-control.c2-native-select {
|
.c2-modal select.form-control.c2-native-select {
|
||||||
appearance: auto;
|
appearance: auto;
|
||||||
@@ -94,7 +90,6 @@
|
|||||||
padding-right: 14px;
|
padding-right: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 textarea.form-control,
|
|
||||||
#page-c2-payloads textarea.form-control,
|
#page-c2-payloads textarea.form-control,
|
||||||
.c2-modal textarea.form-control {
|
.c2-modal textarea.form-control {
|
||||||
resize: vertical;
|
resize: vertical;
|
||||||
@@ -104,7 +99,6 @@
|
|||||||
line-height: 1.6;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
#page-c2 .form-control::placeholder,
|
|
||||||
#page-c2-payloads .form-control::placeholder,
|
#page-c2-payloads .form-control::placeholder,
|
||||||
.c2-modal .form-control::placeholder {
|
.c2-modal .form-control::placeholder {
|
||||||
color: var(--c2-text-muted);
|
color: var(--c2-text-muted);
|
||||||
@@ -140,9 +134,6 @@
|
|||||||
Layout
|
Layout
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
|
|
||||||
.c2-layout { display: flex; flex-direction: column; height: 100%; }
|
|
||||||
.c2-main { flex: 1; overflow-y: auto; }
|
|
||||||
|
|
||||||
.c2-empty {
|
.c2-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -171,103 +162,6 @@
|
|||||||
margin: 12px;
|
margin: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================================
|
|
||||||
Dashboard / Welcome
|
|
||||||
============================================================================ */
|
|
||||||
|
|
||||||
.c2-welcome {
|
|
||||||
text-align: center;
|
|
||||||
padding: 100px 24px 80px;
|
|
||||||
max-width: 860px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-welcome-icon {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
animation: c2-float 4s ease-in-out infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes c2-float {
|
|
||||||
0%, 100% { transform: translateY(0); }
|
|
||||||
50% { transform: translateY(-8px); }
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-welcome h3 {
|
|
||||||
font-size: 28px;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
color: var(--c2-text);
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-welcome p {
|
|
||||||
color: var(--c2-text-dim);
|
|
||||||
font-size: 15px;
|
|
||||||
line-height: 1.7;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
max-width: 520px;
|
|
||||||
margin-left: auto;
|
|
||||||
margin-right: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-stats {
|
|
||||||
display: flex;
|
|
||||||
justify-content: center;
|
|
||||||
gap: 16px;
|
|
||||||
margin-bottom: 48px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-stat-item {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: center;
|
|
||||||
padding: 28px 40px;
|
|
||||||
background: var(--c2-surface);
|
|
||||||
border-radius: var(--c2-radius);
|
|
||||||
border: 1.5px solid var(--c2-border);
|
|
||||||
min-width: 160px;
|
|
||||||
transition: all 0.3s ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-stat-item:hover {
|
|
||||||
transform: translateY(-4px);
|
|
||||||
box-shadow: var(--c2-shadow-md);
|
|
||||||
border-color: var(--c2-accent);
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-stat-item:nth-child(1) .c2-stat-value { color: var(--c2-accent); }
|
|
||||||
.c2-stat-item:nth-child(2) .c2-stat-value { color: var(--c2-green); }
|
|
||||||
.c2-stat-item:nth-child(3) .c2-stat-value { color: var(--c2-amber); }
|
|
||||||
|
|
||||||
.c2-stat-value {
|
|
||||||
font-size: 36px;
|
|
||||||
font-weight: 800;
|
|
||||||
line-height: 1;
|
|
||||||
letter-spacing: -1px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-stat-label {
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--c2-text-dim);
|
|
||||||
margin-top: 12px;
|
|
||||||
font-weight: 600;
|
|
||||||
letter-spacing: 0.3px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-actions {
|
|
||||||
display: flex;
|
|
||||||
gap: 12px;
|
|
||||||
justify-content: center;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
max-width: 420px;
|
|
||||||
margin-inline: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-actions > button {
|
|
||||||
flex: 1;
|
|
||||||
min-width: min(100%, 160px);
|
|
||||||
}
|
|
||||||
/* ============================================================================
|
/* ============================================================================
|
||||||
Listener Cards
|
Listener Cards
|
||||||
============================================================================ */
|
============================================================================ */
|
||||||
@@ -1532,26 +1426,7 @@
|
|||||||
color: var(--c2-text);
|
color: var(--c2-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.c2-modal-close {
|
/* .c2-modal-close 样式见 style.css 统一关闭按钮 */
|
||||||
font-size: 18px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--c2-text-muted);
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
border-radius: var(--c2-radius-xs);
|
|
||||||
transition: all 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-modal-close:hover {
|
|
||||||
background: var(--c2-surface-alt);
|
|
||||||
color: var(--c2-text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.c2-modal-body { padding: 24px 28px; }
|
.c2-modal-body { padding: 24px 28px; }
|
||||||
|
|
||||||
@@ -1590,7 +1465,6 @@
|
|||||||
border-right: none;
|
border-right: none;
|
||||||
border-bottom: 1px solid var(--c2-border);
|
border-bottom: 1px solid var(--c2-border);
|
||||||
}
|
}
|
||||||
.c2-stats { flex-direction: column; gap: 12px; }
|
|
||||||
.c2-payload-grid { grid-template-columns: 1fr; }
|
.c2-payload-grid { grid-template-columns: 1fr; }
|
||||||
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
|
.c2-listener-grid { grid-template-columns: 1fr; padding: 16px; }
|
||||||
.c2-task-detail-grid { grid-template-columns: 1fr; }
|
.c2-task-detail-grid { grid-template-columns: 1fr; }
|
||||||
|
|||||||
+742
-169
File diff suppressed because it is too large
Load Diff
@@ -79,7 +79,6 @@
|
|||||||
"settings": "System settings",
|
"settings": "System settings",
|
||||||
"hitl": "Human-in-the-loop",
|
"hitl": "Human-in-the-loop",
|
||||||
"c2": "C2",
|
"c2": "C2",
|
||||||
"c2Manage": "C2 management",
|
|
||||||
"c2Listeners": "Listeners",
|
"c2Listeners": "Listeners",
|
||||||
"c2Sessions": "Sessions",
|
"c2Sessions": "Sessions",
|
||||||
"c2Tasks": "Tasks",
|
"c2Tasks": "Tasks",
|
||||||
@@ -98,8 +97,13 @@
|
|||||||
"clickToViewTasks": "Click to view tasks",
|
"clickToViewTasks": "Click to view tasks",
|
||||||
"clickToViewVuln": "Click to view vulnerabilities",
|
"clickToViewVuln": "Click to view vulnerabilities",
|
||||||
"clickToViewMCP": "Click to view MCP monitor",
|
"clickToViewMCP": "Click to view MCP monitor",
|
||||||
|
"accessOverviewTitle": "Access overview",
|
||||||
|
"accessTabsAria": "C2 and WebShell",
|
||||||
"c2OverviewTitle": "C2 overview",
|
"c2OverviewTitle": "C2 overview",
|
||||||
"c2GoManage": "Open C2 →",
|
"c2GoManage": "Open C2 →",
|
||||||
|
"webshellGoManage": "Open WebShell →",
|
||||||
|
"webshellConnections": "Active connections",
|
||||||
|
"webshellClickConnections": "View connections",
|
||||||
"c2ListenersRunning": "Listeners running",
|
"c2ListenersRunning": "Listeners running",
|
||||||
"c2SessionsOnline": "Sessions online",
|
"c2SessionsOnline": "Sessions online",
|
||||||
"c2TasksPending": "Pending / queued tasks",
|
"c2TasksPending": "Pending / queued tasks",
|
||||||
@@ -153,7 +157,14 @@
|
|||||||
"lastUpdated": "Last updated",
|
"lastUpdated": "Last updated",
|
||||||
"viewAll": "View all →",
|
"viewAll": "View all →",
|
||||||
"recentVulns": "Recent vulnerabilities",
|
"recentVulns": "Recent vulnerabilities",
|
||||||
|
"recentFacts": "Recent facts",
|
||||||
"noVulnYet": "No recent vulnerabilities",
|
"noVulnYet": "No recent vulnerabilities",
|
||||||
|
"noFactsYet": "No recent facts",
|
||||||
|
"noFactsDesc": "In project-bound chats, the agent records targets, findings, and attack chains",
|
||||||
|
"createFirstProjectBtn": "Create first project",
|
||||||
|
"factProjectMeta": "{{project}} · {{key}}",
|
||||||
|
"factsAcrossProjects_one": "{{count}} active project · {{facts}} facts",
|
||||||
|
"factsAcrossProjects_other": "{{count}} active projects · {{facts}} facts",
|
||||||
"capabilities": "Capabilities",
|
"capabilities": "Capabilities",
|
||||||
"mcpTools": "MCP tools",
|
"mcpTools": "MCP tools",
|
||||||
"rolesLabel": "Roles",
|
"rolesLabel": "Roles",
|
||||||
@@ -230,6 +241,13 @@
|
|||||||
"newProjectCta": "+ New project",
|
"newProjectCta": "+ New project",
|
||||||
"projectList": "Project list",
|
"projectList": "Project list",
|
||||||
"searchProjectsPlaceholder": "Search projects…",
|
"searchProjectsPlaceholder": "Search projects…",
|
||||||
|
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
||||||
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
|
"paginationTotal": "{{total}} total",
|
||||||
|
"paginationPage": "{{page}}/{{total}}",
|
||||||
|
"paginationPerPage": "Per page",
|
||||||
|
"paginationPrev": "Previous",
|
||||||
|
"paginationNext": "Next",
|
||||||
"selectOrCreateTitle": "Select or create a project",
|
"selectOrCreateTitle": "Select or create a project",
|
||||||
"selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
|
"selectOrCreateHint": "Projects share a cross-chat fact board; target, environment, auth and other facts are auto-injected in bound conversations.",
|
||||||
"createFirstProject": "Create first project",
|
"createFirstProject": "Create first project",
|
||||||
@@ -377,6 +395,7 @@
|
|||||||
"settingsIntroTitle": "Project settings",
|
"settingsIntroTitle": "Project settings",
|
||||||
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
"settingsIntroHint": "Configure project metadata and Agent authorization boundary; takes effect immediately for bound conversations after saving.",
|
||||||
"pinProject": "Pin project (show first in list)",
|
"pinProject": "Pin project (show first in list)",
|
||||||
|
"pinFact": "Pin fact (prioritize in list and blackboard index)",
|
||||||
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
|
"editDescriptionPlaceholder": "Targets, authorization scope, contacts, notes…",
|
||||||
"scopeTitle": "Test scope",
|
"scopeTitle": "Test scope",
|
||||||
"scopeHint": "JSON format for Agent authorization boundary and target assets",
|
"scopeHint": "JSON format for Agent authorization boundary and target assets",
|
||||||
@@ -408,6 +427,13 @@
|
|||||||
"addGroup": "New group",
|
"addGroup": "New group",
|
||||||
"recentConversations": "Recent conversations",
|
"recentConversations": "Recent conversations",
|
||||||
"batchManage": "Batch manage",
|
"batchManage": "Batch manage",
|
||||||
|
"paginationShow": "Show {{start}}-{{end}} of {{total}}",
|
||||||
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
|
"paginationTotal": "{{total}} total",
|
||||||
|
"paginationPage": "{{page}}/{{total}}",
|
||||||
|
"paginationPerPage": "Per page",
|
||||||
|
"paginationPrev": "Previous",
|
||||||
|
"paginationNext": "Next",
|
||||||
"attackChain": "Attack chain",
|
"attackChain": "Attack chain",
|
||||||
"viewAttackChain": "View attack chain",
|
"viewAttackChain": "View attack chain",
|
||||||
"selectRole": "Select role",
|
"selectRole": "Select role",
|
||||||
@@ -2529,14 +2555,6 @@
|
|||||||
"checkboxLinkTitle": "Check to link this tool to this role"
|
"checkboxLinkTitle": "Check to link this tool to this role"
|
||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"title": "C2 Management",
|
|
||||||
"welcomeTitle": "AI-Native C2 Framework",
|
|
||||||
"welcomeDesc": "MCP-native design: let LLM call C2 like calling nmap to complete the full chain: initial access → control → tasks → lateral movement → cleanup",
|
|
||||||
"statListeners": "Running Listeners",
|
|
||||||
"statSessions": "Online Sessions",
|
|
||||||
"statPending": "Pending Tasks",
|
|
||||||
"goListeners": "Manage Listeners",
|
|
||||||
"goSessions": "View Sessions",
|
|
||||||
"clipboardCopied": "Copied to clipboard",
|
"clipboardCopied": "Copied to clipboard",
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
|
|||||||
@@ -79,7 +79,6 @@
|
|||||||
"settings": "系统设置",
|
"settings": "系统设置",
|
||||||
"hitl": "人机协同",
|
"hitl": "人机协同",
|
||||||
"c2": "C2",
|
"c2": "C2",
|
||||||
"c2Manage": "C2 管理",
|
|
||||||
"c2Listeners": "监听器",
|
"c2Listeners": "监听器",
|
||||||
"c2Sessions": "会话",
|
"c2Sessions": "会话",
|
||||||
"c2Tasks": "任务",
|
"c2Tasks": "任务",
|
||||||
@@ -98,8 +97,13 @@
|
|||||||
"clickToViewTasks": "点击查看任务管理",
|
"clickToViewTasks": "点击查看任务管理",
|
||||||
"clickToViewVuln": "点击查看漏洞管理",
|
"clickToViewVuln": "点击查看漏洞管理",
|
||||||
"clickToViewMCP": "点击查看 MCP 监控",
|
"clickToViewMCP": "点击查看 MCP 监控",
|
||||||
|
"accessOverviewTitle": "接入概览",
|
||||||
|
"accessTabsAria": "C2 与 WebShell",
|
||||||
"c2OverviewTitle": "C2 概览",
|
"c2OverviewTitle": "C2 概览",
|
||||||
"c2GoManage": "进入 C2 →",
|
"c2GoManage": "进入 C2 →",
|
||||||
|
"webshellGoManage": "进入 WebShell →",
|
||||||
|
"webshellConnections": "活跃连接",
|
||||||
|
"webshellClickConnections": "查看连接",
|
||||||
"c2ListenersRunning": "运行中监听器",
|
"c2ListenersRunning": "运行中监听器",
|
||||||
"c2SessionsOnline": "在线会话",
|
"c2SessionsOnline": "在线会话",
|
||||||
"c2TasksPending": "待审 / 排队任务",
|
"c2TasksPending": "待审 / 排队任务",
|
||||||
@@ -153,7 +157,13 @@
|
|||||||
"lastUpdated": "上次更新",
|
"lastUpdated": "上次更新",
|
||||||
"viewAll": "查看全部 →",
|
"viewAll": "查看全部 →",
|
||||||
"recentVulns": "最近漏洞",
|
"recentVulns": "最近漏洞",
|
||||||
|
"recentFacts": "近期事实",
|
||||||
"noVulnYet": "暂无最近漏洞",
|
"noVulnYet": "暂无最近漏洞",
|
||||||
|
"noFactsYet": "暂无近期事实",
|
||||||
|
"noFactsDesc": "在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实",
|
||||||
|
"createFirstProjectBtn": "创建第一个项目",
|
||||||
|
"factProjectMeta": "{{project}} · {{key}}",
|
||||||
|
"factsAcrossProjects": "{{count}} 个活跃项目 · {{facts}} 条事实",
|
||||||
"capabilities": "能力总览",
|
"capabilities": "能力总览",
|
||||||
"mcpTools": "MCP 工具",
|
"mcpTools": "MCP 工具",
|
||||||
"rolesLabel": "角色",
|
"rolesLabel": "角色",
|
||||||
@@ -219,6 +229,13 @@
|
|||||||
"newProjectCta": "+ 新建项目",
|
"newProjectCta": "+ 新建项目",
|
||||||
"projectList": "项目列表",
|
"projectList": "项目列表",
|
||||||
"searchProjectsPlaceholder": "搜索项目…",
|
"searchProjectsPlaceholder": "搜索项目…",
|
||||||
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||||
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
|
"paginationTotal": "共 {{total}} 条",
|
||||||
|
"paginationPage": "{{page}}/{{total}}",
|
||||||
|
"paginationPerPage": "每页",
|
||||||
|
"paginationPrev": "上一页",
|
||||||
|
"paginationNext": "下一页",
|
||||||
"selectOrCreateTitle": "选择或创建项目",
|
"selectOrCreateTitle": "选择或创建项目",
|
||||||
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
|
"selectOrCreateHint": "项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。",
|
||||||
"createFirstProject": "创建第一个项目",
|
"createFirstProject": "创建第一个项目",
|
||||||
@@ -366,6 +383,7 @@
|
|||||||
"settingsIntroTitle": "项目设置",
|
"settingsIntroTitle": "项目设置",
|
||||||
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
"settingsIntroHint": "配置项目元数据与 Agent 授权边界,保存后即时生效于绑定对话。",
|
||||||
"pinProject": "置顶项目(列表优先显示)",
|
"pinProject": "置顶项目(列表优先显示)",
|
||||||
|
"pinFact": "置顶事实(列表与黑板索引优先)",
|
||||||
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
|
"editDescriptionPlaceholder": "测试目标、授权范围、联系人、注意事项…",
|
||||||
"scopeTitle": "测试范围",
|
"scopeTitle": "测试范围",
|
||||||
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
|
"scopeHint": "JSON 格式,供 Agent 理解授权边界与目标资产",
|
||||||
@@ -397,6 +415,13 @@
|
|||||||
"addGroup": "新建分组",
|
"addGroup": "新建分组",
|
||||||
"recentConversations": "最近对话",
|
"recentConversations": "最近对话",
|
||||||
"batchManage": "批量管理",
|
"batchManage": "批量管理",
|
||||||
|
"paginationShow": "显示 {{start}}-{{end}} / 共 {{total}}",
|
||||||
|
"paginationRange": "{{start}}-{{end}}/{{total}}",
|
||||||
|
"paginationTotal": "共 {{total}} 条",
|
||||||
|
"paginationPage": "{{page}}/{{total}}",
|
||||||
|
"paginationPerPage": "每页",
|
||||||
|
"paginationPrev": "上一页",
|
||||||
|
"paginationNext": "下一页",
|
||||||
"attackChain": "攻击链",
|
"attackChain": "攻击链",
|
||||||
"viewAttackChain": "查看攻击链",
|
"viewAttackChain": "查看攻击链",
|
||||||
"selectRole": "选择角色",
|
"selectRole": "选择角色",
|
||||||
@@ -2518,14 +2543,6 @@
|
|||||||
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
"checkboxLinkTitle": "勾选表示本角色关联使用该工具"
|
||||||
},
|
},
|
||||||
"c2": {
|
"c2": {
|
||||||
"title": "C2 管理",
|
|
||||||
"welcomeTitle": "AI-Native C2 框架",
|
|
||||||
"welcomeDesc": "以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成「上线 → 控制 → 任务 → 横向 → 清场」全流程",
|
|
||||||
"statListeners": "运行中监听器",
|
|
||||||
"statSessions": "在线会话",
|
|
||||||
"statPending": "待审任务",
|
|
||||||
"goListeners": "管理监听器",
|
|
||||||
"goSessions": "查看会话",
|
|
||||||
"clipboardCopied": "已复制到剪贴板",
|
"clipboardCopied": "已复制到剪贴板",
|
||||||
"fmt": {
|
"fmt": {
|
||||||
"durationMs": "{{n}}ms",
|
"durationMs": "{{n}}ms",
|
||||||
|
|||||||
+23
-27
@@ -321,7 +321,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch(pageId) {
|
switch(pageId) {
|
||||||
case 'c2':
|
|
||||||
case 'c2-listeners':
|
case 'c2-listeners':
|
||||||
C2.loadListeners();
|
C2.loadListeners();
|
||||||
break;
|
break;
|
||||||
@@ -370,7 +369,6 @@
|
|||||||
C2.profiles = pdata.profiles;
|
C2.profiles = pdata.profiles;
|
||||||
}
|
}
|
||||||
C2.renderListeners();
|
C2.renderListeners();
|
||||||
C2.updateDashboardStats();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -736,7 +734,6 @@
|
|||||||
return apiRequest('GET', `${API_BASE}/sessions`).then(data => {
|
return apiRequest('GET', `${API_BASE}/sessions`).then(data => {
|
||||||
C2.sessions = data.sessions || [];
|
C2.sessions = data.sessions || [];
|
||||||
C2.renderSessions();
|
C2.renderSessions();
|
||||||
C2.updateDashboardStats();
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -1480,10 +1477,32 @@
|
|||||||
return '/' + stack.join('/');
|
return '/' + stack.join('/');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** 将 /d:/path/file 转为 Windows 远程路径 d:\path\file */
|
||||||
|
C2.toWindowsRemotePath = function(path) {
|
||||||
|
var p = String(path || '').trim().replace(/\\/g, '/');
|
||||||
|
if (/^\/[a-zA-Z]:\//.test(p)) {
|
||||||
|
p = p.slice(1);
|
||||||
|
}
|
||||||
|
return p.replace(/\//g, '\\');
|
||||||
|
};
|
||||||
|
|
||||||
|
C2.sessionIsWindows = function(session) {
|
||||||
|
if (!session) return false;
|
||||||
|
return String(session.os || '').toLowerCase().indexOf('windows') >= 0;
|
||||||
|
};
|
||||||
|
|
||||||
C2.resolveRemotePath = function(browsePath, filename) {
|
C2.resolveRemotePath = function(browsePath, filename) {
|
||||||
var joined = C2.joinFilePath(browsePath || '.', filename);
|
var joined = C2.joinFilePath(browsePath || '.', filename);
|
||||||
if (!C2.implantPwd) return joined;
|
if (!C2.implantPwd) return joined;
|
||||||
return C2.resolvePathAgainstPwd(C2.implantPwd, joined);
|
var resolved = C2.resolvePathAgainstPwd(C2.implantPwd, joined);
|
||||||
|
var session = null;
|
||||||
|
if (C2.selectedSessionId && C2.sessions) {
|
||||||
|
session = C2.sessions.find(function(s) { return s.id === C2.selectedSessionId; });
|
||||||
|
}
|
||||||
|
if (C2.sessionIsWindows(session)) {
|
||||||
|
return C2.toWindowsRemotePath(resolved);
|
||||||
|
}
|
||||||
|
return resolved;
|
||||||
};
|
};
|
||||||
|
|
||||||
C2.updateFileBreadcrumb = function(browsePath) {
|
C2.updateFileBreadcrumb = function(browsePath) {
|
||||||
@@ -2037,7 +2056,6 @@
|
|||||||
C2.renderTasks();
|
C2.renderTasks();
|
||||||
C2.renderTasksPagination();
|
C2.renderTasksPagination();
|
||||||
C2.syncTasksToolbar();
|
C2.syncTasksToolbar();
|
||||||
C2.updateDashboardStats();
|
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
showToast(err.message || String(err), 'error');
|
showToast(err.message || String(err), 'error');
|
||||||
});
|
});
|
||||||
@@ -2163,7 +2181,6 @@
|
|||||||
const tasks = data.tasks || [];
|
const tasks = data.tasks || [];
|
||||||
if (typeof data.pending_queued_count === 'number') {
|
if (typeof data.pending_queued_count === 'number') {
|
||||||
C2.tasksPendingQueuedCount = data.pending_queued_count;
|
C2.tasksPendingQueuedCount = data.pending_queued_count;
|
||||||
C2.updateDashboardStats();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
@@ -2819,7 +2836,6 @@
|
|||||||
showToast(`[${event.category}] ${event.message}`, event.level === 'critical' ? 'error' : 'info');
|
showToast(`[${event.category}] ${event.message}`, event.level === 'critical' ? 'error' : 'info');
|
||||||
}
|
}
|
||||||
|
|
||||||
C2.updateDashboardStats();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
@@ -2953,26 +2969,6 @@
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
// ============================================================================
|
|
||||||
// 仪表盘
|
|
||||||
// ============================================================================
|
|
||||||
|
|
||||||
C2.updateDashboardStats = function() {
|
|
||||||
const runningListeners = C2.listeners.filter(l => l.status === 'running').length;
|
|
||||||
const activeSessions = C2.sessions.filter(s => s.status === 'active').length;
|
|
||||||
const pendingTasks = typeof C2.tasksPendingQueuedCount === 'number'
|
|
||||||
? C2.tasksPendingQueuedCount
|
|
||||||
: C2.tasks.filter(t => t.status === 'queued' || t.status === 'pending').length;
|
|
||||||
|
|
||||||
const elListeners = document.getElementById('c2-stat-listeners');
|
|
||||||
const elSessions = document.getElementById('c2-stat-sessions');
|
|
||||||
const elPending = document.getElementById('c2-stat-pending');
|
|
||||||
|
|
||||||
if (elListeners) elListeners.textContent = runningListeners;
|
|
||||||
if (elSessions) elSessions.textContent = activeSessions;
|
|
||||||
if (elPending) elPending.textContent = pendingTasks;
|
|
||||||
};
|
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// 模态框
|
// 模态框
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
+214
-18
@@ -2939,6 +2939,8 @@ function createConversationListItem(conversation) {
|
|||||||
// 处理历史记录搜索
|
// 处理历史记录搜索
|
||||||
let conversationSearchTimer = null;
|
let conversationSearchTimer = null;
|
||||||
function handleConversationSearch(query) {
|
function handleConversationSearch(query) {
|
||||||
|
conversationsPagination.page = 1;
|
||||||
|
conversationsSearchQuery = query || '';
|
||||||
// 防抖处理,避免频繁请求
|
// 防抖处理,避免频繁请求
|
||||||
if (conversationSearchTimer) {
|
if (conversationSearchTimer) {
|
||||||
clearTimeout(conversationSearchTimer);
|
clearTimeout(conversationSearchTimer);
|
||||||
@@ -2972,6 +2974,8 @@ function clearConversationSearch() {
|
|||||||
clearBtn.style.display = 'none';
|
clearBtn.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
conversationsPagination.page = 1;
|
||||||
|
conversationsSearchQuery = '';
|
||||||
loadConversations('');
|
loadConversations('');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3408,6 +3412,21 @@ async function deleteConversation(conversationId, skipConfirm = false) {
|
|||||||
} else if (typeof loadConversations === 'function') {
|
} else if (typeof loadConversations === 'function') {
|
||||||
loadConversations();
|
loadConversations();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 批量管理弹窗打开时,同步刷新弹窗内列表
|
||||||
|
const batchModal = document.getElementById('batch-manage-modal');
|
||||||
|
if (batchModal && batchModal.style.display === 'flex') {
|
||||||
|
allConversationsForBatch = allConversationsForBatch.filter(c => c.id !== conversationId);
|
||||||
|
updateBatchManageTitle(allConversationsForBatch.length);
|
||||||
|
const searchInput = document.getElementById('batch-search-input');
|
||||||
|
const query = searchInput ? searchInput.value : '';
|
||||||
|
if (query && query.trim()) {
|
||||||
|
filterBatchConversations(query);
|
||||||
|
} else {
|
||||||
|
renderBatchConversations();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
|
// 通知其他模块(如 WebShell AI 助手)同步删除,保持列表一致
|
||||||
try {
|
try {
|
||||||
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
|
document.dispatchEvent(new CustomEvent('conversation-deleted', { detail: { conversationId } }));
|
||||||
@@ -5608,6 +5627,168 @@ let groupsCache = [];
|
|||||||
let conversationGroupMappingCache = {};
|
let conversationGroupMappingCache = {};
|
||||||
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
let pendingGroupMappings = {}; // 待保留的分组映射(用于处理后端API延迟的情况)
|
||||||
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
let conversationsListLoadSeq = 0; // 对话列表加载序号,避免并发请求导致重复渲染
|
||||||
|
const CONVERSATIONS_PAGE_SIZE_KEY = 'cyberstrike.conversations_page_size';
|
||||||
|
|
||||||
|
function getConversationsPageSize() {
|
||||||
|
try {
|
||||||
|
const saved = parseInt(localStorage.getItem(CONVERSATIONS_PAGE_SIZE_KEY), 10);
|
||||||
|
if ([20, 50, 100].includes(saved)) return saved;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
let conversationsPagination = { page: 1, pageSize: getConversationsPageSize(), total: 0 };
|
||||||
|
let conversationsSearchQuery = '';
|
||||||
|
|
||||||
|
function parseListTotalValue(raw, itemsLength) {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||||
|
if (raw != null && raw !== '') {
|
||||||
|
const n = parseInt(String(raw), 10);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return itemsLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListOffsetValue(raw) {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||||
|
if (raw != null && raw !== '') {
|
||||||
|
const n = parseInt(String(raw), 10);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseConversationsListResponse(data) {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
|
||||||
|
}
|
||||||
|
const items = data.conversations || data.items || [];
|
||||||
|
const arr = Array.isArray(items) ? items : [];
|
||||||
|
return {
|
||||||
|
items: arr,
|
||||||
|
total: parseListTotalValue(data.total, arr.length),
|
||||||
|
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
|
||||||
|
offset: parseListOffsetValue(data.offset),
|
||||||
|
isLegacyArray: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveConversationsListTotal(params, parsed, pageSize, offset) {
|
||||||
|
const serverTotal = parsed.total;
|
||||||
|
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
|
||||||
|
return serverTotal;
|
||||||
|
}
|
||||||
|
if (parsed.items.length < pageSize) {
|
||||||
|
return Math.max(serverTotal, offset + parsed.items.length);
|
||||||
|
}
|
||||||
|
const probe = new URLSearchParams(params);
|
||||||
|
probe.set('offset', String(offset + pageSize));
|
||||||
|
probe.set('limit', '1');
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/conversations?${probe}`);
|
||||||
|
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
|
||||||
|
const probeParsed = parseConversationsListResponse(await res.json());
|
||||||
|
if (probeParsed.total > serverTotal) return probeParsed.total;
|
||||||
|
if (probeParsed.items.length > 0) {
|
||||||
|
return Math.max(serverTotal, offset + pageSize + 1);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return Math.max(serverTotal, offset + parsed.items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllConversations(searchQuery) {
|
||||||
|
let all = [];
|
||||||
|
const pageSize = 200;
|
||||||
|
let offset = 0;
|
||||||
|
let total = Infinity;
|
||||||
|
const search = (searchQuery || '').trim();
|
||||||
|
while (all.length < total) {
|
||||||
|
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
const res = await apiFetch(`/api/conversations?${params}`);
|
||||||
|
if (!res.ok) throw new Error('load conversations failed');
|
||||||
|
const parsed = parseConversationsListResponse(await res.json());
|
||||||
|
all = all.concat(parsed.items);
|
||||||
|
total = parsed.total;
|
||||||
|
if (!parsed.items.length) break;
|
||||||
|
offset += parsed.items.length;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getConversationListEmptyHtml() {
|
||||||
|
return '<div class="conversations-list-empty" data-i18n="chat.noHistoryConversations"></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConversationsPagination(visibleCount) {
|
||||||
|
const el = document.getElementById('conversations-pagination');
|
||||||
|
if (!el) return;
|
||||||
|
const { page, pageSize, total } = conversationsPagination;
|
||||||
|
const count = typeof visibleCount === 'number' ? visibleCount : (conversationsPagination.visibleCount || 0);
|
||||||
|
conversationsPagination.visibleCount = count;
|
||||||
|
|
||||||
|
if (count === 0 || total === 0) {
|
||||||
|
el.innerHTML = '';
|
||||||
|
el.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
||||||
|
const navDisabled = totalPages <= 1;
|
||||||
|
el.hidden = false;
|
||||||
|
const start = (page - 1) * pageSize + 1;
|
||||||
|
const end = Math.min(page * pageSize, total);
|
||||||
|
const tFn = typeof window.t === 'function' ? window.t.bind(window) : null;
|
||||||
|
const infoText = tFn
|
||||||
|
? tFn('chat.paginationRange', { start, end, total })
|
||||||
|
: `${start}-${end}/${total}`;
|
||||||
|
const pageText = tFn
|
||||||
|
? tFn('chat.paginationPage', { page, total: totalPages })
|
||||||
|
: `${page}/${totalPages}`;
|
||||||
|
const perPageLabel = tFn ? tFn('chat.paginationPerPage') : 'Per page';
|
||||||
|
const prevLabel = tFn ? tFn('chat.paginationPrev') : 'Prev';
|
||||||
|
const nextLabel = tFn ? tFn('chat.paginationNext') : 'Next';
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
|
||||||
|
<span class="pagination-info">${escapeHtml(infoText)}</span>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(prevLabel)}" aria-label="${escapeHtml(prevLabel)}">‹</button>
|
||||||
|
<span class="pagination-page">${escapeHtml(pageText)}</span>
|
||||||
|
<button type="button" class="btn-icon-pagination" onclick="goConversationsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(nextLabel)}" aria-label="${escapeHtml(nextLabel)}">›</button>
|
||||||
|
</div>
|
||||||
|
<label class="pagination-page-size">
|
||||||
|
${escapeHtml(perPageLabel)}
|
||||||
|
<select id="conversations-page-size-pagination" onchange="changeConversationsPageSize()">
|
||||||
|
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function goConversationsPage(page) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil((conversationsPagination.total || 0) / conversationsPagination.pageSize) || 1);
|
||||||
|
const next = Math.min(Math.max(1, page), totalPages);
|
||||||
|
if (next === conversationsPagination.page) return;
|
||||||
|
conversationsPagination.page = next;
|
||||||
|
loadConversationsWithGroups(conversationsSearchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeConversationsPageSize() {
|
||||||
|
const sel = document.getElementById('conversations-page-size-pagination');
|
||||||
|
const newSize = sel ? parseInt(sel.value, 10) : 50;
|
||||||
|
if (![20, 50, 100].includes(newSize)) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(CONVERSATIONS_PAGE_SIZE_KEY, String(newSize));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
conversationsPagination.pageSize = newSize;
|
||||||
|
conversationsPagination.page = 1;
|
||||||
|
loadConversationsWithGroups(conversationsSearchQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.goConversationsPage = goConversationsPage;
|
||||||
|
window.changeConversationsPageSize = changeConversationsPageSize;
|
||||||
|
|
||||||
// 加载分组列表
|
// 加载分组列表
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
@@ -5704,12 +5885,17 @@ async function loadGroups() {
|
|||||||
async function loadConversationsWithGroups(searchQuery = '') {
|
async function loadConversationsWithGroups(searchQuery = '') {
|
||||||
const loadSeq = ++conversationsListLoadSeq;
|
const loadSeq = ++conversationsListLoadSeq;
|
||||||
try {
|
try {
|
||||||
// 并行加载分组列表、分组映射和对话列表(消除串行等待)
|
conversationsSearchQuery = searchQuery || '';
|
||||||
const limit = (searchQuery && searchQuery.trim()) ? 100 : 100;
|
conversationsPagination.pageSize = getConversationsPageSize();
|
||||||
let url = `/api/conversations?limit=${limit}`;
|
const pageSize = conversationsPagination.pageSize;
|
||||||
|
const offset = (conversationsPagination.page - 1) * pageSize;
|
||||||
|
const convParams = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||||
if (searchQuery && searchQuery.trim()) {
|
if (searchQuery && searchQuery.trim()) {
|
||||||
url += '&search=' + encodeURIComponent(searchQuery.trim());
|
convParams.set('search', searchQuery.trim());
|
||||||
|
} else {
|
||||||
|
convParams.set('exclude_grouped', 'true');
|
||||||
}
|
}
|
||||||
|
const url = `/api/conversations?${convParams}`;
|
||||||
const [,, response] = await Promise.all([
|
const [,, response] = await Promise.all([
|
||||||
loadGroups(),
|
loadGroups(),
|
||||||
loadConversationGroupMapping(),
|
loadConversationGroupMapping(),
|
||||||
@@ -5726,23 +5912,26 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
const sidebarContent = listContainer.closest('.sidebar-content');
|
const sidebarContent = listContainer.closest('.sidebar-content');
|
||||||
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
|
const savedScrollTop = sidebarContent ? sidebarContent.scrollTop : 0;
|
||||||
|
|
||||||
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
|
const emptyStateHtml = getConversationListEmptyHtml();
|
||||||
listContainer.innerHTML = '';
|
listContainer.innerHTML = '';
|
||||||
|
|
||||||
// 如果响应不是200,显示空状态(友好处理,不显示错误)
|
// 如果响应不是200,显示空状态(友好处理,不显示错误)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const conversations = await response.json();
|
const data = await response.json();
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
|
const parsed = parseConversationsListResponse(data);
|
||||||
|
conversationsPagination.total = await resolveConversationsListTotal(convParams, parsed, pageSize, offset);
|
||||||
|
|
||||||
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
|
// 双重保险:后端或并发情况下若出现重复ID,前端按ID去重
|
||||||
const uniqueConversations = [];
|
const uniqueConversations = [];
|
||||||
const seenConversationIds = new Set();
|
const seenConversationIds = new Set();
|
||||||
(Array.isArray(conversations) ? conversations : []).forEach(conv => {
|
parsed.items.forEach(conv => {
|
||||||
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
|
if (!conv || !conv.id || seenConversationIds.has(conv.id)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -5753,6 +5942,7 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
if (uniqueConversations.length === 0) {
|
if (uniqueConversations.length === 0) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -5863,15 +6053,29 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
fragment.appendChild(section);
|
fragment.appendChild(section);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const visibleCount = pinnedConvs.length + Object.values(groups).reduce((n, arr) => n + (arr ? arr.length : 0), 0);
|
||||||
|
conversationsPagination.visibleCount = visibleCount;
|
||||||
|
|
||||||
|
if (!hasSearchQuery && visibleCount === 0 && parsed.items.length > 0) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(parsed.total / pageSize));
|
||||||
|
if (conversationsPagination.page < totalPages) {
|
||||||
|
conversationsPagination.page += 1;
|
||||||
|
loadConversationsWithGroups(searchQuery);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (fragment.children.length === 0) {
|
if (fragment.children.length === 0) {
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
listContainer.innerHTML = emptyStateHtml;
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (loadSeq !== conversationsListLoadSeq) return;
|
if (loadSeq !== conversationsListLoadSeq) return;
|
||||||
listContainer.appendChild(fragment);
|
listContainer.appendChild(fragment);
|
||||||
updateActiveConversation();
|
updateActiveConversation();
|
||||||
|
renderConversationsPagination(visibleCount);
|
||||||
|
|
||||||
// 恢复滚动位置
|
// 恢复滚动位置
|
||||||
if (sidebarContent) {
|
if (sidebarContent) {
|
||||||
@@ -5888,9 +6092,9 @@ async function loadConversationsWithGroups(searchQuery = '') {
|
|||||||
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
// 错误时显示空状态,而不是错误提示(更友好的用户体验)
|
||||||
const listContainer = document.getElementById('conversations-list');
|
const listContainer = document.getElementById('conversations-list');
|
||||||
if (listContainer) {
|
if (listContainer) {
|
||||||
const emptyStateHtml = '<div style="padding: 20px; text-align: center; color: var(--text-muted); font-size: 0.875rem;" data-i18n="chat.noHistoryConversations"></div>';
|
listContainer.innerHTML = getConversationListEmptyHtml();
|
||||||
listContainer.innerHTML = emptyStateHtml;
|
|
||||||
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
if (typeof window.applyTranslations === 'function') window.applyTranslations(listContainer);
|
||||||
|
renderConversationsPagination(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -7004,15 +7208,7 @@ function updateBatchManageTitle(count) {
|
|||||||
|
|
||||||
async function showBatchManageModal() {
|
async function showBatchManageModal() {
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/conversations?limit=1000');
|
allConversationsForBatch = await fetchAllConversations('');
|
||||||
|
|
||||||
// 如果响应不是200,使用空数组(友好处理,不显示错误)
|
|
||||||
if (!response.ok) {
|
|
||||||
allConversationsForBatch = [];
|
|
||||||
} else {
|
|
||||||
const data = await response.json();
|
|
||||||
allConversationsForBatch = Array.isArray(data) ? data : [];
|
|
||||||
}
|
|
||||||
|
|
||||||
const modal = document.getElementById('batch-manage-modal');
|
const modal = document.getElementById('batch-manage-modal');
|
||||||
updateBatchManageTitle(allConversationsForBatch.length);
|
updateBatchManageTitle(allConversationsForBatch.length);
|
||||||
|
|||||||
+354
-31
@@ -21,6 +21,9 @@ var dashboardState = {
|
|||||||
lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms)
|
lastUpdatedAt: 0, // 上次成功刷新的时间戳(ms)
|
||||||
dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹)
|
dismissedAlertKey: null, // 当前会话中被用户「×」掉的告警内容指纹(同样的 reasons 不再弹)
|
||||||
lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA
|
lastResources: null, // 上一轮关键资源快照,用于判断是否首次有数据 / 智能 CTA
|
||||||
|
recentFeedTab: 'vulns', // 最近漏洞 / 近期事实 Tab
|
||||||
|
accessTab: 'c2', // 接入概览 Tab:c2 | webshell
|
||||||
|
lastProjectSummary: null, // 最近一次项目仪表盘摘要(供 Tab 切换时重绘)
|
||||||
};
|
};
|
||||||
|
|
||||||
async function refreshDashboard() {
|
async function refreshDashboard() {
|
||||||
@@ -57,9 +60,14 @@ async function refreshDashboard() {
|
|||||||
hideEl('dashboard-kpi-vuln-critical-badge');
|
hideEl('dashboard-kpi-vuln-critical-badge');
|
||||||
hideEl('dashboard-alert-banner');
|
hideEl('dashboard-alert-banner');
|
||||||
setRecentVulnsLoading();
|
setRecentVulnsLoading();
|
||||||
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) {
|
setRecentFactsLoading();
|
||||||
|
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
||||||
setEl('dashboard-resource-' + k, '…');
|
setEl('dashboard-resource-' + k, '…');
|
||||||
});
|
});
|
||||||
|
setEl('dashboard-webshell-connections', '…');
|
||||||
|
setEl('dashboard-c2-listeners-running', '…');
|
||||||
|
setEl('dashboard-c2-sessions-online', '…');
|
||||||
|
setEl('dashboard-c2-tasks-pending', '…');
|
||||||
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
|
var chartPlaceholder = document.getElementById('dashboard-tools-pie-placeholder');
|
||||||
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
|
if (chartPlaceholder) { chartPlaceholder.style.removeProperty('display'); chartPlaceholder.textContent = (typeof window.t === 'function' ? window.t('common.loading') : '加载中…'); }
|
||||||
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
var barChartEl = document.getElementById('dashboard-tools-bar-chart');
|
||||||
@@ -104,7 +112,8 @@ async function refreshDashboard() {
|
|||||||
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
|
openCriticalRes, openHighRes, openMediumRes, openLowRes, toolsConfigRes,
|
||||||
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
hitlPendingRes, notificationsRes, externalMcpStatsRes,
|
||||||
webshellRes,
|
webshellRes,
|
||||||
c2ListenersRes, c2SessionsRes, c2TasksRes
|
c2ListenersRes, c2SessionsRes, c2TasksRes,
|
||||||
|
projectSummaryRes
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
fetchJson('/api/agent-loop/tasks'),
|
fetchJson('/api/agent-loop/tasks'),
|
||||||
fetchJson('/api/vulnerabilities/stats'),
|
fetchJson('/api/vulnerabilities/stats'),
|
||||||
@@ -112,7 +121,7 @@ async function refreshDashboard() {
|
|||||||
fetchJson('/api/monitor/stats'),
|
fetchJson('/api/monitor/stats'),
|
||||||
fetchJson('/api/knowledge/stats'),
|
fetchJson('/api/knowledge/stats'),
|
||||||
fetchJson('/api/skills/stats'),
|
fetchJson('/api/skills/stats'),
|
||||||
fetchJson('/api/vulnerabilities?limit=5&page=1'),
|
fetchJson('/api/vulnerabilities?limit=10&page=1'),
|
||||||
fetchJson('/api/roles'),
|
fetchJson('/api/roles'),
|
||||||
fetchJson('/api/multi-agent/markdown-agents'),
|
fetchJson('/api/multi-agent/markdown-agents'),
|
||||||
openVulnQuery('critical'),
|
openVulnQuery('critical'),
|
||||||
@@ -134,7 +143,8 @@ async function refreshDashboard() {
|
|||||||
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
// C2 仪表盘条:监听器 / 会话 / 待处理任务(任务接口含 pending_queued_count)
|
||||||
fetchJson('/api/c2/listeners'),
|
fetchJson('/api/c2/listeners'),
|
||||||
fetchJson('/api/c2/sessions?limit=500'),
|
fetchJson('/api/c2/sessions?limit=500'),
|
||||||
fetchJson('/api/c2/tasks?page=1&page_size=1')
|
fetchJson('/api/c2/tasks?page=1&page_size=1'),
|
||||||
|
fetchJson('/api/projects/dashboard-summary?fact_limit=10')
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
// 如果在 await 期间 controller 已被 abort,说明又有新刷新启动了,丢弃本次结果
|
||||||
@@ -373,20 +383,10 @@ async function refreshDashboard() {
|
|||||||
} else {
|
} else {
|
||||||
setEl('dashboard-resource-agents', '-');
|
setEl('dashboard-resource-agents', '-');
|
||||||
}
|
}
|
||||||
// WebShell 已建立的连接:/api/webshell/connections 直接返回数组(不带包裹),
|
|
||||||
// 兼容一下 { connections: [...] } 形式以防后续接口变更
|
|
||||||
var webshellList = null;
|
|
||||||
if (Array.isArray(webshellRes)) webshellList = webshellRes;
|
|
||||||
else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections;
|
|
||||||
var webshellCount = webshellList ? webshellList.length : null;
|
|
||||||
if (webshellCount !== null) {
|
|
||||||
setEl('dashboard-resource-webshell', formatNumber(webshellCount));
|
|
||||||
} else {
|
|
||||||
setEl('dashboard-resource-webshell', '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
// 最近漏洞列表
|
// 最近漏洞列表
|
||||||
renderRecentVulns(recentVulnsRes);
|
renderRecentVulns(recentVulnsRes);
|
||||||
|
dashboardState.lastProjectSummary = projectSummaryRes;
|
||||||
|
renderRecentFacts(projectSummaryRes);
|
||||||
|
|
||||||
// External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作)
|
// External MCP 健康度(同时拿到 down 数喂给 alert banner / 推荐操作)
|
||||||
var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes);
|
var externalMcpDown = renderExternalMcpHealth(externalMcpStatsRes);
|
||||||
@@ -397,8 +397,8 @@ async function refreshDashboard() {
|
|||||||
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
|
// 「最近事件」内联展示(来自通知摘要,过滤掉已经被仪表盘其他位置覆盖的类型)
|
||||||
renderRecentEvents(notificationsRes);
|
renderRecentEvents(notificationsRes);
|
||||||
|
|
||||||
// C2 概览条(监听器 / 在线会话 / 待处理任务)
|
// 接入概览(C2 + WebShell)
|
||||||
renderDashboardC2Overview(c2ListenersRes, c2SessionsRes, c2TasksRes);
|
renderDashboardAccessOverview(c2ListenersRes, c2SessionsRes, c2TasksRes, webshellRes);
|
||||||
|
|
||||||
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
|
// 关键提醒条:把所有可能的告警源(漏洞/HITL/失败率/MCP健康)合并展示
|
||||||
renderDashboardAlertBanner({
|
renderDashboardAlertBanner({
|
||||||
@@ -448,12 +448,13 @@ async function refreshDashboard() {
|
|||||||
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
setKpiSubText('dashboard-kpi-vuln-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
setKpiSubText('dashboard-kpi-tools-sub-text', '-');
|
||||||
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
|
setKpiSubText('dashboard-kpi-rate-sub-text', '-');
|
||||||
['tools', 'skills', 'knowledge', 'roles', 'agents', 'webshell'].forEach(function (k) {
|
['tools', 'skills', 'knowledge', 'roles', 'agents'].forEach(function (k) {
|
||||||
setEl('dashboard-resource-' + k, '-');
|
setEl('dashboard-resource-' + k, '-');
|
||||||
});
|
});
|
||||||
var c2secErr = document.getElementById('dashboard-section-c2');
|
var accessSecErr = document.getElementById('dashboard-section-access');
|
||||||
if (c2secErr) c2secErr.hidden = true;
|
if (accessSecErr) accessSecErr.hidden = true;
|
||||||
setRecentVulnsError();
|
setRecentVulnsError();
|
||||||
|
setRecentFactsError();
|
||||||
renderDashboardToolsBar(null);
|
renderDashboardToolsBar(null);
|
||||||
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
var ph = document.getElementById('dashboard-tools-pie-placeholder');
|
||||||
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
|
if (ph) { ph.style.removeProperty('display'); ph.textContent = (typeof window.t === 'function' ? window.t('dashboard.noCallData') : '暂无调用数据'); }
|
||||||
@@ -467,14 +468,29 @@ async function refreshDashboard() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** C2 概览条:依赖 /api/c2/listeners、sessions、tasks;任一路由失败则整块隐藏 */
|
/** 接入概览:C2 / WebShell Tab 切换;C2 禁用时仅保留 WebShell Tab */
|
||||||
function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) {
|
function renderDashboardAccessOverview(listenersRes, sessionsRes, tasksRes, webshellRes) {
|
||||||
var section = document.getElementById('dashboard-section-c2');
|
var section = document.getElementById('dashboard-section-access');
|
||||||
if (!section) return;
|
if (!section) return;
|
||||||
if (listenersRes === null && sessionsRes === null && tasksRes === null) {
|
|
||||||
|
var c2ConfigOn = window.__c2Enabled !== false;
|
||||||
|
var webshellList = null;
|
||||||
|
if (Array.isArray(webshellRes)) webshellList = webshellRes;
|
||||||
|
else if (webshellRes && Array.isArray(webshellRes.connections)) webshellList = webshellRes.connections;
|
||||||
|
var wsApiOk = webshellRes !== null;
|
||||||
|
var c2ApiOk = listenersRes !== null || sessionsRes !== null || tasksRes !== null;
|
||||||
|
var showC2 = c2ConfigOn && c2ApiOk;
|
||||||
|
var showWs = wsApiOk;
|
||||||
|
|
||||||
|
section.dataset.c2Available = showC2 ? '1' : '0';
|
||||||
|
section.dataset.webshellAvailable = showWs ? '1' : '0';
|
||||||
|
|
||||||
|
if (!showC2 && !showWs) {
|
||||||
section.hidden = true;
|
section.hidden = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (showC2) {
|
||||||
var running = '-';
|
var running = '-';
|
||||||
if (listenersRes && Array.isArray(listenersRes.listeners)) {
|
if (listenersRes && Array.isArray(listenersRes.listeners)) {
|
||||||
running = String(listenersRes.listeners.filter(function (l) {
|
running = String(listenersRes.listeners.filter(function (l) {
|
||||||
@@ -508,12 +524,125 @@ function renderDashboardC2Overview(listenersRes, sessionsRes, tasksRes) {
|
|||||||
setEl('dashboard-c2-listeners-running', running);
|
setEl('dashboard-c2-listeners-running', running);
|
||||||
setEl('dashboard-c2-sessions-online', online);
|
setEl('dashboard-c2-sessions-online', online);
|
||||||
setEl('dashboard-c2-tasks-pending', pending);
|
setEl('dashboard-c2-tasks-pending', pending);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (showWs) {
|
||||||
|
var wsCount = webshellList ? webshellList.length : 0;
|
||||||
|
setEl('dashboard-webshell-connections', formatNumber(wsCount));
|
||||||
|
renderDashboardWebshellRecent(webshellList || []);
|
||||||
|
}
|
||||||
|
|
||||||
section.hidden = false;
|
section.hidden = false;
|
||||||
|
syncDashboardAccessTabs();
|
||||||
if (typeof applyTranslations === 'function') {
|
if (typeof applyTranslations === 'function') {
|
||||||
try { applyTranslations(section); } catch (_e) { /* ignore */ }
|
try { applyTranslations(section); } catch (_e) { /* ignore */ }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** C2 / WebShell Tab 切换(样式与「最近漏洞 / 近期事实」一致) */
|
||||||
|
function switchDashboardAccessTab(tab) {
|
||||||
|
tab = tab === 'webshell' ? 'webshell' : 'c2';
|
||||||
|
dashboardState.accessTab = tab;
|
||||||
|
applyDashboardAccessTabUI(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyDashboardAccessTabUI(tab) {
|
||||||
|
var tabC2 = document.getElementById('dashboard-access-tab-c2');
|
||||||
|
var tabWs = document.getElementById('dashboard-access-tab-webshell');
|
||||||
|
var panelC2 = document.getElementById('dashboard-access-panel-c2');
|
||||||
|
var panelWs = document.getElementById('dashboard-access-panel-webshell');
|
||||||
|
if (tabC2) {
|
||||||
|
tabC2.classList.toggle('is-active', tab === 'c2');
|
||||||
|
tabC2.setAttribute('aria-selected', tab === 'c2' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (tabWs) {
|
||||||
|
tabWs.classList.toggle('is-active', tab === 'webshell');
|
||||||
|
tabWs.setAttribute('aria-selected', tab === 'webshell' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (panelC2) panelC2.hidden = tab !== 'c2';
|
||||||
|
if (panelWs) panelWs.hidden = tab !== 'webshell';
|
||||||
|
updateDashboardAccessViewAll(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardAccessViewAll(tab) {
|
||||||
|
var link = document.getElementById('dashboard-access-view-all');
|
||||||
|
if (!link) return;
|
||||||
|
if (tab === 'webshell') {
|
||||||
|
link.onclick = function () { try { switchPage('webshell'); } catch (_) {} };
|
||||||
|
link.setAttribute('data-i18n', 'dashboard.webshellGoManage');
|
||||||
|
link.textContent = dt('dashboard.webshellGoManage', null, '进入 WebShell →');
|
||||||
|
} else {
|
||||||
|
link.onclick = function () { try { switchPage('c2-listeners'); } catch (_) {} };
|
||||||
|
link.setAttribute('data-i18n', 'dashboard.c2GoManage');
|
||||||
|
link.textContent = dt('dashboard.c2GoManage', null, '进入 C2 →');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 根据可用模块同步 Tab 可见性与默认选中项 */
|
||||||
|
function syncDashboardAccessTabs() {
|
||||||
|
var section = document.getElementById('dashboard-section-access');
|
||||||
|
if (!section || section.hidden) return;
|
||||||
|
|
||||||
|
var showC2 = section.dataset.c2Available === '1';
|
||||||
|
var showWs = section.dataset.webshellAvailable === '1';
|
||||||
|
var tabNav = document.getElementById('dashboard-access-tabs');
|
||||||
|
var tabC2 = document.getElementById('dashboard-access-tab-c2');
|
||||||
|
var tabWs = document.getElementById('dashboard-access-tab-webshell');
|
||||||
|
|
||||||
|
if (tabC2) tabC2.hidden = !showC2;
|
||||||
|
if (tabWs) tabWs.hidden = !showWs;
|
||||||
|
if (tabNav) tabNav.hidden = false;
|
||||||
|
|
||||||
|
var tab = dashboardState.accessTab;
|
||||||
|
if (tab === 'c2' && !showC2) tab = 'webshell';
|
||||||
|
if (tab === 'webshell' && !showWs) tab = 'c2';
|
||||||
|
if (!showC2 && showWs) tab = 'webshell';
|
||||||
|
if (showC2 && !showWs) tab = 'c2';
|
||||||
|
dashboardState.accessTab = tab;
|
||||||
|
applyDashboardAccessTabUI(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** WebShell 接入概览:最近 3 条连接摘要 */
|
||||||
|
function renderDashboardWebshellRecent(list) {
|
||||||
|
var container = document.getElementById('dashboard-webshell-recent');
|
||||||
|
if (!container) return;
|
||||||
|
container.innerHTML = '';
|
||||||
|
if (!list || list.length === 0) {
|
||||||
|
container.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var sorted = list.slice().sort(function (a, b) {
|
||||||
|
var ta = (a && a.createdAt) ? Date.parse(a.createdAt) : 0;
|
||||||
|
var tb = (b && b.createdAt) ? Date.parse(b.createdAt) : 0;
|
||||||
|
return tb - ta;
|
||||||
|
});
|
||||||
|
var recent = sorted.slice(0, 3);
|
||||||
|
recent.forEach(function (conn) {
|
||||||
|
if (!conn) return;
|
||||||
|
var item = document.createElement('div');
|
||||||
|
item.className = 'dashboard-webshell-recent-item';
|
||||||
|
item.setAttribute('role', 'button');
|
||||||
|
item.setAttribute('tabindex', '0');
|
||||||
|
var label = (conn.remark || '').trim() || (conn.url || '').trim() || (conn.id || '');
|
||||||
|
var typeTag = (conn.type || 'shell').toUpperCase();
|
||||||
|
item.innerHTML =
|
||||||
|
'<span class="dashboard-webshell-recent-type">' + esc(typeTag) + '</span>' +
|
||||||
|
'<span class="dashboard-webshell-recent-label" title="' + esc(label) + '">' + esc(label) + '</span>';
|
||||||
|
var openWs = function () {
|
||||||
|
try { switchPage('webshell'); } catch (_) {}
|
||||||
|
};
|
||||||
|
item.addEventListener('click', openWs);
|
||||||
|
item.addEventListener('keydown', function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
openWs();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
container.appendChild(item);
|
||||||
|
});
|
||||||
|
container.hidden = false;
|
||||||
|
}
|
||||||
|
|
||||||
function setEl(id, text) {
|
function setEl(id, text) {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
if (el) el.textContent = text;
|
if (el) el.textContent = text;
|
||||||
@@ -1088,12 +1217,9 @@ function renderRecentVulns(res) {
|
|||||||
if (list.length === 0) {
|
if (list.length === 0) {
|
||||||
if (empty) {
|
if (empty) {
|
||||||
empty.hidden = false;
|
empty.hidden = false;
|
||||||
// 升级版空状态:图标 + 标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
|
// 升级版空状态:标题 + 描述 + 行动按钮,比纯文本更易引导用户下一步
|
||||||
empty.classList.add('is-rich');
|
empty.classList.add('is-rich');
|
||||||
empty.innerHTML = (
|
empty.innerHTML = (
|
||||||
'<span class="dashboard-empty-icon" aria-hidden="true">' +
|
|
||||||
'<svg width="40" height="40" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/><path d="M9 12l2 2 4-4"/></svg>' +
|
|
||||||
'</span>' +
|
|
||||||
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
|
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noVulnYet', null, '暂无最近漏洞')) + '</div>' +
|
||||||
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
|
'<div class="dashboard-empty-desc">' + esc(dt('dashboard.noVulnDesc', null, '此处展示近期漏洞记录;在对话中完成检测后,新结果会出现在这里')) + '</div>' +
|
||||||
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
|
'<button type="button" class="dashboard-empty-action" data-action="scan">' +
|
||||||
@@ -1109,7 +1235,7 @@ function renderRecentVulns(res) {
|
|||||||
empty.classList.remove('is-rich');
|
empty.classList.remove('is-rich');
|
||||||
}
|
}
|
||||||
|
|
||||||
list.slice(0, 5).forEach(function (v) {
|
list.slice(0, 10).forEach(function (v) {
|
||||||
const sev = (v.severity || 'info').toLowerCase();
|
const sev = (v.severity || 'info').toLowerCase();
|
||||||
const status = (v.status || 'open').toLowerCase();
|
const status = (v.status || 'open').toLowerCase();
|
||||||
const item = document.createElement('a');
|
const item = document.createElement('a');
|
||||||
@@ -1130,6 +1256,203 @@ function renderRecentVulns(res) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 最近漏洞 / 近期事实 Tab 切换(共用列表区域,查看全部链接随 Tab 变化)
|
||||||
|
function switchDashboardFeedTab(tab) {
|
||||||
|
tab = tab === 'facts' ? 'facts' : 'vulns';
|
||||||
|
dashboardState.recentFeedTab = tab;
|
||||||
|
|
||||||
|
var tabVulns = document.getElementById('dashboard-feed-tab-vulns');
|
||||||
|
var tabFacts = document.getElementById('dashboard-feed-tab-facts');
|
||||||
|
var panelVulns = document.getElementById('dashboard-feed-panel-vulns');
|
||||||
|
var panelFacts = document.getElementById('dashboard-feed-panel-facts');
|
||||||
|
if (tabVulns) {
|
||||||
|
tabVulns.classList.toggle('is-active', tab === 'vulns');
|
||||||
|
tabVulns.setAttribute('aria-selected', tab === 'vulns' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (tabFacts) {
|
||||||
|
tabFacts.classList.toggle('is-active', tab === 'facts');
|
||||||
|
tabFacts.setAttribute('aria-selected', tab === 'facts' ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
if (panelVulns) panelVulns.hidden = tab !== 'vulns';
|
||||||
|
if (panelFacts) panelFacts.hidden = tab !== 'facts';
|
||||||
|
updateDashboardFeedViewAll(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateDashboardFeedViewAll(tab) {
|
||||||
|
var link = document.getElementById('dashboard-feed-view-all');
|
||||||
|
if (!link) return;
|
||||||
|
if (tab === 'facts') {
|
||||||
|
link.onclick = function () { try { switchPage('projects'); } catch (_) {} };
|
||||||
|
} else {
|
||||||
|
link.onclick = function () { try { switchPage('vulnerabilities'); } catch (_) {} };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRecentFactsLoading() {
|
||||||
|
var wrap = document.getElementById('dashboard-recent-facts');
|
||||||
|
var empty = document.getElementById('dashboard-recent-facts-empty');
|
||||||
|
if (!wrap) return;
|
||||||
|
clearRecentFactsList(wrap);
|
||||||
|
if (empty) {
|
||||||
|
empty.hidden = false;
|
||||||
|
empty.classList.remove('is-rich');
|
||||||
|
empty.textContent = dt('common.loading', null, '加载中…');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRecentFactsList(wrap) {
|
||||||
|
if (!wrap) return;
|
||||||
|
Array.from(wrap.querySelectorAll('.dashboard-recent-fact-item, .dashboard-recent-facts-meta')).forEach(function (n) { n.remove(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRecentFactsError() {
|
||||||
|
var wrap = document.getElementById('dashboard-recent-facts');
|
||||||
|
var empty = document.getElementById('dashboard-recent-facts-empty');
|
||||||
|
if (!wrap) return;
|
||||||
|
clearRecentFactsList(wrap);
|
||||||
|
if (empty) {
|
||||||
|
empty.hidden = false;
|
||||||
|
empty.classList.remove('is-rich');
|
||||||
|
empty.textContent = dt('common.loadFailed', null, '加载失败');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function factConfidenceShortLabel(confidence) {
|
||||||
|
var c = String(confidence || '').toLowerCase();
|
||||||
|
if (c === 'confirmed') return dt('projects.confidenceConfirmed', null, '已确认');
|
||||||
|
if (c === 'tentative') return dt('projects.confidenceTentative', null, '待确认');
|
||||||
|
return c || '—';
|
||||||
|
}
|
||||||
|
|
||||||
|
function factCategoryShortLabel(category) {
|
||||||
|
var raw = String(category || '').trim();
|
||||||
|
return raw || 'note';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按 project_id(回退 project_name)稳定映射 8 种配色,同一项目跨刷新颜色一致
|
||||||
|
function projectFactProjectTone(projectId, projectName) {
|
||||||
|
var key = String(projectId || projectName || '').trim();
|
||||||
|
if (!key) return 0;
|
||||||
|
var hash = 0;
|
||||||
|
for (var i = 0; i < key.length; i++) {
|
||||||
|
hash = ((hash << 5) - hash) + key.charCodeAt(i);
|
||||||
|
hash |= 0;
|
||||||
|
}
|
||||||
|
return Math.abs(hash) % 8;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openProjectFactFromDashboard(projectId, factKey) {
|
||||||
|
if (!projectId) return;
|
||||||
|
if (typeof switchPage === 'function') {
|
||||||
|
switchPage('projects');
|
||||||
|
}
|
||||||
|
setTimeout(async function () {
|
||||||
|
if (typeof window.initProjectsPage === 'function') {
|
||||||
|
await window.initProjectsPage();
|
||||||
|
}
|
||||||
|
if (typeof window.selectProject === 'function') {
|
||||||
|
await window.selectProject(projectId);
|
||||||
|
}
|
||||||
|
if (typeof window.switchProjectTab === 'function') {
|
||||||
|
window.switchProjectTab('facts');
|
||||||
|
}
|
||||||
|
if (factKey && typeof window.viewProjectFactBody === 'function') {
|
||||||
|
window.viewProjectFactBody(factKey);
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRecentFacts(res) {
|
||||||
|
var wrap = document.getElementById('dashboard-recent-facts');
|
||||||
|
var empty = document.getElementById('dashboard-recent-facts-empty');
|
||||||
|
if (!wrap) return;
|
||||||
|
|
||||||
|
clearRecentFactsList(wrap);
|
||||||
|
|
||||||
|
var list = (res && Array.isArray(res.recent_facts)) ? res.recent_facts : [];
|
||||||
|
var totals = (res && res.totals) ? res.totals : {};
|
||||||
|
var activeProjects = totals.active_projects || 0;
|
||||||
|
var totalFacts = totals.total_facts || 0;
|
||||||
|
|
||||||
|
if (list.length === 0) {
|
||||||
|
if (empty) {
|
||||||
|
empty.hidden = false;
|
||||||
|
empty.classList.add('is-rich');
|
||||||
|
var desc = activeProjects > 0
|
||||||
|
? dt('dashboard.noFactsDesc', null, '在绑定项目的对话中,Agent 会自动记录目标、漏洞、攻击链等事实')
|
||||||
|
: dt('projects.selectOrCreateHint', null, '项目用于跨对话共享「事实黑板」:目标、环境、认证等信息会在绑定项目的对话中自动注入。');
|
||||||
|
var ctaLabel = activeProjects > 0
|
||||||
|
? dt('dashboard.goToChat', null, '前往对话')
|
||||||
|
: dt('dashboard.createFirstProjectBtn', null, '创建第一个项目');
|
||||||
|
var ctaAction = activeProjects > 0 ? 'chat' : 'project';
|
||||||
|
empty.innerHTML = (
|
||||||
|
'<div class="dashboard-empty-title">' + esc(dt('dashboard.noFactsYet', null, '暂无近期事实')) + '</div>' +
|
||||||
|
'<div class="dashboard-empty-desc">' + esc(desc) + '</div>' +
|
||||||
|
'<button type="button" class="dashboard-empty-action" data-action="' + esc(ctaAction) + '">' +
|
||||||
|
esc(ctaLabel) + ' →</button>'
|
||||||
|
);
|
||||||
|
var btn = empty.querySelector('[data-action]');
|
||||||
|
if (btn) {
|
||||||
|
btn.onclick = function () {
|
||||||
|
var action = btn.getAttribute('data-action');
|
||||||
|
if (action === 'project') {
|
||||||
|
try { switchPage('projects'); } catch (_) {}
|
||||||
|
setTimeout(function () {
|
||||||
|
if (typeof window.showNewProjectModal === 'function') {
|
||||||
|
window.showNewProjectModal();
|
||||||
|
}
|
||||||
|
}, 350);
|
||||||
|
} else {
|
||||||
|
try { switchPage('chat'); } catch (_) {}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty) {
|
||||||
|
empty.hidden = true;
|
||||||
|
empty.classList.remove('is-rich');
|
||||||
|
}
|
||||||
|
|
||||||
|
list.slice(0, 10).forEach(function (f) {
|
||||||
|
if (!f) return;
|
||||||
|
var category = factCategoryShortLabel(f.category);
|
||||||
|
var confidence = String(f.confidence || 'tentative').toLowerCase();
|
||||||
|
var item = document.createElement('a');
|
||||||
|
item.className = 'dashboard-recent-fact-item';
|
||||||
|
item.setAttribute('role', 'button');
|
||||||
|
item.tabIndex = 0;
|
||||||
|
var pid = f.project_id || '';
|
||||||
|
var fkey = f.fact_key || '';
|
||||||
|
item.onclick = function () { openProjectFactFromDashboard(pid, fkey); };
|
||||||
|
item.onkeydown = function (e) {
|
||||||
|
if (e.key === 'Enter' || e.key === ' ') {
|
||||||
|
e.preventDefault();
|
||||||
|
item.click();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 置顶列始终占位,避免有/无图钉时后续列错位
|
||||||
|
var pinMark = '<span class="dashboard-recent-fact-pin' + (f.pinned ? ' is-pinned' : '') + '"' +
|
||||||
|
(f.pinned ? (' title="' + esc(dt('projects.pinned', null, '置顶')) + '"') : '') +
|
||||||
|
' aria-hidden="true">' + (f.pinned ? '📌' : '') + '</span>';
|
||||||
|
var projectLabel = (f.project_name || '').trim() || dt('projects.defaultProjectName', null, '项目');
|
||||||
|
var factKeyLabel = (f.fact_key || '').trim() || '—';
|
||||||
|
var projectTone = projectFactProjectTone(pid, projectLabel);
|
||||||
|
var projectCol = '<span class="dashboard-recent-fact-project proj-tone-' + projectTone + '" title="' + esc(projectLabel) + '">' + esc(projectLabel) + '</span>';
|
||||||
|
var categoryBadge = '<span class="dashboard-recent-fact-cat cat-' + esc(category.toLowerCase().replace(/[^a-z0-9_-]/g, '')) + '">' + esc(category) + '</span>';
|
||||||
|
var confBadge = '<span class="dashboard-recent-fact-conf conf-' + esc(confidence) + '">' + esc(factConfidenceShortLabel(confidence)) + '</span>';
|
||||||
|
var summary = '<span class="dashboard-recent-fact-summary" title="' + esc(f.summary || '') + '">' + esc(f.summary || dt('common.untitled', null, '无标题')) + '</span>';
|
||||||
|
var factKeyCol = '<span class="dashboard-recent-fact-key" title="' + esc(factKeyLabel) + '">' + esc(factKeyLabel) + '</span>';
|
||||||
|
var time = '<span class="dashboard-recent-fact-time">' + esc(timeAgoStr(f.updated_at)) + '</span>';
|
||||||
|
|
||||||
|
item.innerHTML = pinMark + categoryBadge + confBadge + summary + factKeyCol + projectCol + time;
|
||||||
|
wrap.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据)
|
// 漏洞状态映射:把 status 字符串规整到 4 类(避免脏数据)
|
||||||
function statusKey(s) {
|
function statusKey(s) {
|
||||||
s = String(s || '').toLowerCase();
|
s = String(s || '').toLowerCase();
|
||||||
@@ -1224,7 +1547,7 @@ function renderVulnStatusPanel(byStatus, total) {
|
|||||||
//
|
//
|
||||||
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
|
// bySeverityOpen: { critical, high, medium, low }(只统计 status=open 的漏洞;info 不计入)
|
||||||
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
|
// totalOpen: 待处理漏洞总数(= critical + high + medium + low),仅用于"全无待处理 → safe"判断
|
||||||
// recentVulnsRes: /api/vulnerabilities?limit=5 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
|
// recentVulnsRes: /api/vulnerabilities?limit=10 响应(用于"最近发现"时间,口径是全量,与处置状态无关)
|
||||||
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
function renderSeverityInsights(bySeverityOpen, totalOpen, recentVulnsRes) {
|
||||||
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
|
var riskBox = document.querySelector('.dashboard-severity-insight-risk');
|
||||||
var levelEl = document.getElementById('dashboard-severity-risk-level');
|
var levelEl = document.getElementById('dashboard-severity-risk-level');
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+226
-48
@@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
let projectsCache = [];
|
let projectsCache = [];
|
||||||
let projectsCacheAll = [];
|
let projectsCacheAll = [];
|
||||||
|
const PROJECTS_LIST_PAGE_SIZE_KEY = 'cyberstrike.projects_list_page_size';
|
||||||
let currentProjectId = null;
|
let currentProjectId = null;
|
||||||
let currentProjectTab = 'facts';
|
let currentProjectTab = 'facts';
|
||||||
const projectNameById = {};
|
const projectNameById = {};
|
||||||
@@ -167,23 +168,128 @@ function rebuildProjectNameMap(list) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchProjectsList(includeArchived) {
|
function getProjectsListPageSize() {
|
||||||
|
try {
|
||||||
|
const saved = parseInt(localStorage.getItem(PROJECTS_LIST_PAGE_SIZE_KEY), 10);
|
||||||
|
if ([20, 50, 100].includes(saved)) return saved;
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
let projectsListPagination = { page: 1, pageSize: getProjectsListPageSize(), total: 0 };
|
||||||
|
let projectsListSearch = '';
|
||||||
|
let _projectsListSearchDebounce = null;
|
||||||
|
|
||||||
|
function parseListTotalValue(raw, itemsLength) {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||||
|
if (raw != null && raw !== '') {
|
||||||
|
const n = parseInt(String(raw), 10);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return itemsLength;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseListOffsetValue(raw) {
|
||||||
|
if (typeof raw === 'number' && Number.isFinite(raw) && raw >= 0) return raw;
|
||||||
|
if (raw != null && raw !== '') {
|
||||||
|
const n = parseInt(String(raw), 10);
|
||||||
|
if (Number.isFinite(n) && n >= 0) return n;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseProjectsListResponse(data) {
|
||||||
|
if (Array.isArray(data)) {
|
||||||
|
return { items: data, total: data.length, limit: data.length, offset: 0, isLegacyArray: true };
|
||||||
|
}
|
||||||
|
const items = data.projects || data.items || [];
|
||||||
|
const arr = Array.isArray(items) ? items : [];
|
||||||
|
return {
|
||||||
|
items: arr,
|
||||||
|
total: parseListTotalValue(data.total, arr.length),
|
||||||
|
limit: parseListTotalValue(data.limit, arr.length) || arr.length,
|
||||||
|
offset: parseListOffsetValue(data.offset),
|
||||||
|
isLegacyArray: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveProjectsListTotal(params, parsed, pageSize, offset) {
|
||||||
|
const serverTotal = parsed.total;
|
||||||
|
// 服务端 total 明确大于当前页末尾 → 直接信任
|
||||||
|
if (!parsed.isLegacyArray && serverTotal > offset + parsed.items.length) {
|
||||||
|
return serverTotal;
|
||||||
|
}
|
||||||
|
// 不足一页 → 已是最后一页
|
||||||
|
if (parsed.items.length < pageSize) {
|
||||||
|
return Math.max(serverTotal, offset + parsed.items.length);
|
||||||
|
}
|
||||||
|
// 满页但 total 可能被误算为 items.length → 探测下一页
|
||||||
|
const probe = new URLSearchParams(params);
|
||||||
|
probe.set('offset', String(offset + pageSize));
|
||||||
|
probe.set('limit', '1');
|
||||||
|
try {
|
||||||
|
const res = await apiFetch(`/api/projects?${probe}`);
|
||||||
|
if (!res.ok) return Math.max(serverTotal, offset + parsed.items.length);
|
||||||
|
const probeParsed = parseProjectsListResponse(await res.json());
|
||||||
|
if (probeParsed.total > serverTotal) return probeParsed.total;
|
||||||
|
if (probeParsed.items.length > 0) {
|
||||||
|
return Math.max(serverTotal, offset + pageSize + 1);
|
||||||
|
}
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
return Math.max(serverTotal, offset + parsed.items.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAllProjects(includeArchived) {
|
||||||
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
|
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
|
||||||
const url = showArchived ? '/api/projects?limit=200' : '/api/projects?status=active&limit=200';
|
let all = [];
|
||||||
const res = await apiFetch(url);
|
const pageSize = 200;
|
||||||
|
let offset = 0;
|
||||||
|
let total = Infinity;
|
||||||
|
while (all.length < total) {
|
||||||
|
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||||
|
if (!showArchived) params.set('status', 'active');
|
||||||
|
const res = await apiFetch(`/api/projects?${params}`);
|
||||||
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||||
const data = await res.json();
|
const parsed = parseProjectsListResponse(await res.json());
|
||||||
projectsCache = Array.isArray(data) ? data : [];
|
all = all.concat(parsed.items);
|
||||||
rebuildProjectNameMap(projectsCache);
|
total = parsed.total;
|
||||||
_projectsListReady = true;
|
if (!parsed.items.length) break;
|
||||||
|
offset += parsed.items.length;
|
||||||
|
}
|
||||||
|
return all;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchProjectsList(includeArchived, opts = {}) {
|
||||||
|
const showArchived = includeArchived || document.getElementById('projects-show-archived')?.checked;
|
||||||
|
const page = opts.page ?? projectsListPagination.page;
|
||||||
|
const pageSize = opts.pageSize ?? getProjectsListPageSize();
|
||||||
|
const search = opts.search !== undefined ? opts.search : projectsListSearch;
|
||||||
|
projectsListSearch = search;
|
||||||
|
const offset = (page - 1) * pageSize;
|
||||||
|
const params = new URLSearchParams({ limit: String(pageSize), offset: String(offset) });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
if (!showArchived) params.set('status', 'active');
|
||||||
|
const res = await apiFetch(`/api/projects?${params}`);
|
||||||
|
if (!res.ok) throw new Error(tp('projects.loadProjectsFailed'));
|
||||||
|
const parsed = parseProjectsListResponse(await res.json());
|
||||||
|
const total = await resolveProjectsListTotal(params, parsed, pageSize, offset);
|
||||||
|
projectsCache = parsed.items;
|
||||||
|
projectsListPagination = { page, pageSize: pageSize, total };
|
||||||
|
rebuildProjectNameMap(projectsCacheAll.length ? projectsCacheAll : projectsCache);
|
||||||
return projectsCache;
|
return projectsCache;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 对话页等项目选择器:确保列表已拉取(去重并发请求) */
|
/** 对话页等项目选择器:确保全量列表已拉取(去重并发请求) */
|
||||||
async function ensureProjectsLoaded(force) {
|
async function ensureProjectsLoaded(force) {
|
||||||
if (!force && _projectsListReady) return projectsCache;
|
if (!force && _projectsListReady) return projectsCacheAll;
|
||||||
if (!force && _projectsFetchPromise) return _projectsFetchPromise;
|
if (!force && _projectsFetchPromise) return _projectsFetchPromise;
|
||||||
_projectsFetchPromise = fetchProjectsList(false)
|
_projectsFetchPromise = fetchAllProjects(false)
|
||||||
|
.then((list) => {
|
||||||
|
projectsCacheAll = list;
|
||||||
|
rebuildProjectNameMap(projectsCacheAll);
|
||||||
|
_projectsListReady = true;
|
||||||
|
return projectsCacheAll;
|
||||||
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
_projectsListReady = false;
|
_projectsListReady = false;
|
||||||
throw e;
|
throw e;
|
||||||
@@ -204,9 +310,10 @@ async function ensureDefaultActiveProjectForNewChat() {
|
|||||||
await ensureProjectsLoaded();
|
await ensureProjectsLoaded();
|
||||||
const cur = getActiveProjectId();
|
const cur = getActiveProjectId();
|
||||||
if (cur && isActiveChatProjectId(cur)) return cur;
|
if (cur && isActiveChatProjectId(cur)) return cur;
|
||||||
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
const first =
|
const first =
|
||||||
projectsCache.find((p) => p.pinned && p.status !== 'archived') ||
|
source.find((p) => p.pinned && p.status !== 'archived') ||
|
||||||
projectsCache.find((p) => p.status !== 'archived');
|
source.find((p) => p.status !== 'archived');
|
||||||
if (first) {
|
if (first) {
|
||||||
setActiveProjectId(first.id);
|
setActiveProjectId(first.id);
|
||||||
return first.id;
|
return first.id;
|
||||||
@@ -238,6 +345,8 @@ async function initProjectsPage() {
|
|||||||
initProjectsModalEscape();
|
initProjectsModalEscape();
|
||||||
syncProjectsModalBodyLock();
|
syncProjectsModalBodyLock();
|
||||||
updateProjectsDetailVisibility();
|
updateProjectsDetailVisibility();
|
||||||
|
projectsListPagination.pageSize = getProjectsListPageSize();
|
||||||
|
renderProjectsPagination();
|
||||||
await loadProjectsList();
|
await loadProjectsList();
|
||||||
if (!currentProjectId && projectsCache.length) {
|
if (!currentProjectId && projectsCache.length) {
|
||||||
const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id');
|
const fromHash = new URLSearchParams(window.location.hash.split('?')[1] || '').get('id');
|
||||||
@@ -250,8 +359,19 @@ async function initProjectsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadProjectsList() {
|
async function loadProjectsList() {
|
||||||
|
_projectsListReady = false;
|
||||||
|
projectsCacheAll = [];
|
||||||
|
projectsListPagination.pageSize = getProjectsListPageSize();
|
||||||
await fetchProjectsList();
|
await fetchProjectsList();
|
||||||
renderProjectsSidebar();
|
renderProjectsSidebar();
|
||||||
|
renderProjectsPagination();
|
||||||
|
try {
|
||||||
|
projectsCacheAll = await fetchAllProjects();
|
||||||
|
rebuildProjectNameMap(projectsCacheAll);
|
||||||
|
_projectsListReady = true;
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(e);
|
||||||
|
}
|
||||||
if (typeof refreshChatProjectSelector === 'function') {
|
if (typeof refreshChatProjectSelector === 'function') {
|
||||||
refreshChatProjectSelector();
|
refreshChatProjectSelector();
|
||||||
}
|
}
|
||||||
@@ -277,7 +397,7 @@ function updateProjectsDetailVisibility() {
|
|||||||
|
|
||||||
function updateProjectsListCount() {
|
function updateProjectsListCount() {
|
||||||
const el = document.getElementById('projects-list-count');
|
const el = document.getElementById('projects-list-count');
|
||||||
if (el) el.textContent = String(projectsCache.length);
|
if (el) el.textContent = String(projectsListPagination.total || projectsCache.length);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */
|
/** 事实分类 → 徽章样式(与 fact_template.go 常量对齐) */
|
||||||
@@ -385,26 +505,97 @@ function getProjectsListFilter() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function filterProjectsList() {
|
function filterProjectsList() {
|
||||||
|
if (_projectsListSearchDebounce) clearTimeout(_projectsListSearchDebounce);
|
||||||
|
_projectsListSearchDebounce = setTimeout(() => {
|
||||||
|
_projectsListSearchDebounce = null;
|
||||||
|
const q = getProjectsListFilter();
|
||||||
|
projectsListPagination.page = 1;
|
||||||
|
fetchProjectsList(undefined, { page: 1, search: q })
|
||||||
|
.then(() => {
|
||||||
renderProjectsSidebar();
|
renderProjectsSidebar();
|
||||||
|
renderProjectsPagination();
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn(e));
|
||||||
|
}, 280);
|
||||||
|
}
|
||||||
|
|
||||||
|
function goProjectsPage(page) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil((projectsListPagination.total || 0) / projectsListPagination.pageSize) || 1);
|
||||||
|
const next = Math.min(Math.max(1, page), totalPages);
|
||||||
|
if (next === projectsListPagination.page) return;
|
||||||
|
fetchProjectsList(undefined, { page: next })
|
||||||
|
.then(() => {
|
||||||
|
renderProjectsSidebar();
|
||||||
|
renderProjectsPagination();
|
||||||
|
const listEl = document.getElementById('projects-list');
|
||||||
|
if (listEl) listEl.scrollTop = 0;
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeProjectsPageSize() {
|
||||||
|
const sel = document.getElementById('projects-page-size-pagination');
|
||||||
|
const newSize = sel ? parseInt(sel.value, 10) : 50;
|
||||||
|
if (![20, 50, 100].includes(newSize)) return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(PROJECTS_LIST_PAGE_SIZE_KEY, String(newSize));
|
||||||
|
} catch (e) { /* ignore */ }
|
||||||
|
projectsListPagination.pageSize = newSize;
|
||||||
|
projectsListPagination.page = 1;
|
||||||
|
fetchProjectsList(undefined, { page: 1, pageSize: newSize })
|
||||||
|
.then(() => {
|
||||||
|
renderProjectsSidebar();
|
||||||
|
renderProjectsPagination();
|
||||||
|
})
|
||||||
|
.catch((e) => console.warn(e));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderProjectsPagination() {
|
||||||
|
const el = document.getElementById('projects-pagination');
|
||||||
|
if (!el) return;
|
||||||
|
const { page, pageSize, total } = projectsListPagination;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(total / pageSize) || 1);
|
||||||
|
const navDisabled = total === 0 || totalPages <= 1;
|
||||||
|
el.hidden = false;
|
||||||
|
const start = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||||
|
const end = total === 0 ? 0 : Math.min(page * pageSize, total);
|
||||||
|
const infoText = tpFmt('projects.paginationRange', `${start}-${end}/${total}`, { start, end, total });
|
||||||
|
const pageText = tpFmt('projects.paginationPage', `${page}/${totalPages}`, { page, total: totalPages });
|
||||||
|
el.innerHTML = `
|
||||||
|
<div class="sidebar-list-pagination-inner sidebar-list-pagination-inner--compact">
|
||||||
|
<span class="pagination-info">${escapeHtml(infoText)}</span>
|
||||||
|
<div class="pagination-controls">
|
||||||
|
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page - 1})" ${page <= 1 || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationPrev'))}" aria-label="${escapeHtml(tp('projects.paginationPrev'))}">‹</button>
|
||||||
|
<span class="pagination-page">${escapeHtml(pageText)}</span>
|
||||||
|
<button type="button" class="btn-icon-pagination" onclick="goProjectsPage(${page + 1})" ${page >= totalPages || navDisabled ? 'disabled' : ''} title="${escapeHtml(tp('projects.paginationNext'))}" aria-label="${escapeHtml(tp('projects.paginationNext'))}">›</button>
|
||||||
|
</div>
|
||||||
|
<label class="pagination-page-size">
|
||||||
|
${escapeHtml(tp('projects.paginationPerPage'))}
|
||||||
|
<select id="projects-page-size-pagination" onchange="changeProjectsPageSize()">
|
||||||
|
<option value="20" ${pageSize === 20 ? 'selected' : ''}>20</option>
|
||||||
|
<option value="50" ${pageSize === 50 ? 'selected' : ''}>50</option>
|
||||||
|
<option value="100" ${pageSize === 100 ? 'selected' : ''}>100</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderProjectsSidebar() {
|
function renderProjectsSidebar() {
|
||||||
const el = document.getElementById('projects-list');
|
const el = document.getElementById('projects-list');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
updateProjectsListCount();
|
updateProjectsListCount();
|
||||||
const q = getProjectsListFilter();
|
const list = projectsCache;
|
||||||
const list = q
|
|
||||||
? projectsCache.filter((p) => (p.name || '').toLowerCase().includes(q) || (p.description || '').toLowerCase().includes(q))
|
|
||||||
: projectsCache;
|
|
||||||
if (!projectsCache.length) {
|
if (!projectsCache.length) {
|
||||||
el.innerHTML =
|
el.innerHTML =
|
||||||
`<div class="projects-empty">${escapeHtml(tp('projects.noProjects'))}<br><button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">${escapeHtml(tp('projects.newProject'))}</button></div>`;
|
`<div class="projects-empty">${escapeHtml(tp('projects.noProjects'))}<br><button type="button" class="btn-primary btn-small projects-empty-btn" onclick="showNewProjectModal()">${escapeHtml(tp('projects.newProject'))}</button></div>`;
|
||||||
updateProjectsDetailVisibility();
|
updateProjectsDetailVisibility();
|
||||||
|
renderProjectsPagination();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`;
|
el.innerHTML = `<div class="projects-empty">${escapeHtml(tp('projects.noMatchingProjects'))}</div>`;
|
||||||
updateProjectsDetailVisibility();
|
updateProjectsDetailVisibility();
|
||||||
|
renderProjectsPagination();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
el.innerHTML = list.map((p) => {
|
el.innerHTML = list.map((p) => {
|
||||||
@@ -574,8 +765,11 @@ async function loadProjectFacts() {
|
|||||||
const vulnLink = f.related_vulnerability_id
|
const vulnLink = f.related_vulnerability_id
|
||||||
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
|
? `<span class="projects-fact-vuln-link" title="${escapeHtml(tp('projects.relatedVulnIdTitle'))}">${escapeHtml(f.related_vulnerability_id.slice(0, 8))}…</span>`
|
||||||
: '';
|
: '';
|
||||||
|
const pinBadge = f.pinned
|
||||||
|
? `<span class="projects-list-item-badge" title="${escapeHtml(tp('projects.pinned'))}">${escapeHtml(tp('projects.pinned'))}</span>`
|
||||||
|
: '';
|
||||||
return `<tr>
|
return `<tr>
|
||||||
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${vulnLink}</td>
|
<td class="cell-fact-key"><code class="projects-fact-key-chip" title="${keyEsc}">${keyEsc}</code>${pinBadge}${vulnLink}</td>
|
||||||
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
<td class="cell-fact-category">${formatCategoryBadge(f.category)}</td>
|
||||||
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
<td class="cell-summary" title="${escapeHtml(f.summary)}">${escapeHtml(f.summary)}</td>
|
||||||
<td>${formatFactBodyBadge(f)}</td>
|
<td>${formatFactBodyBadge(f)}</td>
|
||||||
@@ -678,7 +872,6 @@ async function viewProjectFactBody(factKey) {
|
|||||||
];
|
];
|
||||||
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
|
if (f.related_vulnerability_id) metaParts.push(tpFmt('projects.factMetaRelatedVuln', `Related vulnerability: ${f.related_vulnerability_id}`, { value: f.related_vulnerability_id }));
|
||||||
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
|
if (f.source_conversation_id) metaParts.push(tpFmt('projects.factMetaSourceConversation', `Source conversation: ${f.source_conversation_id}`, { value: f.source_conversation_id }));
|
||||||
if (f.supersedes_fact_id) metaParts.push(tp('projects.factMetaHasPrevious'));
|
|
||||||
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
|
document.getElementById('fact-detail-meta').textContent = metaParts.join(' · ');
|
||||||
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
|
document.getElementById('fact-detail-body').textContent = f.body || tp('projects.emptyBody');
|
||||||
const warnEl = document.getElementById('fact-detail-sparse-warn');
|
const warnEl = document.getElementById('fact-detail-sparse-warn');
|
||||||
@@ -691,33 +884,6 @@ async function viewProjectFactBody(factKey) {
|
|||||||
warnEl.textContent = '';
|
warnEl.textContent = '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const prevWrap = document.getElementById('fact-detail-prev-wrap');
|
|
||||||
if (prevWrap) {
|
|
||||||
prevWrap.hidden = true;
|
|
||||||
if (f.id && f.supersedes_fact_id) {
|
|
||||||
try {
|
|
||||||
const prevRes = await apiFetch(
|
|
||||||
`/api/projects/${currentProjectId}/facts/${encodeURIComponent(f.id)}/previous-version`,
|
|
||||||
);
|
|
||||||
if (prevRes.ok) {
|
|
||||||
const prev = await prevRes.json();
|
|
||||||
prevWrap.hidden = false;
|
|
||||||
document.getElementById('fact-detail-prev-meta').textContent = tpFmt(
|
|
||||||
'projects.factPreviousMeta',
|
|
||||||
`Archived at ${formatProjectTime(prev.archived_at)} · Summary: ${prev.summary || '—'} · Confidence: ${prev.confidence || '—'}`,
|
|
||||||
{
|
|
||||||
time: formatProjectTime(prev.archived_at),
|
|
||||||
summary: prev.summary || '—',
|
|
||||||
confidence: prev.confidence || '—',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
document.getElementById('fact-detail-prev-body').textContent = prev.body || tp('projects.emptyBody');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.warn(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
|
const linkBtn = document.getElementById('fact-detail-link-vuln-btn');
|
||||||
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
|
const createBtn = document.getElementById('fact-detail-create-vuln-btn');
|
||||||
if (linkBtn) linkBtn.hidden = false;
|
if (linkBtn) linkBtn.hidden = false;
|
||||||
@@ -1165,6 +1331,8 @@ function resetFactModalForm() {
|
|||||||
document.getElementById('fact-modal-summary').value = '';
|
document.getElementById('fact-modal-summary').value = '';
|
||||||
document.getElementById('fact-modal-body').value = '';
|
document.getElementById('fact-modal-body').value = '';
|
||||||
document.getElementById('fact-modal-confidence').value = 'tentative';
|
document.getElementById('fact-modal-confidence').value = 'tentative';
|
||||||
|
const pinEl = document.getElementById('fact-modal-pinned');
|
||||||
|
if (pinEl) pinEl.checked = false;
|
||||||
const rel = document.getElementById('fact-modal-related-vuln');
|
const rel = document.getElementById('fact-modal-related-vuln');
|
||||||
if (rel) rel.value = '';
|
if (rel) rel.value = '';
|
||||||
updateFactFormHints();
|
updateFactFormHints();
|
||||||
@@ -1198,6 +1366,8 @@ function fillFactModalForm(f) {
|
|||||||
}
|
}
|
||||||
const rel = document.getElementById('fact-modal-related-vuln');
|
const rel = document.getElementById('fact-modal-related-vuln');
|
||||||
if (rel) rel.value = f.related_vulnerability_id || '';
|
if (rel) rel.value = f.related_vulnerability_id || '';
|
||||||
|
const pinEl = document.getElementById('fact-modal-pinned');
|
||||||
|
if (pinEl) pinEl.checked = !!f.pinned;
|
||||||
updateFactFormHints();
|
updateFactFormHints();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1242,6 +1412,7 @@ async function saveFactModal() {
|
|||||||
summary,
|
summary,
|
||||||
body,
|
body,
|
||||||
confidence: document.getElementById('fact-modal-confidence').value,
|
confidence: document.getElementById('fact-modal-confidence').value,
|
||||||
|
pinned: !!document.getElementById('fact-modal-pinned')?.checked,
|
||||||
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
|
related_vulnerability_id: document.getElementById('fact-modal-related-vuln')?.value?.trim() || '',
|
||||||
};
|
};
|
||||||
const editId = window._factModalEditId;
|
const editId = window._factModalEditId;
|
||||||
@@ -1337,7 +1508,8 @@ function getChatProjectSelection() {
|
|||||||
|
|
||||||
function isActiveChatProjectId(id) {
|
function isActiveChatProjectId(id) {
|
||||||
if (!id) return false;
|
if (!id) return false;
|
||||||
return projectsCache.some((p) => p.id === id && p.status !== 'archived');
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
|
return source.some((p) => p.id === id && p.status !== 'archived');
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
/** 用于 UI:无效/已删除/无可用项目时视为未绑定 */
|
||||||
@@ -1392,7 +1564,8 @@ function renderChatProjectPanelList() {
|
|||||||
const list = document.getElementById('chat-project-list');
|
const list = document.getElementById('chat-project-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
const selected = resolveChatProjectSelection();
|
const selected = resolveChatProjectSelection();
|
||||||
const activeProjects = projectsCache.filter((p) => p.status !== 'archived');
|
const source = projectsCacheAll.length ? projectsCacheAll : projectsCache;
|
||||||
|
const activeProjects = source.filter((p) => p.status !== 'archived');
|
||||||
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
|
const items = [{ id: '', name: tp('projects.noProject'), description: tp('projects.noProjectDescription') }, ...activeProjects];
|
||||||
if (!items.length) {
|
if (!items.length) {
|
||||||
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
|
list.innerHTML = `<div class="chat-project-panel-empty">${escapeHtml(tp('projects.noProjectsClickCreate'))}</div>`;
|
||||||
@@ -1535,6 +1708,7 @@ function initChatProjectSelector() {
|
|||||||
window._projectsLanguageListenerBound = true;
|
window._projectsLanguageListenerBound = true;
|
||||||
document.addEventListener('languagechange', () => {
|
document.addEventListener('languagechange', () => {
|
||||||
renderProjectsSidebar();
|
renderProjectsSidebar();
|
||||||
|
renderProjectsPagination();
|
||||||
updateChatProjectButtonLabel();
|
updateChatProjectButtonLabel();
|
||||||
const panel = document.getElementById('chat-project-panel');
|
const panel = document.getElementById('chat-project-panel');
|
||||||
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
if (panel && panel.style.display === 'flex') renderChatProjectPanelList();
|
||||||
@@ -1594,6 +1768,10 @@ window.restoreProjectFactByKey = restoreProjectFactByKey;
|
|||||||
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
|
window.openVulnerabilitiesForProject = openVulnerabilitiesForProject;
|
||||||
window.openVulnerabilityDetail = openVulnerabilityDetail;
|
window.openVulnerabilityDetail = openVulnerabilityDetail;
|
||||||
window.filterProjectsList = filterProjectsList;
|
window.filterProjectsList = filterProjectsList;
|
||||||
|
window.goProjectsPage = goProjectsPage;
|
||||||
|
window.changeProjectsPageSize = changeProjectsPageSize;
|
||||||
|
window.parseProjectsListResponse = parseProjectsListResponse;
|
||||||
|
window.fetchAllProjects = fetchAllProjects;
|
||||||
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
|
window.debouncedLoadProjectFacts = debouncedLoadProjectFacts;
|
||||||
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
|
window.debouncedLoadProjectVulnerabilities = debouncedLoadProjectVulnerabilities;
|
||||||
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
|
window.loadProjectVulnerabilities = loadProjectVulnerabilities;
|
||||||
|
|||||||
@@ -56,8 +56,9 @@ function initRouter() {
|
|||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
if (hash) {
|
if (hash) {
|
||||||
const hashParts = hash.split('?');
|
const hashParts = hash.split('?');
|
||||||
const pageId = hashParts[0];
|
let pageId = hashParts[0];
|
||||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
if (pageId === 'c2') pageId = 'c2-listeners';
|
||||||
|
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'projects', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'tasks', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||||
switchPage(pageId);
|
switchPage(pageId);
|
||||||
if (pageId === 'chat') {
|
if (pageId === 'chat') {
|
||||||
scheduleChatConversationFromHash(500);
|
scheduleChatConversationFromHash(500);
|
||||||
@@ -464,7 +465,6 @@ async function initPage(pageId) {
|
|||||||
loadMarkdownAgents();
|
loadMarkdownAgents();
|
||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case 'c2':
|
|
||||||
case 'c2-listeners':
|
case 'c2-listeners':
|
||||||
case 'c2-sessions':
|
case 'c2-sessions':
|
||||||
case 'c2-tasks':
|
case 'c2-tasks':
|
||||||
@@ -494,9 +494,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
const hash = window.location.hash.slice(1);
|
const hash = window.location.hash.slice(1);
|
||||||
// 处理带参数的hash(如 chat?conversation=xxx)
|
// 处理带参数的hash(如 chat?conversation=xxx)
|
||||||
const hashParts = hash.split('?');
|
const hashParts = hash.split('?');
|
||||||
const pageId = hashParts[0];
|
let pageId = hashParts[0];
|
||||||
|
|
||||||
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
if (pageId === 'c2') pageId = 'c2-listeners';
|
||||||
|
if (pageId && ['dashboard', 'chat', 'hitl', 'info-collect', 'tasks', 'vulnerabilities', 'webshell', 'chat-files', 'mcp-monitor', 'mcp-management', 'knowledge-management', 'knowledge-retrieval-logs', 'roles-management', 'skills-monitor', 'skills-management', 'agents-management', 'settings', 'c2-listeners', 'c2-sessions', 'c2-tasks', 'c2-payloads', 'c2-events', 'c2-profiles'].includes(pageId)) {
|
||||||
switchPage(pageId);
|
switchPage(pageId);
|
||||||
if (pageId === 'chat') {
|
if (pageId === 'chat') {
|
||||||
scheduleChatConversationFromHash(200);
|
scheduleChatConversationFromHash(200);
|
||||||
|
|||||||
@@ -61,21 +61,25 @@ window.syncC2NavOnceFromServer = async function syncC2NavOnceFromServer() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 根据 C2 是否启用显示主导航 C2 入口与仪表盘 C2 区块(与 /api/config 的 c2.enabled 一致)
|
// 根据 C2 是否启用显示主导航 C2 入口与仪表盘接入概览中的 C2 子块(与 /api/config 的 c2.enabled 一致)
|
||||||
function syncC2NavFromConfig(cfg) {
|
function syncC2NavFromConfig(cfg) {
|
||||||
const on = cfg && cfg.c2 && cfg.c2.enabled !== false;
|
const on = cfg && cfg.c2 && cfg.c2.enabled !== false;
|
||||||
const nav = document.getElementById('nav-c2');
|
const nav = document.getElementById('nav-c2');
|
||||||
if (nav) {
|
if (nav) {
|
||||||
nav.style.display = on ? '' : 'none';
|
nav.style.display = on ? '' : 'none';
|
||||||
}
|
}
|
||||||
const dash = document.getElementById('dashboard-section-c2');
|
const c2Tab = document.getElementById('dashboard-access-tab-c2');
|
||||||
if (dash) {
|
if (c2Tab) {
|
||||||
if (!on) {
|
if (!on) {
|
||||||
dash.hidden = true;
|
c2Tab.hidden = true;
|
||||||
} else {
|
} else {
|
||||||
dash.removeAttribute('hidden');
|
c2Tab.removeAttribute('hidden');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
window.__c2Enabled = on;
|
||||||
|
if (typeof syncDashboardAccessTabs === 'function') {
|
||||||
|
syncDashboardAccessTabs();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 切换设置分类
|
// 切换设置分类
|
||||||
|
|||||||
+10
-3
@@ -819,12 +819,19 @@ async function refreshBatchProjectSelectOptions() {
|
|||||||
projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`;
|
projectSelect.innerHTML = `<option value="">${escapeHtml(noneLabel)}</option>`;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await apiFetch('/api/projects?status=active&limit=200');
|
let list = [];
|
||||||
|
if (typeof fetchAllProjects === 'function') {
|
||||||
|
list = await fetchAllProjects(false);
|
||||||
|
} else {
|
||||||
|
const response = await apiFetch('/api/projects?status=active&limit=500');
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(_t('projects.loadProjectsFailed'));
|
throw new Error(_t('projects.loadProjectsFailed'));
|
||||||
}
|
}
|
||||||
const projects = await response.json();
|
const data = await response.json();
|
||||||
const list = Array.isArray(projects) ? projects : [];
|
list = typeof parseProjectsListResponse === 'function'
|
||||||
|
? parseProjectsListResponse(data).items
|
||||||
|
: (Array.isArray(data) ? data : (data.projects || []));
|
||||||
|
}
|
||||||
const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '';
|
const activeProjectId = typeof getActiveProjectId === 'function' ? getActiveProjectId() || '' : '';
|
||||||
|
|
||||||
list.forEach((project) => {
|
list.forEach((project) => {
|
||||||
|
|||||||
@@ -855,11 +855,6 @@ function renderVulnerabilities(vulnerabilities) {
|
|||||||
if (typeof window.applyTranslations === 'function') {
|
if (typeof window.applyTranslations === 'function') {
|
||||||
window.applyTranslations(listContainer);
|
window.applyTranslations(listContainer);
|
||||||
}
|
}
|
||||||
// 清空分页信息
|
|
||||||
const paginationContainer = document.getElementById('vulnerability-pagination');
|
|
||||||
if (paginationContainer) {
|
|
||||||
paginationContainer.innerHTML = '';
|
|
||||||
}
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -960,12 +955,6 @@ function renderVulnerabilityPagination() {
|
|||||||
|
|
||||||
const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination;
|
const { currentPage, totalPages, total, pageSize } = vulnerabilityPagination;
|
||||||
|
|
||||||
// 如果没有数据,不显示分页控件
|
|
||||||
if (total === 0) {
|
|
||||||
paginationContainer.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算显示范围
|
// 计算显示范围
|
||||||
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
const start = total === 0 ? 0 : (currentPage - 1) * pageSize + 1;
|
||||||
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
const end = total === 0 ? 0 : Math.min(currentPage * pageSize, total);
|
||||||
@@ -1052,13 +1041,23 @@ async function populateVulnerabilityModalProjectSelect(selectedId) {
|
|||||||
const sel = document.getElementById('vulnerability-project-id');
|
const sel = document.getElementById('vulnerability-project-id');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/projects?limit=200');
|
let list = [];
|
||||||
|
if (typeof fetchAllProjects === 'function') {
|
||||||
|
list = await fetchAllProjects();
|
||||||
|
} else {
|
||||||
|
const res = await apiFetch('/api/projects?limit=500');
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
const list = await res.json();
|
const data = await res.json();
|
||||||
|
list = typeof parseProjectsListResponse === 'function'
|
||||||
|
? parseProjectsListResponse(data).items
|
||||||
|
: (Array.isArray(data) ? data : (data.projects || []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (list.length) {
|
||||||
if (typeof rebuildProjectNameMap === 'function') {
|
if (typeof rebuildProjectNameMap === 'function') {
|
||||||
rebuildProjectNameMap(list);
|
rebuildProjectNameMap(list);
|
||||||
} else if (typeof projectNameById !== 'undefined') {
|
} else if (typeof projectNameById !== 'undefined') {
|
||||||
(list || []).forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
list.forEach((p) => { if (p.id) projectNameById[p.id] = p.name || p.id; });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -1722,9 +1721,17 @@ async function refreshVulnerabilityProjectFilter() {
|
|||||||
const sel = document.getElementById('vulnerability-project-filter');
|
const sel = document.getElementById('vulnerability-project-filter');
|
||||||
if (!sel) return;
|
if (!sel) return;
|
||||||
try {
|
try {
|
||||||
const res = await apiFetch('/api/projects?limit=200');
|
let list = [];
|
||||||
|
if (typeof fetchAllProjects === 'function') {
|
||||||
|
list = await fetchAllProjects(true);
|
||||||
|
} else {
|
||||||
|
const res = await apiFetch('/api/projects?limit=500');
|
||||||
if (!res.ok) return;
|
if (!res.ok) return;
|
||||||
const list = await res.json();
|
const data = await res.json();
|
||||||
|
list = typeof parseProjectsListResponse === 'function'
|
||||||
|
? parseProjectsListResponse(data).items
|
||||||
|
: (Array.isArray(data) ? data : (data.projects || []));
|
||||||
|
}
|
||||||
if (typeof rebuildProjectNameMap === 'function') {
|
if (typeof rebuildProjectNameMap === 'function') {
|
||||||
rebuildProjectNameMap(list);
|
rebuildProjectNameMap(list);
|
||||||
} else if (typeof projectNameById !== 'undefined') {
|
} else if (typeof projectNameById !== 'undefined') {
|
||||||
|
|||||||
+74
-114
@@ -221,7 +221,6 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<div class="nav-submenu" id="submenu-c2">
|
<div class="nav-submenu" id="submenu-c2">
|
||||||
<div class="nav-submenu-item" data-page="c2" onclick="switchPage('c2')" data-i18n="nav.c2Manage">C2 管理</div>
|
|
||||||
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
|
<div class="nav-submenu-item" data-page="c2-listeners" onclick="switchPage('c2-listeners')" data-i18n="nav.c2Listeners">监听器</div>
|
||||||
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
|
<div class="nav-submenu-item" data-page="c2-sessions" onclick="switchPage('c2-sessions')" data-i18n="nav.c2Sessions">会话</div>
|
||||||
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
|
<div class="nav-submenu-item" data-page="c2-tasks" onclick="switchPage('c2-tasks')" data-i18n="nav.c2Tasks">任务</div>
|
||||||
@@ -574,76 +573,59 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="dashboard-section dashboard-section-recent-vulns">
|
<section class="dashboard-section dashboard-section-recent-feed">
|
||||||
<div class="dashboard-section-header">
|
<div class="dashboard-section-header dashboard-section-header--tabs">
|
||||||
<h3 class="dashboard-section-title" data-i18n="dashboard.recentVulns">最近漏洞</h3>
|
<nav class="dashboard-feed-tabs" role="tablist" aria-label="最近漏洞与近期事实">
|
||||||
<a class="dashboard-section-link" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
<button type="button" class="dashboard-feed-tab is-active" role="tab" id="dashboard-feed-tab-vulns" aria-selected="true" aria-controls="dashboard-feed-panel-vulns" onclick="switchDashboardFeedTab('vulns')" data-i18n="dashboard.recentVulns">最近漏洞</button>
|
||||||
|
<button type="button" class="dashboard-feed-tab" role="tab" id="dashboard-feed-tab-facts" aria-selected="false" aria-controls="dashboard-feed-panel-facts" onclick="switchDashboardFeedTab('facts')" data-i18n="dashboard.recentFacts">近期事实</button>
|
||||||
|
</nav>
|
||||||
|
<a class="dashboard-section-link" id="dashboard-feed-view-all" onclick="switchPage('vulnerabilities')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-feed-panel" id="dashboard-feed-panel-vulns" role="tabpanel" aria-labelledby="dashboard-feed-tab-vulns">
|
||||||
<div class="dashboard-recent-vulns" id="dashboard-recent-vulns">
|
<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 class="dashboard-recent-vulns-empty" id="dashboard-recent-vulns-empty" data-i18n="dashboard.noVulnYet">暂无最近漏洞</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
<!-- C2 概览:介于「最近漏洞」与「批量任务队列」之间 -->
|
|
||||||
<section class="dashboard-section dashboard-section-c2" id="dashboard-section-c2" hidden>
|
|
||||||
<div class="dashboard-section-header">
|
|
||||||
<h3 class="dashboard-section-title" data-i18n="dashboard.c2OverviewTitle">C2 概览</h3>
|
|
||||||
<a class="dashboard-section-link" onclick="switchPage('c2')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-c2-strip">
|
<div class="dashboard-feed-panel" id="dashboard-feed-panel-facts" role="tabpanel" aria-labelledby="dashboard-feed-tab-facts" hidden>
|
||||||
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器">
|
<div class="dashboard-recent-facts" id="dashboard-recent-facts">
|
||||||
<span class="dashboard-c2-stat-value" id="dashboard-c2-listeners-running">-</span>
|
<div class="dashboard-recent-facts-empty" id="dashboard-recent-facts-empty" data-i18n="dashboard.noFactsYet">暂无近期事实</div>
|
||||||
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
|
|
||||||
<span class="dashboard-c2-stat-value" id="dashboard-c2-sessions-online">-</span>
|
|
||||||
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
|
|
||||||
</div>
|
|
||||||
<div class="dashboard-c2-stat" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
|
|
||||||
<span class="dashboard-c2-stat-value" id="dashboard-c2-tasks-pending">-</span>
|
|
||||||
<span class="dashboard-c2-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<section class="dashboard-section dashboard-section-overview">
|
<!-- 接入概览:C2 / WebShell Tab 切换(样式同「最近漏洞 / 近期事实」) -->
|
||||||
<div class="dashboard-section-header">
|
<section class="dashboard-section dashboard-section-access" id="dashboard-section-access" hidden>
|
||||||
<h3 class="dashboard-section-title" data-i18n="dashboard.batchQueues">批量任务队列</h3>
|
<div class="dashboard-section-header dashboard-section-header--tabs">
|
||||||
<a class="dashboard-section-link" onclick="switchPage('tasks')" data-i18n="dashboard.viewAll">查看全部 →</a>
|
<nav class="dashboard-feed-tabs" id="dashboard-access-tabs" role="tablist" aria-label="C2 与 WebShell" data-i18n="dashboard.accessTabsAria" data-i18n-attr="aria-label">
|
||||||
|
<button type="button" class="dashboard-feed-tab is-active" role="tab" id="dashboard-access-tab-c2" aria-selected="true" aria-controls="dashboard-access-panel-c2" onclick="switchDashboardAccessTab('c2')" data-i18n="nav.c2">C2</button>
|
||||||
|
<button type="button" class="dashboard-feed-tab" role="tab" id="dashboard-access-tab-webshell" aria-selected="false" aria-controls="dashboard-access-panel-webshell" onclick="switchDashboardAccessTab('webshell')" data-i18n="dashboard.webshellLabel">WebShell</button>
|
||||||
|
</nav>
|
||||||
|
<a class="dashboard-section-link" id="dashboard-access-view-all" onclick="switchPage('c2-listeners')" data-i18n="dashboard.c2GoManage">进入 C2 →</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-overview-list">
|
<div class="dashboard-feed-panel" id="dashboard-access-panel-c2" role="tabpanel" aria-labelledby="dashboard-access-tab-c2">
|
||||||
<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'); }">
|
<div class="dashboard-access-strip">
|
||||||
<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>
|
<div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-listeners')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-listeners'); }" data-i18n="dashboard.c2ClickListeners" data-i18n-attr="title" title="查看监听器">
|
||||||
<div class="dashboard-overview-content">
|
<span class="dashboard-access-stat-value" id="dashboard-c2-listeners-running">-</span>
|
||||||
<div class="dashboard-overview-header">
|
<span class="dashboard-access-stat-label" data-i18n="dashboard.c2ListenersRunning">运行中监听器</span>
|
||||||
<span class="dashboard-overview-label" data-i18n="dashboard.batchQueues">批量任务队列</span>
|
|
||||||
<span class="dashboard-overview-total" id="dashboard-batch-total">-</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-overview-stats">
|
<div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-sessions')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-sessions'); }" data-i18n="dashboard.c2ClickSessions" data-i18n-attr="title" title="查看会话">
|
||||||
<span class="dashboard-overview-stat dashboard-overview-stat-pending">
|
<span class="dashboard-access-stat-value" id="dashboard-c2-sessions-online">-</span>
|
||||||
<span class="dashboard-overview-stat-badge badge-pending"></span>
|
<span class="dashboard-access-stat-label" data-i18n="dashboard.c2SessionsOnline">在线会话</span>
|
||||||
<span class="dashboard-overview-stat-value" id="dashboard-batch-pending">-</span>
|
|
||||||
<span class="dashboard-overview-stat-label" data-i18n="dashboard.pending">待执行</span>
|
|
||||||
</span>
|
|
||||||
<span class="dashboard-overview-stat dashboard-overview-stat-running">
|
|
||||||
<span class="dashboard-overview-stat-badge badge-running"></span>
|
|
||||||
<span class="dashboard-overview-stat-value" id="dashboard-batch-running">-</span>
|
|
||||||
<span class="dashboard-overview-stat-label" data-i18n="dashboard.executing">执行中</span>
|
|
||||||
</span>
|
|
||||||
<span class="dashboard-overview-stat dashboard-overview-stat-done">
|
|
||||||
<span class="dashboard-overview-stat-badge badge-done"></span>
|
|
||||||
<span class="dashboard-overview-stat-value" id="dashboard-batch-done">-</span>
|
|
||||||
<span class="dashboard-overview-stat-label" data-i18n="dashboard.completed">已完成</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-overview-progress">
|
<div class="dashboard-access-stat dashboard-access-stat--c2" role="button" tabindex="0" onclick="switchPage('c2-tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('c2-tasks'); }" data-i18n="dashboard.c2ClickTasks" data-i18n-attr="title" title="查看任务">
|
||||||
<div class="dashboard-overview-progress-bar">
|
<span class="dashboard-access-stat-value" id="dashboard-c2-tasks-pending">-</span>
|
||||||
<div class="dashboard-overview-progress-segment dashboard-overview-progress-pending" id="dashboard-batch-progress-pending" style="width: 0%"></div>
|
<span class="dashboard-access-stat-label" data-i18n="dashboard.c2TasksPending">待审 / 排队任务</span>
|
||||||
<div class="dashboard-overview-progress-segment dashboard-overview-progress-running" id="dashboard-batch-progress-running" style="width: 0%"></div>
|
|
||||||
<div class="dashboard-overview-progress-segment dashboard-overview-progress-done" id="dashboard-batch-progress-done" style="width: 0%"></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-feed-panel" id="dashboard-access-panel-webshell" role="tabpanel" aria-labelledby="dashboard-access-tab-webshell" hidden>
|
||||||
|
<div class="dashboard-access-strip dashboard-access-strip--webshell">
|
||||||
|
<div class="dashboard-access-stat dashboard-access-stat--webshell" role="button" tabindex="0" onclick="switchPage('webshell')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('webshell'); }" data-i18n="dashboard.webshellClickConnections" data-i18n-attr="title" title="查看连接">
|
||||||
|
<span class="dashboard-access-stat-value" id="dashboard-webshell-connections">-</span>
|
||||||
|
<span class="dashboard-access-stat-label" data-i18n="dashboard.webshellConnections">活跃连接</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="dashboard-webshell-recent" id="dashboard-webshell-recent" hidden></div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
<!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」),
|
<!-- 推荐操作:基于当前数据状态智能生成(如「修复 4 个待处理严重漏洞」「审批 2 个 HITL」),
|
||||||
比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 -->
|
比纯静态导航更有意义;当没有任何推荐时整个 section 隐藏 -->
|
||||||
@@ -656,6 +638,36 @@
|
|||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-side">
|
<div class="dashboard-side">
|
||||||
|
<section class="dashboard-section dashboard-section-batch-side">
|
||||||
|
<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-batch-side-body" role="button" tabindex="0" onclick="switchPage('tasks')" onkeydown="if(event.key==='Enter'||event.key===' ') { event.preventDefault(); switchPage('tasks'); }">
|
||||||
|
<div class="dashboard-batch-side-total" id="dashboard-batch-total">-</div>
|
||||||
|
<div class="dashboard-batch-side-stats">
|
||||||
|
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--pending">
|
||||||
|
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-pending">-</span>
|
||||||
|
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.pending">待执行</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--running">
|
||||||
|
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-running">-</span>
|
||||||
|
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.executing">执行中</span>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-batch-side-stat dashboard-batch-side-stat--done">
|
||||||
|
<span class="dashboard-batch-side-stat-value" id="dashboard-batch-done">-</span>
|
||||||
|
<span class="dashboard-batch-side-stat-label" data-i18n="dashboard.completed">已完成</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="dashboard-batch-side-progress" aria-hidden="true">
|
||||||
|
<div class="dashboard-batch-side-progress-bar">
|
||||||
|
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-pending" id="dashboard-batch-progress-pending" style="width: 0%"></div>
|
||||||
|
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-running" id="dashboard-batch-progress-running" style="width: 0%"></div>
|
||||||
|
<div class="dashboard-batch-side-progress-segment dashboard-batch-side-progress-done" id="dashboard-batch-progress-done" style="width: 0%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
<section class="dashboard-section dashboard-section-tools">
|
<section class="dashboard-section dashboard-section-tools">
|
||||||
<div class="dashboard-section-header">
|
<div class="dashboard-section-header">
|
||||||
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
|
<h3 class="dashboard-section-title" data-i18n="dashboard.toolsExecCount">工具执行次数</h3>
|
||||||
@@ -723,14 +735,6 @@
|
|||||||
<span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span>
|
<span class="dashboard-resource-label" data-i18n="dashboard.agentsLabel">Agents</span>
|
||||||
<span class="dashboard-resource-value" id="dashboard-resource-agents">-</span>
|
<span class="dashboard-resource-value" id="dashboard-resource-agents">-</span>
|
||||||
</a>
|
</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>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -817,6 +821,7 @@
|
|||||||
<div id="conversations-list" class="conversations-list"></div>
|
<div id="conversations-list" class="conversations-list"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="conversations-pagination" class="sidebar-list-pagination conversation-sidebar-pagination"></div>
|
||||||
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
|
<div id="chat-reasoning-wrapper" class="chat-reasoning-wrapper conversation-reasoning-card conversation-reasoning-collapsed" style="display: none;">
|
||||||
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
|
<button type="button" id="conversation-reasoning-toggle" class="conversation-reasoning-card-header" onclick="toggleConversationReasoningCard()" aria-expanded="false" aria-controls="conversation-reasoning-body" data-i18n="chat.reasoningCompactAria" data-i18n-attr="aria-label,title" data-i18n-skip-text="true" aria-label="模型推理选项" title="模型推理选项">
|
||||||
<div class="conversation-reasoning-heading">
|
<div class="conversation-reasoning-heading">
|
||||||
@@ -1455,6 +1460,7 @@
|
|||||||
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder">
|
<input type="search" id="projects-list-search" class="form-input" placeholder="搜索项目…" oninput="filterProjectsList()" autocomplete="off" data-i18n="projects.searchProjectsPlaceholder" data-i18n-attr="placeholder">
|
||||||
</div>
|
</div>
|
||||||
<div id="projects-list" class="projects-list"></div>
|
<div id="projects-list" class="projects-list"></div>
|
||||||
|
<div id="projects-pagination" class="sidebar-list-pagination projects-sidebar-pagination"></div>
|
||||||
</aside>
|
</aside>
|
||||||
<main class="projects-detail" id="projects-detail-main">
|
<main class="projects-detail" id="projects-detail-main">
|
||||||
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
<div class="projects-detail-placeholder" id="projects-detail-placeholder">
|
||||||
@@ -2005,51 +2011,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- C2 管理页面容器(各子页面通过 JS 动态渲染) -->
|
|
||||||
<div id="page-c2" class="page">
|
|
||||||
<div class="page-header">
|
|
||||||
<h2 data-i18n="c2.title">C2 管理</h2>
|
|
||||||
</div>
|
|
||||||
<div class="page-content" id="c2-content">
|
|
||||||
<div class="c2-layout">
|
|
||||||
<div id="c2-main" class="c2-main">
|
|
||||||
<div class="c2-welcome">
|
|
||||||
<div class="c2-welcome-icon">
|
|
||||||
<svg width="72" height="72" viewBox="0 0 24 24" fill="none" stroke="url(#c2-grad)" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
|
||||||
<defs><linearGradient id="c2-grad" x1="0" y1="0" x2="1" y2="1"><stop offset="0%" stop-color="#00d4ff"/><stop offset="100%" stop-color="#a855f7"/></linearGradient></defs>
|
|
||||||
<path d="M4.9 19.1C1 15.2 1 8.8 4.9 4.9"></path>
|
|
||||||
<path d="M7.8 16.2c-2.3-2.3-2.3-6.1 0-8.5"></path>
|
|
||||||
<circle cx="12" cy="12" r="2"></circle>
|
|
||||||
<path d="M16.2 7.8c2.3 2.3 2.3 6.1 0 8.5"></path>
|
|
||||||
<path d="M19.1 4.9C23 8.8 23 15.2 19.1 19"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<h3 data-i18n="c2.welcomeTitle">AI-Native C2 框架</h3>
|
|
||||||
<p data-i18n="c2.welcomeDesc">以 MCP 工具为一等公民,让 LLM 可以像调用 nmap 一样调用 C2 完成"上线 → 控制 → 任务 → 横向 → 清场"全流程</p>
|
|
||||||
<div class="c2-stats" id="c2-dashboard-stats">
|
|
||||||
<div class="c2-stat-item">
|
|
||||||
<span class="c2-stat-value" id="c2-stat-listeners">-</span>
|
|
||||||
<span class="c2-stat-label" data-i18n="c2.statListeners">运行中监听器</span>
|
|
||||||
</div>
|
|
||||||
<div class="c2-stat-item">
|
|
||||||
<span class="c2-stat-value" id="c2-stat-sessions">-</span>
|
|
||||||
<span class="c2-stat-label" data-i18n="c2.statSessions">在线会话</span>
|
|
||||||
</div>
|
|
||||||
<div class="c2-stat-item">
|
|
||||||
<span class="c2-stat-value" id="c2-stat-pending">-</span>
|
|
||||||
<span class="c2-stat-label" data-i18n="c2.statPending">待审任务</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="c2-actions">
|
|
||||||
<button class="btn-primary" onclick="switchPage('c2-listeners')" data-i18n="c2.goListeners">管理监听器</button>
|
|
||||||
<button class="btn-secondary" onclick="switchPage('c2-sessions')" data-i18n="c2.goSessions">查看会话</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- C2 监听器管理页面 -->
|
<!-- C2 监听器管理页面 -->
|
||||||
<div id="page-c2-listeners" class="page">
|
<div id="page-c2-listeners" class="page">
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
@@ -4260,6 +4221,11 @@
|
|||||||
<textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea>
|
<textarea id="fact-modal-body" class="form-input fact-modal-body-input" rows="14" placeholder="攻击链步骤、HTTP/命令 POC、响应现象、证据…" oninput="updateFactFormHints()"></textarea>
|
||||||
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
|
<p id="fact-modal-body-hint" class="projects-field-hint" role="status"></p>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="projects-form-field">
|
||||||
|
<label class="projects-filter-check projects-pin-toggle">
|
||||||
|
<input type="checkbox" id="fact-modal-pinned"> <span data-i18n="projects.pinFact">置顶事实(列表与黑板索引优先)</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
<div class="projects-form-field">
|
<div class="projects-form-field">
|
||||||
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
<label for="fact-modal-related-vuln" data-i18n="projects.relatedVulnIdLabel">关联漏洞 ID</label>
|
||||||
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
<input type="text" id="fact-modal-related-vuln" class="form-input" placeholder="可选" data-i18n="projects.optional" data-i18n-attr="placeholder">
|
||||||
@@ -4284,12 +4250,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="projects-modal-body">
|
<div class="projects-modal-body">
|
||||||
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
|
<p id="fact-detail-sparse-warn" class="projects-fact-sparse-warn" hidden></p>
|
||||||
<div id="fact-detail-prev-wrap" class="fact-detail-prev-wrap" hidden>
|
|
||||||
<h4 class="fact-detail-prev-title" data-i18n="projects.previousVersion">上一版本</h4>
|
|
||||||
<p id="fact-detail-prev-meta" class="projects-modal-subtitle"></p>
|
|
||||||
<pre id="fact-detail-prev-body" class="fact-detail-body fact-detail-body--muted"></pre>
|
|
||||||
</div>
|
|
||||||
<h4 class="fact-detail-current-title" data-i18n="projects.currentVersion">当前版本</h4>
|
|
||||||
<pre id="fact-detail-body" class="fact-detail-body"></pre>
|
<pre id="fact-detail-body" class="fact-detail-body"></pre>
|
||||||
</div>
|
</div>
|
||||||
<div class="projects-modal-footer projects-modal-footer--split">
|
<div class="projects-modal-footer projects-modal-footer--split">
|
||||||
|
|||||||
Reference in New Issue
Block a user