Add files via upload

1、修复删除知识项后总分类数统计错误:将 updateKnowledgeStats 中的 || 改为 != null 检查,并移除会错误更新统计的 updateKnowledgeStatsAfterDelete 调用。
2、为 MCP 状态监控页面添加了批量删除功能(复选框、全选、批量删除按钮)和每页显示数量配置(选择器位于分页控件左侧,设置保存到 localStorage)。
This commit is contained in:
公明
2025-12-31 19:20:58 +08:00
committed by GitHub
parent 24aa12cf33
commit b90a29fdd7
6 changed files with 457 additions and 3 deletions
+1
View File
@@ -448,6 +448,7 @@ func setupRoutes(
protected.GET("/monitor", monitorHandler.Monitor)
protected.GET("/monitor/execution/:id", monitorHandler.GetExecution)
protected.DELETE("/monitor/execution/:id", monitorHandler.DeleteExecution)
protected.DELETE("/monitor/executions", monitorHandler.DeleteExecutions)
protected.GET("/monitor/stats", monitorHandler.GetStats)
// 配置管理
+111
View File
@@ -281,6 +281,117 @@ func (db *DB) DeleteToolExecution(id string) error {
return nil
}
// DeleteToolExecutions 批量删除工具执行记录
func (db *DB) DeleteToolExecutions(ids []string) error {
if len(ids) == 0 {
return nil
}
// 构建 IN 查询的占位符
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := `DELETE FROM tool_executions WHERE id IN (` + strings.Join(placeholders, ",") + `)`
_, err := db.Exec(query, args...)
if err != nil {
db.logger.Error("批量删除工具执行记录失败", zap.Error(err), zap.Int("count", len(ids)))
return err
}
return nil
}
// GetToolExecutionsByIds 根据ID列表获取工具执行记录(用于批量删除前获取统计信息)
func (db *DB) GetToolExecutionsByIds(ids []string) ([]*mcp.ToolExecution, error) {
if len(ids) == 0 {
return []*mcp.ToolExecution{}, nil
}
// 构建 IN 查询的占位符
placeholders := make([]string, len(ids))
args := make([]interface{}, len(ids))
for i, id := range ids {
placeholders[i] = "?"
args[i] = id
}
query := `
SELECT id, tool_name, arguments, status, result, error, start_time, end_time, duration_ms
FROM tool_executions
WHERE id IN (` + strings.Join(placeholders, ",") + `)
`
rows, err := db.Query(query, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var executions []*mcp.ToolExecution
for rows.Next() {
var exec mcp.ToolExecution
var argsJSON string
var resultJSON sql.NullString
var errorText sql.NullString
var endTime sql.NullTime
var durationMs sql.NullInt64
err := rows.Scan(
&exec.ID,
&exec.ToolName,
&argsJSON,
&exec.Status,
&resultJSON,
&errorText,
&exec.StartTime,
&endTime,
&durationMs,
)
if err != nil {
db.logger.Warn("加载执行记录失败", zap.Error(err))
continue
}
// 解析参数
if err := json.Unmarshal([]byte(argsJSON), &exec.Arguments); err != nil {
db.logger.Warn("解析执行参数失败", zap.Error(err))
exec.Arguments = make(map[string]interface{})
}
// 解析结果
if resultJSON.Valid && resultJSON.String != "" {
var result mcp.ToolResult
if err := json.Unmarshal([]byte(resultJSON.String), &result); err != nil {
db.logger.Warn("解析执行结果失败", zap.Error(err))
} else {
exec.Result = &result
}
}
// 设置错误
if errorText.Valid {
exec.Error = errorText.String
}
// 设置结束时间
if endTime.Valid {
exec.EndTime = &endTime.Time
}
// 设置持续时间
if durationMs.Valid {
exec.Duration = time.Duration(durationMs.Int64) * time.Millisecond
}
executions = append(executions, &exec)
}
return executions, nil
}
// SaveToolStats 保存工具统计信息
func (db *DB) SaveToolStats(toolName string, stats *mcp.ToolStats) error {
var lastCallTime sql.NullTime
+75
View File
@@ -307,4 +307,79 @@ func (h *MonitorHandler) DeleteExecution(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
}
// DeleteExecutions 批量删除执行记录
func (h *MonitorHandler) DeleteExecutions(c *gin.Context) {
var request struct {
IDs []string `json:"ids"`
}
if err := c.ShouldBindJSON(&request); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "请求参数无效: " + err.Error()})
return
}
if len(request.IDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "执行记录ID列表不能为空"})
return
}
// 如果使用数据库,先获取执行记录信息,然后删除并更新统计
if h.db != nil {
// 先获取执行记录信息(用于更新统计)
executions, err := h.db.GetToolExecutionsByIds(request.IDs)
if err != nil {
h.logger.Error("获取执行记录失败", zap.Error(err))
c.JSON(http.StatusInternalServerError, gin.H{"error": "获取执行记录失败: " + err.Error()})
return
}
// 按工具名称分组统计需要减少的数量
toolStats := make(map[string]struct {
totalCalls int
successCalls int
failedCalls int
})
for _, exec := range executions {
if exec.ToolName == "" {
continue
}
stats := toolStats[exec.ToolName]
stats.totalCalls++
if exec.Status == "failed" {
stats.failedCalls++
} else if exec.Status == "completed" {
stats.successCalls++
}
toolStats[exec.ToolName] = stats
}
// 批量删除执行记录
err = h.db.DeleteToolExecutions(request.IDs)
if err != nil {
h.logger.Error("批量删除执行记录失败", zap.Error(err), zap.Int("count", len(request.IDs)))
c.JSON(http.StatusInternalServerError, gin.H{"error": "批量删除执行记录失败: " + err.Error()})
return
}
// 更新统计信息(减少相应的计数)
for toolName, stats := range toolStats {
if err := h.db.DecreaseToolStats(toolName, stats.totalCalls, stats.successCalls, stats.failedCalls); err != nil {
h.logger.Warn("更新统计信息失败", zap.Error(err), zap.String("toolName", toolName))
// 不返回错误,因为记录已经删除成功
}
}
h.logger.Info("批量删除执行记录成功", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "成功删除执行记录", "deleted": len(executions)})
return
}
// 如果不使用数据库,尝试从内存中删除(内部MCP服务器)
// 注意:内存中的记录可能已经被清理,所以这里只记录日志
h.logger.Info("尝试批量删除内存中的执行记录", zap.Int("count", len(request.IDs)))
c.JSON(http.StatusOK, gin.H{"message": "执行记录已删除(如果存在)"})
}
+77
View File
@@ -3036,6 +3036,9 @@ header {
.pagination-info {
font-size: 0.875rem;
color: var(--text-secondary);
display: flex;
align-items: center;
gap: 16px;
}
.pagination-controls {
@@ -3056,6 +3059,36 @@ header {
cursor: not-allowed;
}
.pagination-page-size {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.875rem;
color: var(--text-secondary);
}
.pagination-page-size select {
padding: 4px 8px;
border-radius: 6px;
border: 1px solid var(--border-color);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 0.875rem;
cursor: pointer;
transition: all 0.2s;
min-width: 60px;
}
.pagination-page-size select:focus {
outline: none;
border-color: var(--accent-color);
box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1);
}
.pagination-page-size select:hover {
border-color: var(--accent-color);
}
.pagination-btn {
padding: 6px 12px;
font-size: 0.875rem;
@@ -3414,6 +3447,50 @@ header {
color: #c82333;
}
.monitor-batch-actions {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: var(--bg-secondary);
border: 1px solid var(--border-color);
border-radius: 12px;
margin-bottom: 16px;
gap: 16px;
}
.monitor-batch-actions .batch-actions-info {
display: flex;
align-items: center;
color: var(--text-secondary);
font-size: 0.875rem;
font-weight: 500;
}
.monitor-batch-actions .batch-actions-buttons {
display: flex;
align-items: center;
gap: 8px;
}
.monitor-batch-actions .batch-actions-buttons button {
padding: 6px 12px;
font-size: 0.75rem;
}
.monitor-execution-checkbox {
cursor: pointer;
width: 18px;
height: 18px;
accent-color: var(--accent-color);
}
.monitor-table th:first-child,
.monitor-table td:first-child {
text-align: center;
width: 40px;
}
.monitor-vuln-container {
display: grid;
gap: 16px;
+182 -2
View File
@@ -1008,7 +1008,11 @@ const monitorState = {
lastFetchedAt: null,
pagination: {
page: 1,
pageSize: 20,
pageSize: (() => {
// 从 localStorage 读取保存的每页显示数量,默认为 20
const saved = localStorage.getItem('monitorPageSize');
return saved ? parseInt(saved, 10) : 20;
})(),
total: 0,
totalPages: 0
}
@@ -1019,6 +1023,39 @@ function openMonitorPanel() {
if (typeof switchPage === 'function') {
switchPage('mcp-monitor');
}
// 初始化每页显示数量选择器
initializeMonitorPageSize();
}
// 初始化每页显示数量选择器
function initializeMonitorPageSize() {
const pageSizeSelect = document.getElementById('monitor-page-size');
if (pageSizeSelect) {
pageSizeSelect.value = monitorState.pagination.pageSize;
}
}
// 改变每页显示数量
function changeMonitorPageSize() {
const pageSizeSelect = document.getElementById('monitor-page-size');
if (!pageSizeSelect) {
return;
}
const newPageSize = parseInt(pageSizeSelect.value, 10);
if (isNaN(newPageSize) || newPageSize <= 0) {
return;
}
// 保存到 localStorage
localStorage.setItem('monitorPageSize', newPageSize.toString());
// 更新状态
monitorState.pagination.pageSize = newPageSize;
monitorState.pagination.page = 1; // 重置到第一页
// 刷新数据
refreshMonitorPanel(1);
}
function closeMonitorPanel() {
@@ -1076,6 +1113,9 @@ async function refreshMonitorPanel(page = null) {
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, currentStatusFilter);
renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} catch (error) {
console.error('刷新监控面板失败:', error);
if (statsContainer) {
@@ -1150,6 +1190,9 @@ async function refreshMonitorPanelWithFilter(statusFilter = 'all', toolFilter =
renderMonitorStats(monitorState.stats, monitorState.lastFetchedAt);
renderMonitorExecutions(monitorState.executions, statusFilter);
renderMonitorPagination();
// 初始化每页显示数量选择器
initializeMonitorPageSize();
} catch (error) {
console.error('刷新监控面板失败:', error);
if (statsContainer) {
@@ -1250,6 +1293,11 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
} else {
container.innerHTML = '<div class="monitor-empty">暂无执行记录</div>';
}
// 隐藏批量操作栏
const batchActions = document.getElementById('monitor-batch-actions');
if (batchActions) {
batchActions.style.display = 'none';
}
return;
}
@@ -1266,6 +1314,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
const executionId = escapeHtml(exec.id || '');
return `
<tr>
<td>
<input type="checkbox" class="monitor-execution-checkbox" value="${executionId}" onchange="updateBatchActionsState()" />
</td>
<td>${toolName}</td>
<td><span class="${statusClass}">${statusLabel}</span></td>
<td>${startTime}</td>
@@ -1299,6 +1350,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
<table class="monitor-table">
<thead>
<tr>
<th style="width: 40px;">
<input type="checkbox" id="monitor-select-all" onchange="toggleSelectAll(this)" />
</th>
<th>工具</th>
<th>状态</th>
<th>开始时间</th>
@@ -1317,6 +1371,9 @@ function renderMonitorExecutions(executions = [], statusFilter = 'all') {
} else {
container.appendChild(tableContainer);
}
// 更新批量操作状态
updateBatchActionsState();
}
// 渲染监控面板分页控件
@@ -1342,7 +1399,16 @@ function renderMonitorPagination() {
pagination.innerHTML = `
<div class="pagination-info">
显示 ${startItem}-${endItem} / ${total} 条记录
<span>显示 ${startItem}-${endItem} / ${total} 条记录</span>
<label class="pagination-page-size">
每页显示
<select id="monitor-page-size" onchange="changeMonitorPageSize()">
<option value="10" ${pageSize === 10 ? 'selected' : ''}>10</option>
<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>
<div class="pagination-controls">
<button class="btn-secondary" onclick="refreshMonitorPanel(1)" ${page === 1 || total === 0 ? 'disabled' : ''}>首页</button>
@@ -1354,6 +1420,9 @@ function renderMonitorPagination() {
`;
container.appendChild(pagination);
// 初始化每页显示数量选择器
initializeMonitorPageSize();
}
// 删除执行记录
@@ -1388,6 +1457,117 @@ async function deleteExecution(executionId) {
}
}
// 更新批量操作状态
function updateBatchActionsState() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
const selectedCount = checkboxes.length;
const batchActions = document.getElementById('monitor-batch-actions');
const selectedCountSpan = document.getElementById('monitor-selected-count');
if (selectedCount > 0) {
if (batchActions) {
batchActions.style.display = 'flex';
}
if (selectedCountSpan) {
selectedCountSpan.textContent = `已选择 ${selectedCount}`;
}
} else {
if (batchActions) {
batchActions.style.display = 'none';
}
}
// 更新全选复选框状态
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
const allCheckboxes = document.querySelectorAll('.monitor-execution-checkbox');
const allChecked = allCheckboxes.length > 0 && Array.from(allCheckboxes).every(cb => cb.checked);
selectAllCheckbox.checked = allChecked;
selectAllCheckbox.indeterminate = selectedCount > 0 && selectedCount < allCheckboxes.length;
}
}
// 切换全选
function toggleSelectAll(checkbox) {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = checkbox.checked;
});
updateBatchActionsState();
}
// 全选
function selectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = true;
});
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = true;
selectAllCheckbox.indeterminate = false;
}
updateBatchActionsState();
}
// 取消全选
function deselectAllExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox');
checkboxes.forEach(cb => {
cb.checked = false;
});
const selectAllCheckbox = document.getElementById('monitor-select-all');
if (selectAllCheckbox) {
selectAllCheckbox.checked = false;
selectAllCheckbox.indeterminate = false;
}
updateBatchActionsState();
}
// 批量删除执行记录
async function batchDeleteExecutions() {
const checkboxes = document.querySelectorAll('.monitor-execution-checkbox:checked');
if (checkboxes.length === 0) {
alert('请先选择要删除的执行记录');
return;
}
const ids = Array.from(checkboxes).map(cb => cb.value);
const count = ids.length;
// 确认删除
if (!confirm(`确定要删除选中的 ${count} 条执行记录吗?此操作不可恢复。`)) {
return;
}
try {
const response = await apiFetch('/api/monitor/executions', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ ids: ids })
});
if (!response.ok) {
const error = await response.json().catch(() => ({}));
throw new Error(error.error || '批量删除执行记录失败');
}
const result = await response.json().catch(() => ({}));
const deletedCount = result.deleted || count;
// 删除成功后刷新当前页面
const currentPage = monitorState.pagination.page;
await refreshMonitorPanel(currentPage);
alert(`成功删除 ${deletedCount} 条执行记录`);
} catch (error) {
console.error('批量删除执行记录失败:', error);
alert('批量删除执行记录失败: ' + error.message);
}
}
function formatExecutionDuration(start, end) {
if (!start) {
return '未知';
+11 -1
View File
@@ -292,6 +292,16 @@
</label>
</div>
</div>
<div id="monitor-batch-actions" class="monitor-batch-actions" style="display: none;">
<div class="batch-actions-info">
<span id="monitor-selected-count">已选择 0 项</span>
</div>
<div class="batch-actions-buttons">
<button class="btn-secondary" onclick="selectAllExecutions()">全选</button>
<button class="btn-secondary" onclick="deselectAllExecutions()">取消全选</button>
<button class="btn-secondary btn-delete" onclick="batchDeleteExecutions()">批量删除</button>
</div>
</div>
<div id="monitor-executions" class="monitor-table-container">
<div class="monitor-empty">加载中...</div>
</div>
@@ -1005,7 +1015,7 @@
<span class="modal-close" onclick="closeCreateGroupModal()">&times;</span>
</div>
<div class="modal-body create-group-body">
<p class="create-group-description">分组功能可将对话集中归类管理,并支持自定义指令,让对话更加井然有序。</p>
<p class="create-group-description">分组功能可将对话集中归类管理,让对话更加井然有序。</p>
<div class="create-group-input-wrapper">
<span class="group-icon-input">😊</span>
<input type="text" id="create-group-name-input" placeholder="请输入分组名称" />