feat: add project usage analytics with ECharts
This commit is contained in:
@@ -622,8 +622,8 @@ function assertFrontendSidebarCollapseContract() {
|
||||
'Rich themes should provide isolated rail treatments on top of the shared semantic fallback'
|
||||
);
|
||||
assert(
|
||||
indexSource.includes('style.css?v=20260803-usage-statistics')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
|
||||
indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
|
||||
'Sidebar interaction assets should share the reviewed cache-busting version'
|
||||
);
|
||||
}
|
||||
@@ -946,8 +946,8 @@ function assertPlanListProgressContract() {
|
||||
assert(extractorSource.includes('references/source-assets/wasteland-icon-sheet.webp'), 'Plan progress extractor should read the archived source sheet');
|
||||
assert(!extractorSource.includes('sessions/_attachments'), 'Plan progress extractor should not depend on temporary session attachments');
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Plan progress CSS should use the current cache-busted URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Plan progress frontend logic should use the current cache-busted URL');
|
||||
}
|
||||
|
||||
function assertFrontendGildedThemeContract() {
|
||||
@@ -1062,8 +1062,8 @@ function assertFrontendGildedThemeContract() {
|
||||
assert(contrast('#655446', '#fff7ea') >= 4.5, 'Gilded muted text should remain readable on ivory panels');
|
||||
assert(contrast('#fff7ea', '#7a3f20') >= 7, 'Gilded primary action text should reach AAA contrast on copper');
|
||||
assert(themeStyle.includes('@media (prefers-reduced-motion: reduce)'), 'Gilded theme motion should respect reduced-motion preferences');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Theme bundle stylesheet should use the current cache-busted asset URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Theme bundle app script should use the current cache-busted asset URL');
|
||||
}
|
||||
|
||||
function assertFrontendWastelandThemeContract() {
|
||||
@@ -1316,8 +1316,8 @@ function assertFrontendWastelandThemeContract() {
|
||||
assert(contrast('#c9bda6', backgroundColor) >= 4.5, `Wasteland muted text should reach AA contrast on ${backgroundColor}`);
|
||||
});
|
||||
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts'), 'Wasteland stylesheet should share the cache-busted theme bundle URL');
|
||||
assert(indexSource.includes('app.js?v=20260803-usage-statistics-echarts'), 'Wasteland registration should share the cache-busted theme bundle URL');
|
||||
}
|
||||
|
||||
function assertFrontendCcwebPromptContract() {
|
||||
@@ -4449,8 +4449,8 @@ function assertAdvancedSessionSearchContract() {
|
||||
'Existing sidebar search clear control contract should remain unchanged');
|
||||
assert(indexSource.includes('id="advanced-search-panel"') && indexSource.includes('id="advanced-search-results"'),
|
||||
'Advanced search workspace should expose stable panel and result hooks');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
|
||||
'Advanced search CSS and frontend script should share the reviewed cache-bust');
|
||||
|
||||
const panelStyleStart = styleSource.indexOf('.advanced-search-panel {');
|
||||
@@ -4519,23 +4519,89 @@ function assertUsageStatisticsContract() {
|
||||
const usageOpenIndex = indexSource.indexOf('id="usage-dashboard-open"', footerIndex);
|
||||
const chatMainIndex = indexSource.indexOf('class="chat-main"');
|
||||
const usagePanelIndex = indexSource.indexOf('id="usage-dashboard-panel"');
|
||||
const metricGridIndex = indexSource.indexOf('class="usage-dashboard__metric-grid"', usagePanelIndex);
|
||||
const primaryGridIndex = indexSource.indexOf('usage-dashboard__content-grid--primary', usagePanelIndex);
|
||||
const trendPanelIndex = indexSource.indexOf('usage-dashboard__panel--trend', primaryGridIndex);
|
||||
const mcpPanelIndex = indexSource.indexOf('usage-dashboard__panel--mcp', primaryGridIndex);
|
||||
const lowerGridIndex = indexSource.indexOf('usage-dashboard__content-grid--lower', mcpPanelIndex);
|
||||
const statusPanelIndex = indexSource.indexOf('usage-dashboard__panel--status', lowerGridIndex);
|
||||
const projectsPanelIndex = indexSource.indexOf('usage-dashboard__panel--projects', lowerGridIndex);
|
||||
const sessionsPanelIndex = indexSource.indexOf('usage-dashboard__panel--sessions', projectsPanelIndex);
|
||||
assert(sessionListIndex >= 0 && footerIndex > sessionListIndex && settingsIndex > footerIndex && usageOpenIndex > settingsIndex,
|
||||
'Usage entry should stay in the fixed sidebar footer after settings, outside the session list');
|
||||
assert(chatMainIndex >= 0 && usagePanelIndex > chatMainIndex,
|
||||
'Usage dashboard should be a chat-main-local workspace');
|
||||
assert(indexSource.includes('class="usage-dashboard-open"') && !indexSource.includes('class="settings-btn usage-dashboard-open"'),
|
||||
'Usage entry must use its own class so theme-specific settings pseudo-elements cannot leak');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics'),
|
||||
'Usage dashboard CSS and frontend script should share the current cache-bust');
|
||||
assert(indexSource.includes('style.css?v=20260803-usage-statistics-echarts')
|
||||
&& indexSource.includes('vendor/echarts.min.js?v=5.6.0')
|
||||
&& indexSource.includes('app.js?v=20260803-usage-statistics-echarts'),
|
||||
'Usage dashboard CSS, local ECharts runtime, and frontend script should share the current asset contract');
|
||||
assert(metricGridIndex > usagePanelIndex
|
||||
&& primaryGridIndex > metricGridIndex
|
||||
&& trendPanelIndex > primaryGridIndex
|
||||
&& mcpPanelIndex > trendPanelIndex
|
||||
&& lowerGridIndex > mcpPanelIndex
|
||||
&& statusPanelIndex > lowerGridIndex
|
||||
&& projectsPanelIndex > statusPanelIndex
|
||||
&& sessionsPanelIndex > projectsPanelIndex,
|
||||
'Usage dashboard should follow the reference hierarchy: metrics, trend + MCP, status + projects, then full-width sessions');
|
||||
assert((indexSource.match(/<div class="usage-dashboard__sparkline" data-usage-spark=/g) || []).length === 5
|
||||
&& indexSource.includes('<div id="usage-dashboard-trend" class="usage-dashboard__chart"')
|
||||
&& indexSource.includes('id="usage-dashboard-period-custom"')
|
||||
&& indexSource.includes('usage-dashboard__table--mcp')
|
||||
&& indexSource.includes('id="usage-dashboard-mcp-more"')
|
||||
&& indexSource.includes('id="usage-dashboard-detail-scrim"')
|
||||
&& indexSource.includes('usage-dashboard__rank-head')
|
||||
&& indexSource.includes('usage-dashboard__table--sessions'),
|
||||
'Usage dashboard should expose five ECharts sparkline containers, a trend container, custom range selection, and dense MCP/project/session tables');
|
||||
assert(!indexSource.includes('Skill')
|
||||
&& !indexSource.includes('skill')
|
||||
&& indexSource.includes('项目使用排行')
|
||||
&& indexSource.includes('id="usage-dashboard-project-rows"')
|
||||
&& indexSource.includes('<th scope="col">项目</th>'),
|
||||
'Usage dashboard should replace every Skill surface with project ranking and recent-session project attribution');
|
||||
assert(fs.existsSync(path.join(REPO_DIR, 'public', 'vendor', 'echarts.min.js'))
|
||||
&& fs.existsSync(path.join(REPO_DIR, 'public', 'vendor', 'echarts.LICENSE'))
|
||||
&& fs.existsSync(path.join(REPO_DIR, 'public', 'vendor', 'echarts.NOTICE'))
|
||||
&& !/https?:\/\/[^"']*echarts/i.test(indexSource),
|
||||
'Usage dashboard should ship ECharts and its notices locally without a runtime CDN dependency');
|
||||
assert(!indexSource.includes('独立用户数')
|
||||
&& !indexSource.includes('会话时长')
|
||||
&& !indexSource.includes('全部组织'),
|
||||
'Usage dashboard must not copy reference-only fields that are absent from the retained-session statistics protocol');
|
||||
|
||||
const usageFunctionsStart = frontendSource.indexOf('function usageDateInputValue');
|
||||
const usageFunctionsEnd = frontendSource.indexOf('function getProjectCollapseKey', usageFunctionsStart);
|
||||
const usageFunctions = frontendSource.slice(usageFunctionsStart, usageFunctionsEnd);
|
||||
assert(usageFunctions.includes("type: 'usage_stats_query'")
|
||||
&& usageFunctions.includes('usageDashboardState.requestId')
|
||||
&& usageFunctions.includes("behavior: reduceMotion ? 'auto' : 'smooth'"),
|
||||
'Usage dashboard should use an independent request id and respect reduced-motion while navigating details');
|
||||
&& usageFunctions.includes('usageDashboardDetail.dataset.motion')
|
||||
&& usageFunctions.includes('setUsageMcpDetailOpen'),
|
||||
'Usage dashboard should use an independent request id and use an in-context detail drawer with reduced-motion handling');
|
||||
assert(usageFunctions.includes('function getUsageEchart')
|
||||
&& usageFunctions.includes('window.echarts')
|
||||
&& usageFunctions.includes('function renderUsageSparklines')
|
||||
&& usageFunctions.includes("type: 'bar'")
|
||||
&& usageFunctions.includes("type: 'line'")
|
||||
&& usageFunctions.includes("type: 'pie'")
|
||||
&& usageFunctions.includes('disposeUsageEchartsIn')
|
||||
&& usageFunctions.includes('scheduleUsageEchartResize')
|
||||
&& usageFunctions.includes('scheduleUsageEchartRefresh')
|
||||
&& frontendSource.includes("attributeFilter: ['data-theme']")
|
||||
&& usageFunctions.includes('function renderUsageProjects')
|
||||
&& usageFunctions.includes('function getUsageProjectLabel')
|
||||
&& usageFunctions.includes('data?.projects')
|
||||
&& usageFunctions.includes('row?.projects')
|
||||
&& usageFunctions.includes("usage-dashboard__rate-action")
|
||||
&& usageFunctions.includes('tools.slice(0, 6)')
|
||||
&& usageFunctions.includes('usageDashboardState.mcpToolsExpanded')
|
||||
&& frontendSource.includes('usageDashboardPeriodCustom?.addEventListener'),
|
||||
'Usage dashboard should render ECharts trends and truthful pies with theme/resize lifecycle, project aggregation, bounded MCP rankings, detail affordances, and custom ranges');
|
||||
assert(!usageFunctions.includes("style.setProperty('--usage-donut'")
|
||||
&& !styleSource.includes('.usage-dashboard__chart-line')
|
||||
&& !styleSource.includes('.usage-dashboard__spark-line'),
|
||||
'Usage dashboard should not retain the former conic-gradient or hand-authored SVG chart implementation');
|
||||
assert(!/\bcurrentSessionId\s*=/.test(usageFunctions)
|
||||
&& !/\bsessions\s*=/.test(usageFunctions)
|
||||
&& !/\bsessionSearchQuery\s*=/.test(usageFunctions)
|
||||
@@ -4570,15 +4636,28 @@ function assertUsageStatisticsContract() {
|
||||
'Usage index should count explicit Skill mentions and MCP status without retaining message bodies');
|
||||
assert(usageSource.includes('DEFAULT_MCP_TOOL_LIMIT = 200')
|
||||
&& usageSource.includes('DEFAULT_SKILL_LIMIT = 100')
|
||||
&& usageSource.includes('DEFAULT_PROJECT_LIMIT = 100')
|
||||
&& usageSource.includes('trendProjectKeys')
|
||||
&& usageSource.includes('returnedProjects')
|
||||
&& usageSource.includes('unassignedProjectSessions')
|
||||
&& usageSource.includes('detailCallsReturned')
|
||||
&& frontendSource.includes('最近 ${formatUsageNumber(returnedCalls)} / 共 ${formatUsageNumber(totalCalls)} 条'),
|
||||
'Usage result rankings and detail payloads should be bounded and visibly disclose partial detail windows');
|
||||
'Usage result MCP/project rankings and detail payloads should be bounded and visibly disclose partial detail windows');
|
||||
|
||||
assert(styleSource.includes('/* === 使用统计看板:独立工作区 === */')
|
||||
&& styleSource.includes('/* 使用统计原型版式:信息架构固定,主题只覆盖语义变量。 */')
|
||||
&& styleSource.includes("html[data-theme='wasteland'] .usage-dashboard-open")
|
||||
&& styleSource.includes("html[data-theme='gilded'] .usage-dashboard")
|
||||
&& styleSource.includes("html[data-theme='coolvibe'] .usage-dashboard-open")
|
||||
&& styleSource.includes('--usage-dashboard-accent-ink: #0d1b1f')
|
||||
&& styleSource.includes('--usage-dashboard-metric-sessions: #357df2')
|
||||
&& styleSource.includes('--usage-dashboard-metric-projects: #f0ad35')
|
||||
&& styleSource.includes('.usage-dashboard__panel--projects')
|
||||
&& styleSource.includes('.usage-dashboard__status-card')
|
||||
&& styleSource.includes('.usage-dashboard__list-more')
|
||||
&& styleSource.includes('.usage-dashboard__detail-scrim')
|
||||
&& styleSource.includes('.usage-dashboard__content-grid--primary')
|
||||
&& styleSource.includes('@media (max-width: 560px)')
|
||||
&& /html\[data-theme='wasteland'\][\s\S]*?\.usage-dashboard__dates input\s*\{\s*color-scheme:\s*dark;/.test(styleSource)
|
||||
&& /@media \(prefers-reduced-motion:\s*reduce\)[\s\S]*?\.usage-dashboard/.test(styleSource),
|
||||
'Usage dashboard should provide isolated theme surfaces, readable native controls, mobile sizing, and reduced-motion coverage');
|
||||
|
||||
@@ -123,14 +123,23 @@ async function main() {
|
||||
assert.strictEqual(result.overview.mcpCalls, 2);
|
||||
assert.strictEqual(result.overview.mcpFailures, 1, 'done=true 的失败调用仍应计入失败');
|
||||
assert.strictEqual(result.overview.skillMentions, 1);
|
||||
assert.strictEqual(result.overview.projects, 1);
|
||||
assert.strictEqual(result.overview.projectSessions, 1);
|
||||
assert.strictEqual(result.overview.unassignedProjectSessions, 0);
|
||||
assert.strictEqual(result.mcpStatus.completed, 1);
|
||||
assert.strictEqual(result.mcpStatus.failed, 1);
|
||||
assert.strictEqual(result.mcpTools[0].server, 'ccweb');
|
||||
assert.strictEqual(result.mcpTools[0].calls, 1, '同调用数时应按工具名稳定排序');
|
||||
assert.strictEqual(result.mcpTools[0].detailCallsReturned, 1);
|
||||
assert.strictEqual(result.skills[0].name, 'openai-docs');
|
||||
assert.strictEqual(result.projects[0].name, 'usage-project');
|
||||
assert.strictEqual(result.projects[0].sessions, 1);
|
||||
assert.strictEqual(result.projects[0].messages, 2);
|
||||
assert.strictEqual(result.projects[0].mcpCalls, 2);
|
||||
assert.strictEqual(result.recentSessions[0].projectName, 'usage-project');
|
||||
assert.strictEqual(result.mcpRecentCalls[0].timestamp, '2026-07-28T02:00:00.000Z');
|
||||
assert(result.trend.find((row) => row.date === '2026-07-28')?.mcpCalls === 2);
|
||||
assert(result.trend.find((row) => row.date === '2026-07-28')?.projects === 1);
|
||||
const serialized = JSON.stringify(result);
|
||||
assert(!serialized.includes('secret-body'));
|
||||
assert(!serialized.includes('secret-result'));
|
||||
@@ -145,6 +154,7 @@ async function main() {
|
||||
timeZone: 'UTC',
|
||||
mcpToolLimit: 1,
|
||||
skillLimit: 1,
|
||||
projectLimit: 1,
|
||||
mcpDetailLimit: 1,
|
||||
});
|
||||
assert.strictEqual(limited.coverage.distinctMcpTools, 2);
|
||||
@@ -154,6 +164,9 @@ async function main() {
|
||||
assert.strictEqual(limited.mcpTools[0].detailCallsReturned, 1);
|
||||
assert.strictEqual(limited.coverage.distinctSkills, 1);
|
||||
assert.strictEqual(limited.coverage.returnedSkills, 1);
|
||||
assert.strictEqual(limited.coverage.distinctProjects, 1);
|
||||
assert.strictEqual(limited.coverage.returnedProjects, 1);
|
||||
assert.strictEqual(limited.projects.length, 1);
|
||||
|
||||
fixture.messages.push({ role: 'user', content: '增量消息', timestamp: '2026-07-30T04:00:00.000Z' });
|
||||
writeSession(sessionsDir, fixture);
|
||||
|
||||
Reference in New Issue
Block a user