diff --git a/web/static/css/style.css b/web/static/css/style.css index dcc7c5ac..a64b5ccc 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -9574,6 +9574,369 @@ html[data-theme="dark"] .openapi-doc-btn:hover { display: none; } +/* MCP 监控统计重构:工具卡 + 柱线趋势 */ +.mcp-stats-combined { + border-radius: 8px; +} + +.mcp-stats-combined__head { + padding: 14px 18px; +} + +.mcp-stats-combined__body--full { + min-height: 248px; +} + +.mcp-stats-combined__main { + padding: 14px 16px 16px; +} + +.mcp-stats-combined__body--full .mcp-stats-combined__timeline { + gap: 8px; + padding: 14px 16px 12px; +} + +.mcp-stats-tools-panel { + gap: 12px; +} + +.mcp-stats-proportion-bar { + height: 12px; + box-shadow: inset 0 0 0 1px rgba(15, 23, 42, 0.05); +} + +.mcp-stats-tools-panel__caption { + gap: 6px; +} + +.mcp-stats-tools-panel__list { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 10px; +} + +.mcp-stats-tool-item { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + min-width: 0; + min-height: 132px; + padding: 12px; + border: 1px solid rgba(148, 163, 184, 0.16); + background: + linear-gradient(180deg, rgba(248, 250, 252, 0.92), rgba(255, 255, 255, 0.96)); + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.8) inset; +} + +.mcp-stats-tool-item:hover, +.mcp-stats-tool-item.is-highlighted { + background: + linear-gradient(180deg, rgba(239, 246, 255, 0.96), rgba(255, 255, 255, 0.98)); + border-color: rgba(59, 130, 246, 0.26); +} + +.mcp-stats-tool-item.is-active { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.38); +} + +.mcp-stats-tool-item__top, +.mcp-stats-tool-item__middle, +.mcp-stats-tool-item__bottom { + display: flex; + align-items: center; + min-width: 0; +} + +.mcp-stats-tool-item__top { + gap: 8px; +} + +.mcp-stats-tool-item__rank { + flex: 0 0 auto; +} + +.mcp-stats-tool-item__name { + font-size: 0.8125rem; +} + +.mcp-stats-tool-item__share { + margin-left: auto; + padding: 2px 7px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.05); +} + +.mcp-stats-tool-item__middle { + gap: 8px; +} + +.mcp-stats-tool-item__calls { + font-size: 1.45rem; + line-height: 1; +} + +.mcp-stats-tool-item__calls-label { + flex: 0 0 auto; + font-size: 0.6875rem; + color: var(--text-muted); +} + +.mcp-stats-tool-item__track { + flex: 1 1 auto; + width: auto; + height: 6px; + background: rgba(15, 23, 42, 0.07); +} + +.mcp-stats-tool-item__bottom { + gap: 6px; + flex-wrap: wrap; + margin-top: auto; +} + +.mcp-stats-tool-item__pill { + display: inline-flex; + align-items: center; + min-height: 20px; + padding: 2px 7px; + border-radius: 999px; + font-size: 0.6875rem; + font-weight: 600; + color: var(--text-secondary); + background: rgba(148, 163, 184, 0.13); +} + +.mcp-stats-tool-item__pill.is-success { + color: #15803d; + background: rgba(34, 197, 94, 0.13); +} + +.mcp-stats-tool-item__pill.is-danger { + color: #b91c1c; + background: rgba(239, 68, 68, 0.12); +} + +.mcp-stats-tool-item__rate { + margin-left: auto; + flex-direction: row; + align-items: center; + gap: 4px; + min-width: 0; +} + +.mcp-stats-tool-item__fail { + font-size: 0.625rem; +} + +.mcp-stats-timeline-moments { + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + margin: 2px 0 0; +} + +.mcp-stats-timeline-moments__label { + flex: 0 0 auto; + font-size: 0.6875rem; + font-weight: 600; + color: var(--text-muted); +} + +.mcp-stats-timeline-moments__list { + display: flex; + gap: 6px; + min-width: 0; + overflow: hidden; + flex: 1 1 auto; + flex-wrap: nowrap; +} + +.mcp-stats-timeline-moment { + display: inline-flex; + align-items: center; + gap: 6px; + min-width: 0; + max-width: 144px; + padding: 4px 8px; + border-radius: 999px; + background: rgba(59, 130, 246, 0.08); + color: #1d4ed8; + font-size: 0.6875rem; + font-weight: 600; + flex: 0 0 auto; +} + +.mcp-stats-timeline-moment__time { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.mcp-stats-timeline-moment__count { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 999px; + color: #fff; + background: #2563eb; + box-shadow: inset 0 0 0 999px rgba(0, 0, 0, 0.04); +} + +.mcp-stats-timeline-moment__fail { + display: inline-flex; + align-items: center; + min-height: 18px; + padding: 0 6px; + border-radius: 999px; + color: #b91c1c; + background: rgba(239, 68, 68, 0.12); + font-size: 0.625rem; + font-weight: 700; + white-space: nowrap; +} + +.mcp-stats-timeline-moment--more { + color: var(--text-secondary); + background: rgba(148, 163, 184, 0.14); + font-weight: 700; +} + +.mcp-stats-timeline__chart-wrap { + min-height: 146px; + padding: 4px 0 0; +} + +.mcp-stats-combined__timeline .mcp-stats-timeline__chart-wrap, +.mcp-stats-combined__timeline .mcp-stats-timeline__chart { + min-height: 132px; +} + +.mcp-stats-timeline-bar { + fill: rgba(59, 130, 246, 0.14); + cursor: crosshair; + transition: fill 0.12s ease, opacity 0.12s ease; +} + +.mcp-stats-timeline-bar.is-active { + fill: rgba(59, 130, 246, 0.42); +} + +.mcp-stats-timeline-bar.is-hover { + fill: rgba(37, 99, 235, 0.68); +} + +.mcp-stats-timeline-bar-fail { + fill: rgba(239, 68, 68, 0.72); + cursor: crosshair; +} + +.mcp-stats-timeline-bar-fail.is-hover { + fill: rgba(220, 38, 38, 0.9); +} + +.mcp-stats-timeline-area { + opacity: 0.55; +} + +.mcp-stats-timeline-line { + stroke-width: 2; +} + +.mcp-stats-timeline-dot { + opacity: 0; +} + +@media (max-width: 1180px) { + .mcp-stats-tools-panel__list { + grid-template-columns: repeat(auto-fit, minmax(168px, 1fr)); + } +} + +@media (max-width: 720px) { + .mcp-stats-tools-panel__list { + grid-template-columns: 1fr; + } + .mcp-stats-combined__main { + max-width: none; + } + .mcp-stats-combined__timeline { + max-width: none !important; + } + .mcp-stats-timeline-moments { + align-items: center; + flex-direction: row; + } + .mcp-stats-timeline-moments__list { + flex-wrap: nowrap; + overflow: hidden; + } +} + +html[data-theme="dark"] .mcp-stats-tool-item { + background: linear-gradient(180deg, #111827 0%, #0f172a 100%); + border-color: #263244; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04) inset; +} + +html[data-theme="dark"] .mcp-stats-tool-item:hover, +html[data-theme="dark"] .mcp-stats-tool-item.is-highlighted { + background: linear-gradient(180deg, #172033 0%, #111827 100%); + border-color: rgba(96, 165, 250, 0.36); +} + +html[data-theme="dark"] .mcp-stats-tool-item.is-active { + background: rgba(96, 165, 250, 0.12); + border-color: rgba(96, 165, 250, 0.46); +} + +html[data-theme="dark"] .mcp-stats-tool-item__share, +html[data-theme="dark"] .mcp-stats-tool-item__track { + background: rgba(148, 163, 184, 0.12); +} + +html[data-theme="dark"] .mcp-stats-tool-item__pill { + background: rgba(148, 163, 184, 0.14); +} + +html[data-theme="dark"] .mcp-stats-tool-item__pill.is-success { + color: #86efac; + background: rgba(34, 197, 94, 0.14); +} + +html[data-theme="dark"] .mcp-stats-tool-item__pill.is-danger, +html[data-theme="dark"] .mcp-stats-timeline-moment__fail { + color: #fca5a5; + background: rgba(239, 68, 68, 0.14); +} + +html[data-theme="dark"] .mcp-stats-timeline-moment { + color: #93c5fd; + background: rgba(96, 165, 250, 0.14); +} + +html[data-theme="dark"] .mcp-stats-timeline-moment--more { + color: var(--text-secondary); + background: rgba(148, 163, 184, 0.14); +} + +html[data-theme="dark"] .mcp-stats-timeline-bar { + fill: rgba(96, 165, 250, 0.16); +} + +html[data-theme="dark"] .mcp-stats-timeline-bar.is-active { + fill: rgba(96, 165, 250, 0.46); +} + +html[data-theme="dark"] .mcp-stats-timeline-bar.is-hover { + fill: rgba(96, 165, 250, 0.78); +} + /* ── 工具统计面板:表格 + 饼图 ── */ .mcp-stats-panel { background: #fff; diff --git a/web/static/js/monitor.js b/web/static/js/monitor.js index 8ffbbeab..cb6238cb 100644 --- a/web/static/js/monitor.js +++ b/web/static/js/monitor.js @@ -4377,6 +4377,8 @@ function buildMcpTimelineSvg(points, rangeKey) { const maxVal = Math.max(1, ...points.map((p) => p.total || 0)); const hasFailed = points.some((p) => (p.failed || 0) > 0); const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US'; + const barGap = points.length > 48 ? 1 : 2; + const barW = Math.max(1.6, Math.min(8, (plotW / Math.max(1, points.length)) - barGap)); const coords = points.map((p, i) => { const x = padL + (points.length <= 1 ? plotW / 2 : (i / (points.length - 1)) * plotW); @@ -4433,6 +4435,21 @@ function buildMcpTimelineSvg(points, rangeKey) { data-failed="${c.p.failed || 0}" />`; }).join(''); + const bars = coords.map((c) => { + const total = c.p.total || 0; + const failed = c.p.failed || 0; + const h = total > 0 ? Math.max(3, (total / maxVal) * plotH) : 1; + const y = baseY - h; + const failedH = failed > 0 ? Math.max(2, (failed / maxVal) * plotH) : 0; + const tipTime = formatMcpTimelineLabel(c.p.t, rangeKey, locale); + return ` + + ${failedH > 0 ? `` : ''} + `; + }).join(''); + const peakC = coords[peakIdx]; const peakMarker = (peakC.p.total || 0) > 0 ? `` @@ -4453,6 +4470,7 @@ function buildMcpTimelineSvg(points, rangeKey) { ${yLines} + ${bars} ${peakMarker} ${hasFailed ? `` : ''} @@ -4484,14 +4502,17 @@ function bindMcpStatsTimelineEvents() { } root.addEventListener('mousemove', function (e) { - const dot = e.target.closest('.mcp-stats-timeline-dot'); + const dot = e.target.closest('.mcp-stats-timeline-dot, .mcp-stats-timeline-bar, .mcp-stats-timeline-bar-fail'); if (!dot || !mcpTimelineTooltipEl) { root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active')); + root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover')); mcpTimelineTooltipEl.style.display = 'none'; return; } root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active')); + root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover')); dot.classList.add('is-active'); + dot.classList.add('is-hover'); const time = dot.getAttribute('data-time') || ''; const total = dot.getAttribute('data-total') || '0'; const failed = dot.getAttribute('data-failed') || '0'; @@ -4507,6 +4528,7 @@ function bindMcpStatsTimelineEvents() { if (!e.target.closest || !e.target.closest('.mcp-stats-combined__timeline, .mcp-stats-timeline')) return; if (e.relatedTarget && root.contains(e.relatedTarget)) return; root.querySelectorAll('.mcp-stats-timeline-dot.is-active').forEach((d) => d.classList.remove('is-active')); + root.querySelectorAll('.mcp-stats-timeline-bar.is-hover, .mcp-stats-timeline-bar-fail.is-hover').forEach((d) => d.classList.remove('is-hover')); if (mcpTimelineTooltipEl) mcpTimelineTooltipEl.style.display = 'none'; }); @@ -4568,6 +4590,37 @@ function buildTimelineSparseHint(points, timeline) { || `该时段多数时间为 0,峰值 ${peak} 次出现在 ${peakTime}`; } +function renderMcpTimelineActiveMoments(points, rangeKey) { + if (!Array.isArray(points) || points.length === 0) return ''; + const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US'; + const active = points + .map((p, i) => ({ ...p, i })) + .filter((p) => (p.total || 0) > 0) + .sort((a, b) => (b.total || 0) - (a.total || 0) || b.i - a.i) + const shown = active.slice(0, 4); + const hiddenCount = Math.max(0, active.length - shown.length); + if (!active.length) return ''; + const label = mcpMonitorT('timelineActiveMoments') || monitorFallback('活跃时段', 'Active moments'); + const moreLabel = mcpMonitorT('timelineMoreMoments', { n: hiddenCount }) || `+${hiddenCount}`; + const chips = shown.map((p) => { + const time = formatMcpTimelineLabel(p.t, rangeKey, locale); + const failed = p.failed || 0; + const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`; + return ` + ${escapeHtml(time)} + ${p.total || 0} + ${failed > 0 ? `${escapeHtml(failedLabel)}` : ''} + `; + }).join(''); + const moreChip = hiddenCount > 0 + ? `${escapeHtml(moreLabel)}` + : ''; + return `
+ ${escapeHtml(label)} +
${chips}${moreChip}
+
`; +} + async function setMcpMonitorTimelineRange(range) { if (!MCP_TIMELINE_RANGES.includes(range)) return; localStorage.setItem('mcpMonitorTimelineRange', range); @@ -4643,12 +4696,14 @@ function renderMcpStatsTimelineBody(timeline, timelineError, compactEmpty, loadi const failLegend = mcpMonitorT('timelineFailedLegend') || '失败'; const hasFailed = points.some((p) => (p.failed || 0) > 0); const sparseHint = buildTimelineSparseHint(points, timeline); + const momentsHtml = renderMcpTimelineActiveMoments(points, rangeKey); const sparseHtml = sparseHint ? `

${escapeHtml(sparseHint)}

` : ''; return `

${escapeHtml(hint)} · ${escapeHtml(summaryText)}

+ ${momentsHtml}
${chartSvg}
${sparseHtml}
@@ -5294,10 +5349,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') { const caption = mcpMonitorT('rankingSummary', { n: MCP_STATS_TOP_N, pct: topNSharePct, total: totals.total }) || `Top ${MCP_STATS_TOP_N} 占 ${topNSharePct}% · 共 ${totals.total} 次`; const unknownToolLabel = mcpMonitorT('unknownTool') || '未知工具'; - const colTool = mcpMonitorT('columnTool') || '工具'; - const colCalls = mcpMonitorT('columnCalls') || '调用'; - const colShare = mcpMonitorT('columnShare') || '占比'; - const colRate = mcpMonitorT('columnSuccessRate') || '成功率'; const distAria = mcpMonitorT('distTitle') || '调用分布'; const stackedHtml = segments.map((s) => { @@ -5336,20 +5387,27 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') { const failNote = failed > 0 ? `${escapeHtml(mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`)}` : ''; + const successLabel = mcpMonitorT('successCount', { n: success }) || `成功 ${success}`; + const failedLabel = mcpMonitorT('failedCount', { n: failed }) || `失败 ${failed}`; return `
  • - ${index + 1} - -
    +
    + ${index + 1} + ${escapeHtml(name)} + +
    +
    + ${total} + ${escapeHtml(mcpMonitorT('columnCalls') || '调用')}
    -
    - - ${total} +
    + ${escapeHtml(successLabel)} + ${escapeHtml(failedLabel)} ${toolRate}%${failNote}
  • `; @@ -5364,16 +5422,6 @@ function renderMcpStatsToolsPanel(topTools, totals, activeToolFilter = '') { ${escapeHtml(caption)}

    -
      ${listHtml}
    `; }