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

@@ -0,0 +1,502 @@
'use strict';
const childProcess = require('child_process');
const fs = require('fs');
const http = require('http');
const net = require('net');
const os = require('os');
const path = require('path');
const WebSocket = require('ws');
const REPO_DIR = path.resolve(__dirname, '..', '..');
const OUTPUT_DIR = path.join(__dirname, 'screenshots');
const REPORT_PATH = path.join(__dirname, 'visual-report.json');
const CHROME_PATH = process.env.CHROME_PATH
|| '/home/hdzx/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome';
const PASSWORD = 'UsageVisual!234';
const THEMES = [
'washi', 'coolvibe', 'editorial', 'sage', 'ink', 'dawn',
'carbon', 'nocturne', 'cinder', 'gilded', 'wasteland',
];
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFreePort() {
return new Promise((resolve, reject) => {
const server = net.createServer();
server.unref();
server.on('error', reject);
server.listen(0, '127.0.0.1', () => {
const address = server.address();
server.close(() => resolve(address.port));
});
});
}
async function getJson(url) {
return new Promise((resolve, reject) => {
const request = http.get(url, (response) => {
let body = '';
response.setEncoding('utf8');
response.on('data', (chunk) => { body += chunk; });
response.on('end', () => {
try {
resolve(JSON.parse(body));
} catch (error) {
reject(error);
}
});
});
request.on('error', reject);
request.setTimeout(2000, () => request.destroy(new Error(`请求超时:${url}`)));
});
}
async function waitFor(check, timeoutMs = 15000, intervalMs = 80) {
const started = Date.now();
let lastError = null;
while (Date.now() - started < timeoutMs) {
try {
const value = await check();
if (value) return value;
} catch (error) {
lastError = error;
}
await sleep(intervalMs);
}
throw lastError || new Error(`等待条件超时(${timeoutMs}ms`);
}
function writeUsageFixtures(sessionsDir) {
const tools = [
['search-server', 'web_search'],
['data-server', 'query_data'],
['code-server', 'run_code'],
['doc-server', 'fetch_doc'],
['api-server', 'api_request'],
['file-server', 'read_file'],
['memory-server', 'remember'],
['browser-server', 'open_page'],
['terminal-server', 'run_task'],
['github-server', 'list_issues'],
];
const skills = ['数据分析', '报表生成', '文档总结', '代码解释', '翻译助手'];
for (let day = 1; day <= 31; day += 1) {
const messages = [];
const messageCount = 7 + (day % 7);
for (let index = 0; index < messageCount; index += 1) {
const hour = 1 + (index % 20);
const minute = (index * 7) % 60;
const timestamp = new Date(Date.UTC(2026, 6, day, hour, minute)).toISOString();
const skill = skills[(day + index) % skills.length];
messages.push({
role: 'user',
content: `视觉验收消息 ${day}-${index}`,
timestamp,
crossConversation: (day + index) % 4 === 0,
composerMentions: index % 2 === 0
? [{ kind: 'skill', name: skill, label: `$${skill}` }]
: [],
});
if (index % 2 === 0) {
const [server, tool] = tools[(day + index) % tools.length];
const failed = (day + index) % 9 === 0;
const other = !failed && (day + index) % 13 === 0;
messages.push({
role: 'assistant',
content: `视觉验收回复 ${day}-${index}`,
timestamp: new Date(Date.UTC(2026, 6, day, hour, minute + 1)).toISOString(),
toolCalls: [{
name: 'McpToolCall',
kind: 'mcp_tool_call',
input: { server, tool, arguments: { fixture: true } },
result: 'visual-fixture-result',
done: !other,
meta: {
kind: 'mcp_tool_call',
status: failed ? 'failed' : other ? 'pending' : 'completed',
},
}],
});
}
}
const created = new Date(Date.UTC(2026, 6, day, 0, 0)).toISOString();
const updated = new Date(Date.UTC(2026, 6, day, 23, 30)).toISOString();
const id = `visual-usage-${String(day).padStart(2, '0')}`;
fs.writeFileSync(path.join(sessionsDir, `${id}.json`), JSON.stringify({
id,
title: `使用统计视觉验收会话 ${String(day).padStart(2, '0')}`,
agent: day % 2 === 0 ? 'codexapp' : 'claude',
cwd: `/tmp/usage-visual/project-${day % 4}`,
projectName: `视觉项目 ${day % 4}`,
created,
updated,
messages,
}, null, 2));
}
}
async function connectCdp(webSocketUrl) {
const socket = new WebSocket(webSocketUrl);
await new Promise((resolve, reject) => {
socket.once('open', resolve);
socket.once('error', reject);
});
let sequence = 0;
const pending = new Map();
const rejectPending = (error) => {
for (const entry of pending.values()) entry.reject(error);
pending.clear();
};
socket.on('message', (payload) => {
const message = JSON.parse(String(payload));
if (!message.id || !pending.has(message.id)) return;
const entry = pending.get(message.id);
pending.delete(message.id);
if (message.error) entry.reject(new Error(message.error.message || JSON.stringify(message.error)));
else entry.resolve(message.result || {});
});
socket.on('close', () => rejectPending(new Error('Chrome CDP 连接已关闭')));
socket.on('error', (error) => rejectPending(error));
return {
socket,
send(method, params = {}) {
sequence += 1;
const id = sequence;
return new Promise((resolve, reject) => {
pending.set(id, { resolve, reject });
socket.send(JSON.stringify({ id, method, params }), (error) => {
if (!error) return;
pending.delete(id);
reject(error);
});
});
},
};
}
async function evaluate(cdp, expression) {
const result = await cdp.send('Runtime.evaluate', {
expression,
returnByValue: true,
awaitPromise: true,
});
if (result.exceptionDetails) {
throw new Error(result.exceptionDetails.text || '页面脚本执行失败');
}
return result.result?.value;
}
async function setViewport(cdp, width, height) {
await cdp.send('Emulation.setDeviceMetricsOverride', {
width,
height,
deviceScaleFactor: 1,
mobile: false,
screenWidth: width,
screenHeight: height,
});
await sleep(120);
}
async function capture(cdp, fileName) {
const result = await cdp.send('Page.captureScreenshot', {
format: 'png',
fromSurface: true,
captureBeyondViewport: false,
});
fs.writeFileSync(path.join(OUTPUT_DIR, fileName), Buffer.from(result.data, 'base64'));
}
async function stopChildProcess(child) {
if (!child || child.exitCode !== null) return;
const exited = new Promise((resolve) => child.once('exit', resolve));
child.kill('SIGTERM');
await Promise.race([exited, sleep(2000)]);
}
async function collectGeometry(cdp, theme, width, height) {
return evaluate(cdp, `(() => {
const rect = (selector) => {
const element = document.querySelector(selector);
if (!element) return null;
const box = element.getBoundingClientRect();
return {
x: Math.round(box.x * 10) / 10,
y: Math.round(box.y * 10) / 10,
width: Math.round(box.width * 10) / 10,
height: Math.round(box.height * 10) / 10,
};
};
const metricRects = Array.from(document.querySelectorAll('.usage-dashboard__metric')).map((element) => {
const box = element.getBoundingClientRect();
return { x: Math.round(box.x), y: Math.round(box.y), width: Math.round(box.width), height: Math.round(box.height) };
});
const overflowSelectors = [
'.usage-dashboard__metric-label',
'.usage-dashboard__metric-copy strong',
'.usage-dashboard__panel-heading h3',
'.usage-dashboard__tool-name',
'.usage-dashboard__rank-list strong',
'.usage-dashboard__session-link',
];
const textOverflow = overflowSelectors.flatMap((selector) => Array.from(document.querySelectorAll(selector))
.filter((element) => element.scrollWidth > element.clientWidth + 1)
.map((element) => ({ selector, text: element.textContent.trim().slice(0, 80) })));
const dashboard = document.querySelector('.usage-dashboard');
const body = document.querySelector('.usage-dashboard__body');
const sparklineCanvases = document.querySelectorAll('[data-usage-spark] canvas').length;
const donutCanvases = document.querySelectorAll('.usage-dashboard__donut-chart canvas').length;
return {
theme: ${JSON.stringify(theme)},
viewport: { width: ${width}, height: ${height} },
dashboard: rect('.usage-dashboard'),
header: rect('.usage-dashboard__header'),
toolbar: rect('.usage-dashboard__toolbar'),
metricGrid: rect('.usage-dashboard__metric-grid'),
metrics: metricRects,
primary: rect('.usage-dashboard__content-grid--primary'),
trend: rect('.usage-dashboard__panel--trend'),
mcp: rect('.usage-dashboard__panel--mcp'),
lower: rect('.usage-dashboard__content-grid--lower'),
status: rect('.usage-dashboard__panel--status'),
projects: rect('.usage-dashboard__panel--projects'),
sessions: rect('.usage-dashboard__panel--sessions'),
dashboardOverflowX: Math.max(0, dashboard.scrollWidth - dashboard.clientWidth),
bodyOverflowX: Math.max(0, body.scrollWidth - body.clientWidth),
bodyScrollHeight: body.scrollHeight,
bodyClientHeight: body.clientHeight,
metricColumns: getComputedStyle(document.querySelector('.usage-dashboard__metric-grid')).gridTemplateColumns,
primaryColumns: getComputedStyle(document.querySelector('.usage-dashboard__content-grid--primary')).gridTemplateColumns,
panelBackground: getComputedStyle(document.querySelector('.usage-dashboard__panel')).backgroundColor,
pageBackground: getComputedStyle(dashboard).backgroundColor,
charts: {
runtime: typeof window.echarts?.init === 'function',
sparklines: sparklineCanvases,
trend: document.querySelectorAll('#usage-dashboard-trend canvas').length,
donuts: donutCanvases,
ready: sparklineCanvases === 5
&& document.querySelectorAll('#usage-dashboard-trend canvas').length === 1
&& donutCanvases === 3,
},
skillTextVisible: /Skill/i.test(dashboard.innerText),
textOverflow,
};
})()`);
}
async function main() {
if (!fs.existsSync(CHROME_PATH)) throw new Error(`找不到 Chrome${CHROME_PATH}`);
fs.mkdirSync(OUTPUT_DIR, { recursive: true });
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'cc-web-usage-visual-'));
const configDir = path.join(tempRoot, 'config');
const sessionsDir = path.join(tempRoot, 'sessions');
const logsDir = path.join(tempRoot, 'logs');
const profileDir = path.join(tempRoot, 'chrome-profile');
[configDir, sessionsDir, logsDir, profileDir].forEach((dir) => fs.mkdirSync(dir, { recursive: true }));
writeUsageFixtures(sessionsDir);
const port = await getFreePort();
const debugPort = await getFreePort();
let server = null;
let chrome = null;
let cdp = null;
try {
server = childProcess.spawn(process.execPath, ['server.js'], {
cwd: REPO_DIR,
env: {
...process.env,
PORT: String(port),
CC_WEB_PASSWORD: PASSWORD,
CC_WEB_CONFIG_DIR: configDir,
CC_WEB_SESSIONS_DIR: sessionsDir,
CC_WEB_LOGS_DIR: logsDir,
CC_WEB_USAGE_STATISTICS: '1',
},
stdio: ['ignore', 'pipe', 'pipe'],
});
let serverError = '';
server.stderr.on('data', (chunk) => { serverError += String(chunk); });
await waitFor(() => new Promise((resolve) => {
const request = http.get(`http://127.0.0.1:${port}/`, (response) => {
response.resume();
resolve(response.statusCode === 200);
});
request.on('error', () => resolve(false));
}), 20000);
if (server.exitCode !== null) throw new Error(serverError || '测试服务提前退出');
chrome = childProcess.spawn(CHROME_PATH, [
'--headless=new',
'--no-sandbox',
'--disable-gpu',
'--disable-dev-shm-usage',
'--hide-scrollbars',
'--remote-allow-origins=*',
`--remote-debugging-port=${debugPort}`,
`--user-data-dir=${profileDir}`,
'--window-size=1467,943',
'about:blank',
], { stdio: ['ignore', 'ignore', 'pipe'] });
const targets = await waitFor(async () => {
const list = await getJson(`http://127.0.0.1:${debugPort}/json/list`);
return list.find((target) => target.type === 'page') ? list : null;
}, 20000);
const pageTarget = targets.find((target) => target.type === 'page');
cdp = await connectCdp(pageTarget.webSocketDebuggerUrl);
await cdp.send('Page.enable');
await cdp.send('Runtime.enable');
await cdp.send('Network.enable');
await cdp.send('Network.setBlockedURLs', { urls: ['https://cdnjs.cloudflare.com/*'] });
await cdp.send('Page.addScriptToEvaluateOnNewDocument', {
source: `(() => {
class Renderer {}
window.marked = { Renderer, setOptions() {}, parse: (value) => String(value || '') };
window.hljs = {
getLanguage: () => false,
highlight: (_value, options) => ({ value: String(options?.language || '') }),
highlightAuto: (value) => ({ value: String(value || '') }),
};
window.mermaid = { initialize() {}, render: async () => ({ svg: '' }) };
})();`,
});
await setViewport(cdp, 1467, 943);
const navigation = await cdp.send('Page.navigate', { url: `http://127.0.0.1:${port}/` });
if (navigation.errorText) throw new Error(`页面导航失败:${navigation.errorText}`);
try {
await waitFor(() => evaluate(cdp, "document.readyState === 'complete'"), 30000);
} catch (error) {
const diagnostic = await evaluate(cdp, `(() => ({
href: location.href,
readyState: document.readyState,
title: document.title,
body: document.body?.textContent?.trim().slice(0, 240) || '',
}))()`);
throw new Error(`页面未完成加载:${JSON.stringify(diagnostic)}${error.message}`);
}
await waitFor(() => evaluate(cdp, "!!document.querySelector('#login-form')"), 10000);
await sleep(500);
await evaluate(cdp, `(() => {
document.querySelector('#login-password').value = ${JSON.stringify(PASSWORD)};
document.querySelector('#login-form').requestSubmit();
return true;
})()`);
try {
try {
await waitFor(() => evaluate(cdp, "document.querySelector('#login-overlay').hidden && !document.querySelector('#usage-dashboard-open').hidden"), 3000);
} catch {
await evaluate(cdp, `(() => {
document.querySelector('#login-password').value = ${JSON.stringify(PASSWORD)};
document.querySelector('#login-form').requestSubmit();
return true;
})()`);
await waitFor(() => evaluate(cdp, "document.querySelector('#login-overlay').hidden && !document.querySelector('#usage-dashboard-open').hidden"), 17000);
}
} catch (error) {
const diagnostic = await evaluate(cdp, `(() => ({
loginHidden: document.querySelector('#login-overlay')?.hidden,
loginError: document.querySelector('#login-error')?.hidden ? '' : document.querySelector('#login-error')?.textContent,
appHidden: document.querySelector('#app')?.hidden,
usageHidden: document.querySelector('#usage-dashboard-open')?.hidden,
marked: typeof window.marked?.parse,
echarts: typeof window.echarts?.init,
}))()`);
throw new Error(`登录后页面未就绪:${JSON.stringify(diagnostic)}${error.message}`);
}
await evaluate(cdp, "document.querySelector('#usage-dashboard-open').click(); true");
await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-panel').hidden"), 10000);
await waitFor(() => evaluate(cdp, "document.querySelector('#usage-dashboard-panel').dataset.state !== 'loading'"), 30000);
await sleep(320);
await evaluate(cdp, `(() => {
const from = document.querySelector('#usage-dashboard-from');
const to = document.querySelector('#usage-dashboard-to');
from.value = '2026-07-01';
to.value = '2026-07-31';
from.dispatchEvent(new Event('change', { bubbles: true }));
to.dispatchEvent(new Event('change', { bubbles: true }));
document.querySelector('#usage-dashboard-apply').click();
return true;
})()`);
try {
await waitFor(() => evaluate(cdp, `document.querySelector('#usage-dashboard-panel').dataset.state === 'ready'
&& document.querySelector('[data-usage-metric="newSessions"]').textContent !== '0'
&& document.querySelectorAll('[data-usage-spark] canvas').length === 5
&& document.querySelectorAll('#usage-dashboard-trend canvas').length === 1
&& document.querySelectorAll('.usage-dashboard__donut-chart canvas').length === 3
&& document.querySelectorAll('#usage-dashboard-project-rows li').length === 4
&& !/Skill/i.test(document.querySelector('#usage-dashboard-panel').innerText)`), 30000);
} catch (error) {
const diagnostic = await evaluate(cdp, `(() => ({
state: document.querySelector('#usage-dashboard-panel').dataset.state,
error: document.querySelector('#usage-dashboard-error').textContent,
from: document.querySelector('#usage-dashboard-from').value,
to: document.querySelector('#usage-dashboard-to').value,
applyDisabled: document.querySelector('#usage-dashboard-apply').disabled,
newSessions: document.querySelector('[data-usage-metric="newSessions"]').textContent,
status: document.querySelector('#usage-dashboard-status').textContent,
}))()`);
throw new Error(`自定义统计查询未返回预期数据:${JSON.stringify(diagnostic)}${error.message}`);
}
await evaluate(cdp, `(() => {
const rows = document.querySelectorAll('#usage-dashboard-mcp-rows tr').length;
const more = document.querySelector('#usage-dashboard-mcp-more');
if (rows !== 6 || !more || more.hidden || !/其余/.test(more.textContent)) {
throw new Error(JSON.stringify({ rows, moreHidden: more?.hidden, moreText: more?.textContent }));
}
more.click();
return true;
})()`);
await waitFor(() => evaluate(cdp, `document.querySelectorAll('#usage-dashboard-mcp-rows tr').length === 10
&& document.querySelector('#usage-dashboard-mcp-more').getAttribute('aria-expanded') === 'true'`), 5000);
await evaluate(cdp, "document.querySelector('#usage-dashboard-mcp-more').click(); true");
await waitFor(() => evaluate(cdp, "document.querySelectorAll('#usage-dashboard-mcp-rows tr').length === 6"), 5000);
const report = { generatedAt: new Date().toISOString(), chrome: CHROME_PATH, views: [], themes: [] };
const viewports = [
{ width: 1467, height: 943, name: 'reference' },
{ width: 1024, height: 768, name: 'desktop-narrow' },
{ width: 768, height: 1024, name: 'tablet' },
{ width: 390, height: 844, name: 'mobile' },
];
await evaluate(cdp, "document.documentElement.dataset.theme = 'washi'; document.querySelector('.usage-dashboard__body').scrollTop = 0; true");
for (const viewport of viewports) {
await setViewport(cdp, viewport.width, viewport.height);
await evaluate(cdp, "document.querySelector('.usage-dashboard__body').scrollTop = 0; true");
await sleep(100);
await capture(cdp, `dashboard-washi-${viewport.width}x${viewport.height}.png`);
report.views.push(await collectGeometry(cdp, 'washi', viewport.width, viewport.height));
}
await setViewport(cdp, 1467, 943);
await evaluate(cdp, "document.querySelector('.usage-dashboard__body').scrollTop = 0; document.querySelector('.usage-dashboard__rate-action')?.click(); true");
await waitFor(() => evaluate(cdp, "!document.querySelector('#usage-dashboard-detail').hidden"), 5000);
await sleep(220);
await capture(cdp, 'dashboard-washi-detail-1467x943.png');
await evaluate(cdp, "document.querySelector('#usage-dashboard-detail-close').click(); document.querySelector('.usage-dashboard__body').scrollTop = 0; true");
await setViewport(cdp, 1440, 900);
for (const theme of THEMES) {
await evaluate(cdp, `document.documentElement.dataset.theme = ${JSON.stringify(theme)}; document.querySelector('.usage-dashboard__body').scrollTop = 0; true`);
await sleep(80);
await capture(cdp, `dashboard-theme-${theme}-1440x900.png`);
report.themes.push(await collectGeometry(cdp, theme, 1440, 900));
}
fs.writeFileSync(REPORT_PATH, JSON.stringify(report, null, 2));
process.stdout.write(`${JSON.stringify({ report: REPORT_PATH, screenshots: fs.readdirSync(OUTPUT_DIR).length }, null, 2)}\n`);
} finally {
if (cdp?.socket?.readyState === WebSocket.OPEN) cdp.socket.close();
await stopChildProcess(chrome);
await stopChildProcess(server);
fs.rmSync(tempRoot, { recursive: true, force: true });
}
}
main().catch((error) => {
console.error(error.stack || error.message || String(error));
process.exitCode = 1;
});