diff --git a/web/static/css/style.css b/web/static/css/style.css index 2d4f2c3c..281fe324 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -5931,14 +5931,41 @@ header { flex-shrink: 0; } -.mcp-stats-dist-donut { - position: relative; +.mcp-stats-dist-svg { + display: block; width: 100%; height: 100%; - border-radius: 50%; - box-shadow: - 0 2px 12px rgba(0, 0, 0, 0.08), - inset 0 0 0 1px rgba(255, 255, 255, 0.8); + filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.08)); +} + +.mcp-stats-dist-segment { + cursor: pointer; + transition: opacity 0.15s ease, filter 0.15s ease; + outline: none; +} + +.mcp-stats-dist-segment.is-others { + cursor: default; +} + +.mcp-stats-dist-segment.is-dimmed { + opacity: 0.45; +} + +.mcp-stats-dist-segment.is-highlighted { + opacity: 1; + filter: brightness(1.06); +} + +.mcp-stats-dist-segment.is-active { + filter: brightness(1.08); + stroke: rgba(0, 102, 255, 0.55); + stroke-width: 0.6; +} + +.mcp-stats-dist-segment:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.45); + outline-offset: 1px; } .mcp-stats-dist-donut-hole { @@ -5952,17 +5979,34 @@ header { justify-content: center; gap: 2px; box-shadow: inset 0 0 0 1px var(--border-color); + pointer-events: none; } .mcp-stats-dist-donut-label { font-size: 0.6875rem; font-weight: 700; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.04em; color: var(--text-muted); + max-width: 92%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + text-align: center; +} + +.mcp-stats-dist-donut-label:not(.is-default) { + text-transform: none; + font-size: 0.625rem; + font-weight: 600; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; } .mcp-stats-dist-donut-value { + display: flex; + align-items: baseline; + justify-content: center; + gap: 1px; font-size: 1.625rem; font-weight: 800; color: var(--text-primary); @@ -5987,24 +6031,62 @@ header { flex-shrink: 0; } +.mcp-stats-dist-legend-item-wrap { + list-style: none; + margin: 0; + padding: 0; +} + .mcp-stats-dist-legend-item { display: grid; grid-template-columns: 6px minmax(0, 1fr); grid-template-rows: auto auto; gap: 2px 8px; align-items: center; + width: 100%; padding: 7px 8px; border-radius: 8px; background: var(--bg-primary); border: 1px solid var(--border-color); - transition: background 0.15s ease, border-color 0.15s ease; + transition: background 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease; + box-sizing: border-box; + font: inherit; + color: inherit; + text-align: left; + cursor: pointer; } -.mcp-stats-dist-legend-item:hover { +.mcp-stats-dist-legend-item:hover, +.mcp-stats-dist-legend-item.is-highlighted { border-color: rgba(0, 102, 255, 0.22); background: rgba(0, 102, 255, 0.03); } +.mcp-stats-dist-legend-item.is-active { + border-color: rgba(0, 102, 255, 0.35); + background: rgba(0, 102, 255, 0.05); + box-shadow: 0 0 0 2px rgba(0, 102, 255, 0.1); +} + +.mcp-stats-dist-legend-item.is-dimmed { + opacity: 0.5; +} + +.mcp-stats-dist-legend-item:focus-visible { + outline: 2px solid rgba(0, 102, 255, 0.45); + outline-offset: 2px; +} + +.mcp-stats-dist-legend-item.is-others { + cursor: default; + opacity: 0.72; +} + +.mcp-stats-dist-legend-item.is-others:hover { + border-color: var(--border-color); + background: var(--bg-primary); +} + .mcp-stats-dist-swatch { grid-row: 1 / 3; align-self: stretch; diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json index 2121fff3..3d8ffd12 100644 --- a/web/static/i18n/en-US.json +++ b/web/static/i18n/en-US.json @@ -1321,6 +1321,10 @@ "statsSubtitle": "Refreshed {{time}} · {{count}} tools", "distTitle": "Call distribution", "distLegend": "Slice area shows share of all calls", + "distClickHint": "Click legend or slice to filter records", + "distHeaderHint": "{{n}} total calls", + "distSegmentAria": "{{name}}, {{pct}}% of calls, {{calls}} times", + "distOthersNoFilter": "Other tools cannot be filtered individually", "distTotalCalls": "{{n}} total calls", "distTop6Share": "Top {{n}} share of all calls", "distOthers": "Other tools", diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json index dff884aa..d543c2b2 100644 --- a/web/static/i18n/zh-CN.json +++ b/web/static/i18n/zh-CN.json @@ -1310,6 +1310,10 @@ "statsSubtitle": "最后刷新 {{time}} · 共 {{count}} 个工具", "distTitle": "调用分布", "distLegend": "扇区面积为占全部调用比例", + "distClickHint": "点击图例或扇区筛选执行记录", + "distHeaderHint": "共 {{n}} 次调用", + "distSegmentAria": "{{name}},占 {{pct}}%,{{calls}} 次", + "distOthersNoFilter": "其他工具无法单独筛选", "distTotalCalls": "共 {{n}} 次调用", "distTop6Share": "Top {{n}} 占全部调用", "distOthers": "其他工具", diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 2c0bf3cd..79527f98 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -3120,9 +3120,102 @@ function getMcpToolRateClass(rateNum) { const MCP_STATS_DIST_COLORS = ['#3b82f6', '#22c55e', '#f59e0b', '#8b5cf6', '#14b8a6', '#ec4899']; -function renderMcpStatsInsightPanel(topTools, totals) { +function mcpStatsDescribeDonutSegment(startPct, endPct, outerR, innerR) { + if (endPct <= startPct) return ''; + const span = endPct - startPct; + const cx = 50; + const cy = 50; + const point = (pct, r) => { + const rad = ((pct / 100) * 360 - 90) * Math.PI / 180; + return [cx + r * Math.cos(rad), cy + r * Math.sin(rad)]; + }; + if (span >= 99.995) { + const [x1, y1] = point(0, outerR); + const [x2, y2] = point(50, outerR); + const [x3, y3] = point(50, innerR); + const [x4, y4] = point(0, innerR); + const [x5, y5] = point(50, outerR); + const [x6, y6] = point(100, outerR); + const [x7, y7] = point(100, innerR); + const [x8, y8] = point(50, innerR); + return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 0 1 ${x2.toFixed(3)} ${y2.toFixed(3)} A ${outerR} ${outerR} 0 0 1 ${x6.toFixed(3)} ${y6.toFixed(3)} L ${x7.toFixed(3)} ${y7.toFixed(3)} A ${innerR} ${innerR} 0 0 0 ${x8.toFixed(3)} ${y8.toFixed(3)} A ${innerR} ${innerR} 0 0 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`; + } + const large = span > 50 ? 1 : 0; + const [x1, y1] = point(startPct, outerR); + const [x2, y2] = point(endPct, outerR); + const [x3, y3] = point(endPct, innerR); + const [x4, y4] = point(startPct, innerR); + return `M ${x1.toFixed(3)} ${y1.toFixed(3)} A ${outerR} ${outerR} 0 ${large} 1 ${x2.toFixed(3)} ${y2.toFixed(3)} L ${x3.toFixed(3)} ${y3.toFixed(3)} A ${innerR} ${innerR} 0 ${large} 0 ${x4.toFixed(3)} ${y4.toFixed(3)} Z`; +} + +function resetMcpStatsDistCenter(panel) { + if (!panel) return; + const label = panel.querySelector('.mcp-stats-dist-donut-label'); + const value = panel.querySelector('.mcp-stats-dist-donut-value'); + const unit = panel.querySelector('.mcp-stats-dist-donut-unit'); + if (!label || !value) return; + label.textContent = panel.getAttribute('data-center-label') || ''; + label.classList.add('is-default'); + const centerVal = panel.getAttribute('data-center-value') || ''; + const numEl = panel.querySelector('.mcp-stats-dist-donut-value-num'); + if (numEl) numEl.textContent = centerVal; + else value.textContent = centerVal; + if (unit) { + unit.textContent = panel.getAttribute('data-center-suffix') || '%'; + unit.hidden = false; + } +} + +function previewMcpStatsDistCenter(panel, toolName, pct) { + if (!panel) return; + const label = panel.querySelector('.mcp-stats-dist-donut-label'); + const value = panel.querySelector('.mcp-stats-dist-donut-value'); + const unit = panel.querySelector('.mcp-stats-dist-donut-unit'); + if (!label || !value) return; + const shortName = toolName.length > 14 ? `${toolName.slice(0, 13)}…` : toolName; + label.textContent = shortName; + label.classList.remove('is-default'); + const numEl = panel.querySelector('.mcp-stats-dist-donut-value-num'); + if (numEl) numEl.textContent = pct; + else value.textContent = pct; + if (unit) unit.hidden = false; +} + +function setMcpStatsDistHover(toolName) { + const panel = document.querySelector('.mcp-stats-dist-panel'); + if (!panel) return; + const esc = typeof CSS !== 'undefined' && CSS.escape ? CSS.escape(toolName) : toolName.replace(/"/g, '\\"'); + panel.querySelectorAll('.mcp-stats-dist-segment, .mcp-stats-dist-legend-item').forEach((el) => { + const t = el.getAttribute('data-tool-name') || ''; + const match = toolName && t === toolName; + el.classList.toggle('is-highlighted', !!match); + el.classList.toggle('is-dimmed', !!toolName && !match && t); + }); + if (toolName) { + const el = panel.querySelector(`[data-tool-name="${esc}"]`); + if (el) { + previewMcpStatsDistCenter(panel, toolName, el.getAttribute('data-pct') || ''); + } + } else { + resetMcpStatsDistCenter(panel); + } +} + +function handleMonitorStatsToolFilter(toolName) { + if (!toolName) return; + const toolFilter = document.getElementById('monitor-tool-filter'); + if (toolFilter && toolFilter.value === toolName) { + clearMonitorToolFilter(); + return; + } + filterMonitorByTool(toolName); +} + +function renderMcpStatsInsightPanel(topTools, totals, activeToolFilter = '') { const distTitle = mcpMonitorT('distTitle') || '调用分布'; const distLegend = mcpMonitorT('distLegend') || '扇区面积为占全部调用比例'; + const distClickHint = mcpMonitorT('distClickHint') || '点击图例或扇区筛选执行记录'; + const distOthersTitle = mcpMonitorT('distOthersNoFilter') || '其他工具无法单独筛选'; const top6ShareLabel = mcpMonitorT('distTop6Share', { n: MCP_STATS_TOP_N }) || `Top ${MCP_STATS_TOP_N} 占全部调用`; const othersLabel = mcpMonitorT('distOthers') || '其他工具'; const callsUnit = (n) => mcpMonitorT('distCallsUnit', { n }) || `${n} 次`; @@ -3144,6 +3237,7 @@ function renderMcpStatsInsightPanel(topTools, totals) { name: tool.toolName || '', calls, pct: pct.toFixed(1), + isOthers: false, }); acc += pct; }); @@ -3156,19 +3250,51 @@ function renderMcpStatsInsightPanel(topTools, totals) { name: othersLabel, calls: otherCalls, pct: pct.toFixed(1), + isOthers: true, }); } - const conic = segments.length > 0 - ? segments.map(s => `${s.color} ${s.start.toFixed(2)}% ${s.end.toFixed(2)}%`).join(', ') - : '#e2e8f0 0% 100%'; - const legendHtml = segments.map(s => ` -