feat: add project usage analytics with ECharts

This commit is contained in:
shiyue
2026-08-04 00:49:52 +08:00
parent 58d5f816c2
commit c61b7434d2
39 changed files with 5040 additions and 283 deletions

View File

@@ -272,12 +272,16 @@
period: 'week',
data: null,
selectedMcpKey: '',
mcpToolsExpanded: false,
openedAt: 0,
savedScrollTop: 0,
savedFocus: null,
savedSelection: null,
};
let usageDashboardRequestSeq = 0;
const usageEchartInstances = new Set();
let usageChartResizeFrame = 0;
let usageChartRefreshFrame = 0;
let lastSessionListStructureSignature = '';
const collapsedProjectKeys = (() => {
try {
@@ -347,23 +351,25 @@
const usageDashboardRefresh = $('#usage-dashboard-refresh');
const usageDashboardPeriodWeek = $('#usage-dashboard-period-week');
const usageDashboardPeriodMonth = $('#usage-dashboard-period-month');
const usageDashboardPeriodCustom = $('#usage-dashboard-period-custom');
const usageDashboardFrom = $('#usage-dashboard-from');
const usageDashboardTo = $('#usage-dashboard-to');
const usageDashboardApply = $('#usage-dashboard-apply');
const usageDashboardLoading = $('#usage-dashboard-loading');
const usageDashboardError = $('#usage-dashboard-error');
const usageDashboardTrend = $('#usage-dashboard-trend');
const usageDashboardTrendLegend = $('#usage-dashboard-trend-legend');
const usageDashboardMcpStatus = $('#usage-dashboard-mcp-status');
const usageDashboardMcpRows = $('#usage-dashboard-mcp-rows');
const usageDashboardMcpEmpty = $('#usage-dashboard-mcp-empty');
const usageDashboardMcpMore = $('#usage-dashboard-mcp-more');
const usageDashboardDetail = $('#usage-dashboard-detail');
const usageDashboardDetailTitle = $('#usage-dashboard-detail-title');
const usageDashboardDetailMeta = $('#usage-dashboard-detail-meta');
const usageDashboardDetailRows = $('#usage-dashboard-detail-rows');
const usageDashboardDetailClose = $('#usage-dashboard-detail-close');
const usageDashboardSkillRows = $('#usage-dashboard-skill-rows');
const usageDashboardSkillEmpty = $('#usage-dashboard-skill-empty');
const usageDashboardDetailScrim = $('#usage-dashboard-detail-scrim');
const usageDashboardProjectRows = $('#usage-dashboard-project-rows');
const usageDashboardProjectEmpty = $('#usage-dashboard-project-empty');
const usageDashboardSessionRows = $('#usage-dashboard-session-rows');
const usageDashboardSessionEmpty = $('#usage-dashboard-session-empty');
const sessionList = $('#session-list');
@@ -4427,6 +4433,7 @@
usageDashboardState.period = period;
usageDashboardPeriodWeek?.setAttribute('aria-pressed', period === 'week' ? 'true' : 'false');
usageDashboardPeriodMonth?.setAttribute('aria-pressed', period === 'month' ? 'true' : 'false');
usageDashboardPeriodCustom?.setAttribute('aria-pressed', period === 'custom' ? 'true' : 'false');
if (period === 'week' || period === 'month') {
const dates = usagePeriodDates(period);
if (usageDashboardFrom) usageDashboardFrom.value = dates.from;
@@ -4483,122 +4490,480 @@
}).format(date);
}
function usageSvgElement(name, attributes = {}) {
const element = document.createElementNS('http://www.w3.org/2000/svg', name);
Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
return element;
function usageChartAnimationDuration() {
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ? 0 : 320;
}
function getUsageEchart(element) {
if (!element || !window.echarts) return null;
let chart = window.echarts.getInstanceByDom(element);
if (!chart) {
chart = window.echarts.init(element, null, {
renderer: 'canvas',
devicePixelRatio: Math.min(2, Math.max(1, Number(window.devicePixelRatio || 1))),
});
usageEchartInstances.add(chart);
}
return chart;
}
function disposeUsageEchartsIn(container) {
if (!container || !window.echarts) return;
container.querySelectorAll('[_echarts_instance_]').forEach((element) => {
const chart = window.echarts.getInstanceByDom(element);
if (!chart) return;
usageEchartInstances.delete(chart);
chart.dispose();
});
}
function usageChartColorWithAlpha(color, alpha) {
return window.echarts?.color?.modifyAlpha?.(color, alpha) || color;
}
function resolveUsageChartColor(variableName, fallback) {
if (!usageDashboardPanel) return fallback;
const probe = document.createElement('span');
probe.style.position = 'absolute';
probe.style.pointerEvents = 'none';
probe.style.opacity = '0';
probe.style.color = `var(${variableName}, ${fallback})`;
usageDashboardPanel.appendChild(probe);
const color = getComputedStyle(probe).color || fallback;
probe.remove();
return color;
}
function getUsageChartPalette() {
return {
accent: resolveUsageChartColor('--usage-dashboard-accent', '#c45a3c'),
messages: resolveUsageChartColor('--usage-dashboard-metric-messages', '#c45a3c'),
mcp: resolveUsageChartColor('--usage-dashboard-metric-mcp', '#8b5cf6'),
sessions: resolveUsageChartColor('--usage-dashboard-metric-sessions', '#2563eb'),
projects: resolveUsageChartColor('--usage-dashboard-metric-projects', '#d97706'),
text: resolveUsageChartColor('--usage-dashboard-text-secondary', '#475569'),
muted: resolveUsageChartColor('--usage-dashboard-text-muted', '#94a3b8'),
border: resolveUsageChartColor('--usage-dashboard-border', '#d7dee8'),
surface: resolveUsageChartColor('--usage-dashboard-surface', '#ffffff'),
};
}
function resizeUsageEcharts() {
usageChartResizeFrame = 0;
if (!usageDashboardState.open) return;
[...usageEchartInstances].forEach((chart) => {
if (!chart || chart.isDisposed?.()) {
usageEchartInstances.delete(chart);
return;
}
chart.resize({ animation: { duration: 0 } });
});
}
function scheduleUsageEchartResize() {
if (usageChartResizeFrame) cancelAnimationFrame(usageChartResizeFrame);
usageChartResizeFrame = requestAnimationFrame(resizeUsageEcharts);
}
function refreshUsageEcharts() {
usageChartRefreshFrame = 0;
if (!usageDashboardState.open || !usageDashboardState.data) return;
renderUsageSparklines(usageDashboardState.data);
renderUsageTrend(usageDashboardState.data);
renderUsageMcpStatus(usageDashboardState.data);
scheduleUsageEchartResize();
}
function scheduleUsageEchartRefresh() {
if (usageChartRefreshFrame) cancelAnimationFrame(usageChartRefreshFrame);
usageChartRefreshFrame = requestAnimationFrame(refreshUsageEcharts);
}
function renderUsageSparklines(data) {
if (!usageDashboardPanel) return;
const rows = Array.isArray(data?.trend) ? data.trend : [];
usageDashboardPanel.querySelectorAll('[data-usage-spark]').forEach((sparkline) => {
const key = String(sparkline.dataset.usageSpark || '');
const values = rows.map((row) => Math.max(0, Number(row?.[key] || 0)));
const chart = getUsageEchart(sparkline);
if (!chart) {
sparkline.dataset.chartState = 'unavailable';
sparkline.textContent = '—';
return;
}
sparkline.dataset.chartState = 'ready';
if (values.length === 0) {
chart.clear();
return;
}
const lineColor = getComputedStyle(sparkline).color || '#6b7280';
const lastIndex = values.length - 1;
chart.setOption({
animation: usageChartAnimationDuration() > 0,
animationDuration: usageChartAnimationDuration(),
aria: {
enabled: true,
description: sparkline.getAttribute('aria-label') || '每日微趋势',
},
grid: { left: 1, right: 2, top: 3, bottom: 2, containLabel: false },
tooltip: { show: false },
xAxis: {
type: 'category',
data: rows.map((row) => String(row?.date || '')),
boundaryGap: false,
show: false,
},
yAxis: {
type: 'value',
min: 0,
max: Math.max(1, ...values),
show: false,
},
series: [
{
type: 'line',
data: values,
smooth: 0.28,
symbol: 'none',
silent: true,
lineStyle: { color: lineColor, width: 1.8 },
areaStyle: {
color: new window.echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: usageChartColorWithAlpha(lineColor, 0.2) },
{ offset: 1, color: usageChartColorWithAlpha(lineColor, 0.02) },
]),
},
},
{
type: 'scatter',
data: values.map((value, index) => (index === lastIndex ? value : null)),
symbolSize: 4,
silent: true,
itemStyle: {
color: lineColor,
borderColor: getComputedStyle(usageDashboardPanel).backgroundColor,
borderWidth: 1,
},
},
],
}, { notMerge: true, lazyUpdate: false, silent: true });
});
}
function renderUsageTrend(data) {
if (!usageDashboardTrend || !usageDashboardTrendLegend) return;
usageDashboardTrend.replaceChildren();
usageDashboardTrendLegend.replaceChildren();
const rows = Array.isArray(data?.trend) ? data.trend : [];
const series = [
{ key: 'newSessions', label: '会话', className: 'sessions' },
{ key: 'messages', label: '消息', className: 'messages' },
{ key: 'mcpCalls', label: 'MCP', className: 'mcp' },
{ key: 'skillMentions', label: 'Skill', className: 'skills' },
];
series.forEach((item) => {
const legend = document.createElement('span');
legend.className = `usage-dashboard__legend-item usage-dashboard__legend-item--${item.className}`;
legend.textContent = item.label;
usageDashboardTrendLegend.appendChild(legend);
});
const width = 720;
const height = 280;
const padding = { left: 46, right: 18, top: 20, bottom: 42 };
const chartWidth = width - padding.left - padding.right;
const chartHeight = height - padding.top - padding.bottom;
const maxValue = Math.max(1, ...rows.flatMap((row) => series.map((item) => Number(row?.[item.key] || 0))));
const yTicks = 4;
for (let tick = 0; tick <= yTicks; tick += 1) {
const y = padding.top + (chartHeight * tick) / yTicks;
usageDashboardTrend.appendChild(usageSvgElement('line', {
x1: padding.left,
x2: width - padding.right,
y1: y,
y2: y,
class: 'usage-dashboard__chart-grid',
}));
const label = usageSvgElement('text', {
x: padding.left - 10,
y: y + 4,
class: 'usage-dashboard__chart-axis',
'text-anchor': 'end',
});
label.textContent = formatUsageNumber(maxValue - (maxValue * tick) / yTicks);
usageDashboardTrend.appendChild(label);
if (!usageDashboardTrend) return;
const chart = getUsageEchart(usageDashboardTrend);
if (!chart) {
usageDashboardTrend.dataset.chartState = 'unavailable';
usageDashboardTrend.textContent = '图表组件未加载';
return;
}
if (rows.length === 0) return;
const xAt = (index) => rows.length === 1
? padding.left + chartWidth / 2
: padding.left + (chartWidth * index) / (rows.length - 1);
const yAt = (value) => padding.top + chartHeight - (Math.max(0, Number(value || 0)) / maxValue) * chartHeight;
const labelStride = Math.max(1, Math.ceil(rows.length / 7));
rows.forEach((row, index) => {
if (index % labelStride !== 0 && index !== rows.length - 1) return;
const label = usageSvgElement('text', {
x: xAt(index),
y: height - 14,
class: 'usage-dashboard__chart-axis',
'text-anchor': 'middle',
});
label.textContent = String(row.date || '').slice(5).replace('-', '/');
usageDashboardTrend.appendChild(label);
});
series.forEach((item) => {
const points = rows.map((row, index) => `${xAt(index)},${yAt(row?.[item.key])}`);
const path = usageSvgElement('path', {
d: points.map((point, index) => `${index === 0 ? 'M' : 'L'}${point}`).join(' '),
class: `usage-dashboard__chart-line usage-dashboard__chart-line--${item.className}`,
});
usageDashboardTrend.appendChild(path);
if (rows.length <= 14) {
rows.forEach((row, index) => {
usageDashboardTrend.appendChild(usageSvgElement('circle', {
cx: xAt(index),
cy: yAt(row?.[item.key]),
r: 3,
class: `usage-dashboard__chart-dot usage-dashboard__chart-dot--${item.className}`,
}));
});
}
});
usageDashboardTrend.dataset.chartState = 'ready';
const rows = Array.isArray(data?.trend) ? data.trend : [];
if (rows.length === 0) {
chart.clear();
return;
}
const palette = getUsageChartPalette();
const isMobile = window.matchMedia?.('(max-width: 560px)').matches;
const labelInterval = Math.max(0, Math.ceil(rows.length / (isMobile ? 5 : 7)) - 1);
const animationDuration = usageChartAnimationDuration();
chart.setOption({
animation: animationDuration > 0,
animationDuration,
animationDurationUpdate: animationDuration,
aria: {
enabled: true,
description: usageDashboardTrend.getAttribute('aria-label') || '每日使用趋势',
},
grid: {
left: isMobile ? 36 : 44,
right: isMobile ? 34 : 42,
top: isMobile ? 50 : 44,
bottom: 28,
containLabel: false,
},
legend: {
top: 0,
right: 0,
itemWidth: 12,
itemHeight: 6,
itemGap: isMobile ? 7 : 11,
textStyle: { color: palette.muted, fontSize: 9 },
selectedMode: true,
},
tooltip: {
trigger: 'axis',
confine: true,
appendToBody: false,
backgroundColor: palette.surface,
borderColor: palette.border,
borderWidth: 1,
padding: [8, 10],
textStyle: { color: palette.text, fontSize: 11 },
axisPointer: {
type: 'line',
lineStyle: { color: palette.border, type: 'dashed', width: 1 },
},
valueFormatter: (value) => formatUsageNumber(value),
},
xAxis: {
type: 'category',
data: rows.map((row) => String(row?.date || '').slice(5).replace('-', '/')),
boundaryGap: true,
axisLine: { lineStyle: { color: palette.border } },
axisTick: { show: false },
axisLabel: {
color: palette.muted,
fontSize: 9,
interval: labelInterval,
hideOverlap: true,
},
},
yAxis: [
{
type: 'value',
min: 0,
splitNumber: 4,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: palette.muted, fontSize: 9, formatter: (value) => formatUsageNumber(value) },
splitLine: { lineStyle: { color: usageChartColorWithAlpha(palette.border, 0.62), width: 1 } },
},
{
type: 'value',
min: 0,
splitNumber: 4,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { color: palette.muted, fontSize: 9, formatter: (value) => formatUsageNumber(value) },
splitLine: { show: false },
},
],
series: [
{
name: '消息',
type: 'bar',
data: rows.map((row) => Math.max(0, Number(row?.messages || 0))),
yAxisIndex: 0,
barMaxWidth: 8,
itemStyle: {
color: usageChartColorWithAlpha(palette.messages, 0.52),
borderRadius: [2, 2, 0, 0],
},
emphasis: { focus: 'series' },
},
{
name: 'MCP',
type: 'bar',
data: rows.map((row) => Math.max(0, Number(row?.mcpCalls || 0))),
yAxisIndex: 0,
barMaxWidth: 8,
itemStyle: {
color: usageChartColorWithAlpha(palette.mcp, 0.42),
borderRadius: [2, 2, 0, 0],
},
emphasis: { focus: 'series' },
},
{
name: '新建会话',
type: 'line',
data: rows.map((row) => Math.max(0, Number(row?.newSessions || 0))),
yAxisIndex: 1,
smooth: 0.22,
showSymbol: rows.length <= 14,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: palette.sessions, width: 2 },
itemStyle: { color: palette.sessions, borderColor: palette.surface, borderWidth: 1 },
emphasis: { focus: 'series' },
},
{
name: '项目',
type: 'line',
data: rows.map((row) => Math.max(0, Number(row?.projects || 0))),
yAxisIndex: 1,
smooth: 0.22,
showSymbol: rows.length <= 14,
symbol: 'circle',
symbolSize: 4,
lineStyle: { color: palette.projects, width: 1.8, type: 'dashed' },
itemStyle: { color: palette.projects, borderColor: palette.surface, borderWidth: 1 },
emphasis: { focus: 'series' },
},
],
}, { notMerge: true, lazyUpdate: false });
}
function renderUsageMcpStatus(data) {
if (!usageDashboardMcpStatus) return;
disposeUsageEchartsIn(usageDashboardMcpStatus);
usageDashboardMcpStatus.replaceChildren();
const status = data?.mcpStatus || {};
const rows = [
{ key: 'completed', label: '成功', className: 'success' },
{ key: 'failed', label: '失败', className: 'failure' },
{ key: 'other', label: '其他', className: 'other' },
const overview = data?.overview || {};
const palette = getUsageChartPalette();
const cards = [
{
title: 'MCP 调用成功率',
centerValue: (segments) => {
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
return total > 0 ? `${((segments[0].value / total) * 100).toFixed(1)}%` : '0%';
},
centerLabel: '成功率',
segments: [
{ label: '成功', value: Number(status.completed || 0), colorVar: '--usage-dashboard-success', fallback: '#4f8a56' },
{ label: '失败', value: Number(status.failed || 0), colorVar: '--usage-dashboard-danger', fallback: '#d95545' },
{ label: '其他', value: Number(status.other || 0), colorVar: '--usage-dashboard-info', fallback: '#5d85a6' },
],
},
{
title: '消息来源占比',
centerValue: (segments) => formatUsageNumber(segments.reduce((sum, segment) => sum + segment.value, 0)),
centerLabel: '消息',
segments: [
{ label: '直接消息', value: Number(overview.directMessages || 0), colorVar: '--usage-dashboard-metric-messages', fallback: '#c45a3c' },
{ label: '跨会话', value: Number(overview.crossConversationMessages || 0), colorVar: '--usage-dashboard-accent', fallback: '#7f5f52' },
],
},
{
title: '会话项目归属',
centerValue: (segments) => formatUsageNumber(segments.reduce((sum, segment) => sum + segment.value, 0)),
centerLabel: '会话',
segments: [
{ label: '已归属项目', value: Number(overview.projectSessions || 0), colorVar: '--usage-dashboard-metric-projects', fallback: '#d97706' },
{ label: '未归属', value: Number(overview.unassignedProjectSessions || 0), colorVar: '--usage-dashboard-info', fallback: '#5d85a6' },
],
},
];
const total = Math.max(1, rows.reduce((sum, row) => sum + Math.max(0, Number(status[row.key] || 0)), 0));
rows.forEach((row) => {
const item = document.createElement('div');
item.className = `usage-dashboard__status usage-dashboard__status--${row.className}`;
const header = document.createElement('div');
const label = document.createElement('span');
label.textContent = row.label;
const value = document.createElement('strong');
value.textContent = formatUsageNumber(status[row.key]);
header.append(label, value);
const track = document.createElement('span');
track.className = 'usage-dashboard__status-track';
const fill = document.createElement('span');
fill.style.width = `${(Math.max(0, Number(status[row.key] || 0)) / total) * 100}%`;
track.appendChild(fill);
item.append(header, track);
cards.forEach((card) => {
const segments = card.segments.map((segment) => ({
...segment,
value: Math.max(0, Number(segment.value || 0)),
color: resolveUsageChartColor(segment.colorVar, segment.fallback),
}));
const total = segments.reduce((sum, segment) => sum + segment.value, 0);
const item = document.createElement('article');
item.className = 'usage-dashboard__status-card';
const title = document.createElement('h4');
title.textContent = card.title;
const content = document.createElement('div');
content.className = 'usage-dashboard__status-content';
const donut = document.createElement('div');
donut.className = 'usage-dashboard__donut-chart';
donut.setAttribute('role', 'img');
donut.setAttribute('aria-label', `${card.title},总计 ${formatUsageNumber(total)}`);
const legend = document.createElement('div');
legend.className = 'usage-dashboard__status-legend';
segments.forEach((segment) => {
const row = document.createElement('div');
const label = document.createElement('span');
label.style.setProperty('--usage-segment-color', segment.color);
label.textContent = segment.label;
const value = document.createElement('strong');
const share = total > 0 ? (segment.value / total) * 100 : 0;
value.textContent = `${formatUsageNumber(segment.value)} · ${share.toFixed(1)}%`;
row.append(label, value);
legend.appendChild(row);
});
content.append(donut, legend);
item.append(title, content);
usageDashboardMcpStatus.appendChild(item);
const chart = getUsageEchart(donut);
if (!chart) {
donut.dataset.chartState = 'unavailable';
donut.textContent = '—';
return;
}
donut.dataset.chartState = 'ready';
const centerValue = card.centerValue(segments);
const chartData = total > 0
? segments.map((segment) => ({
name: segment.label,
value: segment.value,
itemStyle: { color: segment.color },
}))
: [{
name: '无数据',
value: 1,
itemStyle: { color: usageChartColorWithAlpha(palette.border, 0.34) },
}];
const animationDuration = usageChartAnimationDuration();
chart.setOption({
animation: animationDuration > 0,
animationDuration,
animationDurationUpdate: animationDuration,
aria: {
enabled: true,
description: donut.getAttribute('aria-label') || card.title,
},
tooltip: {
show: total > 0,
trigger: 'item',
confine: true,
backgroundColor: palette.surface,
borderColor: palette.border,
borderWidth: 1,
padding: [7, 9],
textStyle: { color: palette.text, fontSize: 10 },
valueFormatter: (value) => formatUsageNumber(value),
},
graphic: [
{
type: 'text',
left: 'center',
top: '34%',
silent: true,
style: {
text: centerValue,
fill: palette.text,
font: '700 13px sans-serif',
textAlign: 'center',
},
},
{
type: 'text',
left: 'center',
top: '56%',
silent: true,
style: {
text: card.centerLabel,
fill: palette.muted,
font: '7px sans-serif',
textAlign: 'center',
},
},
],
series: [{
type: 'pie',
radius: ['62%', '82%'],
center: ['50%', '50%'],
startAngle: 90,
minAngle: total > 0 ? 2 : 360,
clockwise: true,
avoidLabelOverlap: true,
label: { show: false },
labelLine: { show: false },
emphasis: { scale: true, scaleSize: 2 },
data: chartData,
}],
}, { notMerge: true, lazyUpdate: false });
});
}
function showUsageMcpDetail(toolRow) {
function setUsageMcpDetailOpen(open) {
const isOpen = !!open;
if (usageDashboardDetail) usageDashboardDetail.hidden = !isOpen;
if (usageDashboardDetailScrim) usageDashboardDetailScrim.hidden = !isOpen;
if (usageDashboardPanel) usageDashboardPanel.dataset.detailOpen = isOpen ? 'true' : 'false';
}
function closeUsageMcpDetail(options = {}) {
usageDashboardState.selectedMcpKey = '';
setUsageMcpDetailOpen(false);
if (options.restoreFocus !== false) usageDashboardMcpRows?.querySelector('button')?.focus();
}
function showUsageMcpDetail(toolRow, options = {}) {
if (!usageDashboardDetail || !usageDashboardDetailRows || !usageDashboardState.data) return;
const key = String(toolRow?.key || `${toolRow?.server || ''}/${toolRow?.tool || ''}`);
usageDashboardState.selectedMcpKey = key;
@@ -4636,9 +5001,12 @@
row.appendChild(cell);
usageDashboardDetailRows.appendChild(row);
}
usageDashboardDetail.hidden = false;
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches;
usageDashboardDetail.scrollIntoView({ behavior: reduceMotion ? 'auto' : 'smooth', block: 'start' });
usageDashboardDetail.dataset.motion = reduceMotion ? 'reduced' : 'normal';
setUsageMcpDetailOpen(true);
if (options.focus !== false) {
requestAnimationFrame(() => usageDashboardDetailClose?.focus());
}
}
function renderUsageMcpTools(data) {
@@ -4646,59 +5014,97 @@
usageDashboardMcpRows.replaceChildren();
const tools = Array.isArray(data?.mcpTools) ? data.mcpTools : [];
usageDashboardMcpEmpty.hidden = tools.length > 0;
tools.forEach((toolRow) => {
const visibleTools = usageDashboardState.mcpToolsExpanded ? tools : tools.slice(0, 6);
if (usageDashboardMcpMore) {
const remaining = Math.max(0, tools.length - 6);
usageDashboardMcpMore.hidden = remaining === 0;
usageDashboardMcpMore.setAttribute('aria-expanded', usageDashboardState.mcpToolsExpanded ? 'true' : 'false');
usageDashboardMcpMore.textContent = usageDashboardState.mcpToolsExpanded
? '收起其余工具'
: `查看其余 ${formatUsageNumber(remaining)} 个工具`;
}
visibleTools.forEach((toolRow, index) => {
const row = document.createElement('tr');
const rank = document.createElement('td');
rank.className = 'usage-dashboard__table-rank';
rank.textContent = String(index + 1);
const name = document.createElement('td');
const nameWrap = document.createElement('span');
nameWrap.className = 'usage-dashboard__tool-name';
const server = document.createElement('span');
server.className = 'usage-dashboard__tool-server';
server.textContent = toolRow.server || '';
const separator = document.createElement('span');
separator.className = 'usage-dashboard__tool-separator';
separator.textContent = ' / ';
const tool = document.createElement('strong');
tool.textContent = toolRow.tool || '';
tool.title = `${toolRow.server || ''}/${toolRow.tool || ''}`;
name.append(server, tool);
nameWrap.append(server, separator, tool);
name.appendChild(nameWrap);
const calls = document.createElement('td');
calls.textContent = formatUsageNumber(toolRow.calls);
const completed = document.createElement('td');
completed.className = 'usage-dashboard__success-text';
completed.textContent = formatUsageNumber(toolRow.completed);
const failed = document.createElement('td');
failed.textContent = formatUsageNumber(toolRow.failed);
failed.className = Number(toolRow.failed || 0) > 0 ? 'usage-dashboard__failure-text' : '';
const rate = document.createElement('td');
rate.textContent = `${Number(toolRow.successRate || 0).toFixed(1)}%`;
const action = document.createElement('td');
const button = document.createElement('button');
button.type = 'button';
button.className = 'usage-dashboard__row-action';
button.textContent = '';
button.className = 'usage-dashboard__rate-action';
button.title = '查看调用明细';
button.setAttribute('aria-label', `查看 ${toolRow.server || ''} ${toolRow.tool || ''} 的调用明细`);
const rateTrack = document.createElement('span');
rateTrack.className = 'usage-dashboard__rate-track';
const rateFill = document.createElement('span');
rateFill.style.width = `${Math.max(0, Math.min(100, Number(toolRow.successRate || 0)))}%`;
rateTrack.appendChild(rateFill);
const rateValue = document.createElement('strong');
rateValue.textContent = `${Number(toolRow.successRate || 0).toFixed(1)}%`;
button.append(rateTrack, rateValue);
button.addEventListener('click', () => showUsageMcpDetail(toolRow));
action.appendChild(button);
row.append(name, calls, completed, failed, rate, action);
rate.appendChild(button);
row.append(rank, name, calls, completed, failed, rate);
usageDashboardMcpRows.appendChild(row);
});
}
function renderUsageSkills(data) {
if (!usageDashboardSkillRows || !usageDashboardSkillEmpty) return;
usageDashboardSkillRows.replaceChildren();
const skills = Array.isArray(data?.skills) ? data.skills : [];
usageDashboardSkillEmpty.hidden = skills.length > 0;
skills.forEach((skill, index) => {
function renderUsageProjects(data) {
if (!usageDashboardProjectRows || !usageDashboardProjectEmpty) return;
usageDashboardProjectRows.replaceChildren();
const projects = Array.isArray(data?.projects) ? data.projects : [];
usageDashboardProjectEmpty.hidden = projects.length > 0;
projects.forEach((project, index) => {
const item = document.createElement('li');
const rank = document.createElement('span');
rank.className = 'usage-dashboard__rank';
rank.textContent = String(index + 1).padStart(2, '0');
rank.textContent = String(index + 1);
const label = document.createElement('strong');
label.textContent = skill.label || `$${skill.name || ''}`;
label.title = skill.label || skill.name || '';
const value = document.createElement('span');
value.textContent = `${formatUsageNumber(skill.uses)}`;
item.append(rank, label, value);
usageDashboardSkillRows.appendChild(item);
label.textContent = project.name || '未命名项目';
label.title = project.cwd || project.name || '';
const sessionCount = document.createElement('span');
sessionCount.className = 'usage-dashboard__rank-count';
sessionCount.textContent = formatUsageNumber(project.sessions);
const messages = document.createElement('span');
messages.className = 'usage-dashboard__rank-count';
messages.textContent = formatUsageNumber(project.messages);
const mcpCalls = document.createElement('span');
mcpCalls.className = 'usage-dashboard__rank-count';
mcpCalls.textContent = formatUsageNumber(project.mcpCalls);
item.append(rank, label, sessionCount, messages, mcpCalls);
usageDashboardProjectRows.appendChild(item);
});
}
function getUsageProjectLabel(sessionRow) {
const projectName = String(sessionRow?.projectName || '').trim();
if (projectName) return projectName;
const cwd = String(sessionRow?.cwd || '').replace(/\\/g, '/').replace(/\/+$/, '');
return cwd.split('/').filter(Boolean).pop() || '未归属';
}
function renderUsageSessions(data) {
if (!usageDashboardSessionRows || !usageDashboardSessionEmpty) return;
usageDashboardSessionRows.replaceChildren();
@@ -4712,20 +5118,31 @@
button.className = 'usage-dashboard__session-link';
button.textContent = sessionRow.title || '未命名会话';
button.title = sessionRow.cwd || sessionRow.title || '';
button.addEventListener('click', () => {
const openTargetSession = () => {
closeUsageDashboard({ restoreFocus: false });
openSession(sessionRow.sessionId, { force: true, blocking: false });
});
};
button.addEventListener('click', openTargetSession);
name.appendChild(button);
const messages = document.createElement('td');
messages.textContent = formatUsageNumber(sessionRow.messages);
const mcp = document.createElement('td');
mcp.textContent = formatUsageNumber(sessionRow.mcpCalls);
const skills = document.createElement('td');
skills.textContent = formatUsageNumber(sessionRow.skillMentions);
const project = document.createElement('td');
project.className = 'usage-dashboard__session-project';
project.textContent = getUsageProjectLabel(sessionRow);
project.title = sessionRow.cwd || project.textContent;
const time = document.createElement('td');
time.textContent = formatUsageTime(sessionRow.lastActivity);
row.append(name, messages, mcp, skills, time);
const action = document.createElement('td');
const view = document.createElement('button');
view.type = 'button';
view.className = 'usage-dashboard__session-action';
view.textContent = '查看';
view.setAttribute('aria-label', `查看会话 ${sessionRow.title || '未命名会话'}`);
view.addEventListener('click', openTargetSession);
action.appendChild(view);
row.append(name, messages, mcp, project, time, action);
usageDashboardSessionRows.appendChild(row);
});
}
@@ -4754,16 +5171,18 @@
const indexed = formatUsageNumber(data.coverage?.indexedSessions);
usageDashboardStatus.textContent = `基于当前保留数据 · ${indexed} 个会话 · MCP 按 assistant 消息时间归属`;
}
renderUsageSparklines(data);
renderUsageTrend(data);
renderUsageMcpStatus(data);
renderUsageMcpTools(data);
renderUsageSkills(data);
renderUsageProjects(data);
renderUsageSessions(data);
if (usageDashboardDetail && usageDashboardState.selectedMcpKey) {
const selected = (data.mcpTools || []).find((item) => item.key === usageDashboardState.selectedMcpKey);
if (selected) showUsageMcpDetail(selected);
else usageDashboardDetail.hidden = true;
if (selected) showUsageMcpDetail(selected, { focus: false });
else setUsageMcpDetailOpen(false);
}
scheduleUsageEchartResize();
}
function runUsageDashboardQuery() {
@@ -4811,6 +5230,7 @@
renderUsageDashboard();
setUsageDashboardState('ready');
}
scheduleUsageEchartResize();
requestAnimationFrame(() => usageDashboardClose?.focus());
runUsageDashboardQuery();
}
@@ -4843,6 +5263,7 @@
if (!requestId || requestId !== usageDashboardState.requestId) return;
usageDashboardState.requestId = '';
usageDashboardState.data = msg;
usageDashboardState.mcpToolsExpanded = false;
usageDashboardState.error = '';
renderUsageDashboard(msg);
setUsageDashboardState('ready');
@@ -10563,6 +10984,10 @@
usageDashboardOpen.addEventListener('click', openUsageDashboard);
usageDashboardClose?.addEventListener('click', () => closeUsageDashboard());
usageDashboardRefresh?.addEventListener('click', runUsageDashboardQuery);
usageDashboardMcpMore?.addEventListener('click', () => {
usageDashboardState.mcpToolsExpanded = !usageDashboardState.mcpToolsExpanded;
renderUsageMcpTools(usageDashboardState.data);
});
usageDashboardApply?.addEventListener('click', () => {
usageDashboardState.period = 'custom';
syncUsageDashboardPeriod('custom');
@@ -10576,16 +11001,36 @@
syncUsageDashboardPeriod('month');
runUsageDashboardQuery();
});
usageDashboardPeriodCustom?.addEventListener('click', () => {
syncUsageDashboardPeriod('custom');
usageDashboardFrom?.focus();
});
[usageDashboardFrom, usageDashboardTo].forEach((input) => {
input?.addEventListener('change', () => {
usageDashboardState.period = 'custom';
syncUsageDashboardPeriod('custom');
});
});
usageDashboardDetailClose?.addEventListener('click', () => {
usageDashboardState.selectedMcpKey = '';
if (usageDashboardDetail) usageDashboardDetail.hidden = true;
});
usageDashboardDetailClose?.addEventListener('click', () => closeUsageMcpDetail());
usageDashboardDetailScrim?.addEventListener('click', () => closeUsageMcpDetail({ restoreFocus: false }));
if (typeof ResizeObserver === 'function') {
const usageDashboardResizeObserver = new ResizeObserver(() => scheduleUsageEchartResize());
usageDashboardResizeObserver.observe(usageDashboardPanel);
}
window.addEventListener('resize', scheduleUsageEchartResize);
if (typeof MutationObserver === 'function') {
const usageDashboardThemeObserver = new MutationObserver((mutations) => {
if (mutations.some((mutation) => mutation.attributeName === 'data-theme')) {
scheduleUsageEchartRefresh();
}
});
usageDashboardThemeObserver.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme'],
});
}
const usageDashboardMotionPreference = window.matchMedia?.('(prefers-reduced-motion: reduce)');
usageDashboardMotionPreference?.addEventListener?.('change', scheduleUsageEchartRefresh);
syncUsageDashboardPeriod('week');
setUsageDashboardFeatureEnabled(false);
}
@@ -10596,6 +11041,11 @@
openAdvancedSessionSearch();
return;
}
if (event.key === 'Escape' && usageDashboardState.open && usageDashboardDetail && !usageDashboardDetail.hidden) {
event.preventDefault();
closeUsageMcpDetail();
return;
}
if (event.key === 'Escape' && usageDashboardState.open) {
event.preventDefault();
closeUsageDashboard();