From 6d6ca28cba1492e500c24a66e7e08aa9bfed94d5 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Sun, 23 Aug 2026 07:29:10 -0700 Subject: [PATCH 1/6] feat(zh): localize data and tool workflows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrite Reliability, Submissions, Historical, CollectiveX, AI Chart, and Feedback surfaces with complete locale-aware states, accessibility copy, safe errors, analytics, and responsive E2E coverage. 中文:完整重写可靠性、提交记录、历史趋势、CollectiveX、AI 图表与反馈相关界面的中文文案,并补齐状态提示、无障碍文案、安全错误处理、交互分析及响应式端到端测试。 --- .../cypress/component/feedback-modal.cy.tsx | 49 ++- packages/app/cypress/e2e/ai-chart.cy.ts | 121 ++++++++ packages/app/cypress/e2e/collectivex.cy.ts | 58 +++- .../app/cypress/e2e/historical-trends.cy.ts | 56 +++- .../app/cypress/e2e/reliability-chart.cy.ts | 43 +++ packages/app/cypress/e2e/zh-pages.cy.ts | 136 +++++++++ .../components/ai-chart/AiChartDisplay.tsx | 24 +- .../src/components/ai-chart/AiChartResult.tsx | 213 ++++++++----- .../components/ai-chart/example-prompts.ts | 26 +- .../ai-chart/prompt-templates.test.ts | 62 ++++ .../components/ai-chart/prompt-templates.ts | 20 +- .../app/src/components/ai-chart/types.test.ts | 6 + packages/app/src/components/ai-chart/types.ts | 10 +- .../collectivex/CollectiveXChart.tsx | 287 +++++++++++------- .../collectivex/CollectiveXDisplay.tsx | 97 +++--- .../collectivex/CollectiveXKvChart.tsx | 241 +++++++++------ .../collectivex/CollectiveXKvSection.tsx | 97 ++++-- .../collectivex/CollectiveXRunsTable.tsx | 42 +-- .../app/src/components/feedback-modal.tsx | 99 ++++-- .../feedback-viewer/FeedbackViewer.tsx | 48 ++- .../components/inference/ui/TrendChart.tsx | 96 ++++-- .../components/reliability/ui/BarChartD3.tsx | 52 +++- .../reliability/ui/ChartControls.tsx | 2 +- .../reliability/ui/ChartDisplay.tsx | 2 +- .../submissions/SubmissionsChart.tsx | 88 ++++-- .../submissions/SubmissionsDisplay.tsx | 56 +++- .../submissions/SubmissionsTable.tsx | 87 ++++-- .../trends/HistoricalTrendsDisplay.tsx | 53 +++- .../app/src/hooks/api/ai-chart-data.test.ts | 15 + packages/app/src/hooks/api/ai-chart-data.ts | 41 +++ packages/app/src/hooks/api/use-ai-chart.ts | 173 ++++++----- 31 files changed, 1765 insertions(+), 635 deletions(-) create mode 100644 packages/app/src/components/ai-chart/prompt-templates.test.ts diff --git a/packages/app/cypress/component/feedback-modal.cy.tsx b/packages/app/cypress/component/feedback-modal.cy.tsx index f42e883dd..a27f45052 100644 --- a/packages/app/cypress/component/feedback-modal.cy.tsx +++ b/packages/app/cypress/component/feedback-modal.cy.tsx @@ -43,7 +43,7 @@ describe('FeedbackForm', () => { cy.contains('Thanks for your feedback!').should('be.visible'); cy.then(() => expect(submittedFired).to.be.true); // Success-hold is 2s; onDismiss fires after. - cy.get('@onDismiss', { timeout: 3000 }).should('have.been.calledOnce'); + cy.get('@onDismiss').should('have.been.calledOnce'); }); it('surfaces a 429 as a user-readable error', () => { @@ -54,4 +54,51 @@ describe('FeedbackForm', () => { cy.wait('@post'); cy.contains('Too many submissions').should('be.visible'); }); + + it('renders validation, privacy, controls, and success in Chinese', () => { + cy.viewport(1440, 900); + cy.intercept('POST', '/api/v1/feedback', { statusCode: 204 }).as('postZh'); + cy.mount(); + + cy.contains('h2', '帮助我们改进 InferenceX').should('be.visible'); + cy.contains('您的反馈会加密保存').should('be.visible'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.contains('[role="alert"]', '请至少填写一项。').should('be.visible'); + + cy.get('[data-testid="feedback-doing-well"]').type('图表很清晰'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.wait('@postZh'); + cy.contains('感谢您的反馈!').should('be.visible'); + }); + + it('localizes rate-limit and server errors on Chinese routes', () => { + cy.intercept('POST', '/api/v1/feedback', { statusCode: 429 }).as('rateLimited'); + cy.mount(); + cy.get('[data-testid="feedback-doing-well"]').type('反馈'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.wait('@rateLimited'); + cy.contains('[role="alert"]', '提交次数过多,请稍后再试。').should('be.visible'); + }); + + it('does not expose a raw network error on Chinese routes', () => { + cy.intercept('POST', '/api/v1/feedback', { forceNetworkError: true }).as('networkFailure'); + cy.mount(); + cy.get('[data-testid="feedback-doing-well"]').type('反馈'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.wait('@networkFailure'); + cy.contains('[role="alert"]', '出现意外错误,请重试。').should('be.visible'); + cy.contains('Failed to fetch').should('not.exist'); + }); + + for (const width of [375, 390]) { + it(`keeps the Chinese form inside a ${width}px viewport`, () => { + cy.viewport(width, 667); + cy.mount(); + + cy.get('[data-testid="feedback-modal-submit"]').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } }); diff --git a/packages/app/cypress/e2e/ai-chart.cy.ts b/packages/app/cypress/e2e/ai-chart.cy.ts index f9ba41bc8..262fdfb09 100644 --- a/packages/app/cypress/e2e/ai-chart.cy.ts +++ b/packages/app/cypress/e2e/ai-chart.cy.ts @@ -169,3 +169,124 @@ describe('AI chart metric semantics', () => { }); }); }); + +describe('AI chart Chinese workflow', () => { + it('requests Chinese presentation copy and renders the returned chart and summary', () => { + cy.viewport(1440, 900); + cy.fixture('api/benchmarks.json').then((fixtureRows) => { + const rows = [ + fixtureRow(fixtureRows, 'b200', 40, 12_000), + fixtureRow(fixtureRows, 'mi355x', 40, 10_000), + ]; + const spec = benchmarkSpec({ + title: '每芯片吞吐量对比', + description: '在目标交互性下对比两种芯片。', + yAxisLabel: '每芯片吞吐量', + }); + + cy.intercept('GET', '**/api/v1/benchmarks?*', rows).as('zhBenchmarks'); + cy.intercept('POST', 'https://api.openai.com/v1/chat/completions', (request) => { + const systemPrompt = request.body.messages?.[0]?.content ?? ''; + if (systemPrompt.includes('chart generation assistant')) { + expect(systemPrompt).to.contain('natural Simplified Chinese'); + request.reply({ choices: [{ message: { content: JSON.stringify(spec) } }] }); + return; + } + + expect(systemPrompt).to.contain('用自然、准确的简体中文回答'); + request.reply({ choices: [{ message: { content: 'B200 在该配置下吞吐量更高。' } }] }); + }).as('zhOpenAi'); + + cy.visit('/zh/ai-chart'); + cy.get('input[placeholder="OpenAI API Key"]').type('test-api-key', { log: false }); + cy.get('textarea[placeholder="描述您想查看的图表……"]').type( + '对比 B200 和 MI355X 的每芯片吞吐量', + ); + cy.contains('button', '生成图表').click(); + + cy.wait('@zhBenchmarks'); + cy.get('#ai-chart-bar-export') + .should('contain.text', '每芯片吞吐量对比') + .and('contain.text', '在目标交互性下对比两种芯片。') + .and('contain.text', '每芯片吞吐量'); + cy.get('[role="img"][aria-label="AI 生成的条形图"]').should('be.visible'); + cy.contains('[data-slot="card-title"]', 'AI 总结').should('be.visible'); + cy.contains('B200 在该配置下吞吐量更高。').should('be.visible'); + }); + }); + + it('shows a localized empty state for one unmatched chart in a multi-chart result', () => { + cy.fixture('api/benchmarks.json').then((fixtureRows) => { + const rows = [fixtureRow(fixtureRows, 'b200', 40, 12_000)]; + const specs = [ + benchmarkSpec({ + title: 'B200 吞吐量', + description: '实测配置。', + hardwareKeys: ['b200'], + yAxisLabel: '每芯片吞吐量', + }), + benchmarkSpec({ + title: 'H100 吞吐量', + description: '当前数据集中没有匹配项。', + hardwareKeys: ['h100'], + yAxisLabel: '每芯片吞吐量', + }), + ]; + + cy.intercept('GET', '**/api/v1/benchmarks?*', rows).as('multiChartBenchmarks'); + cy.intercept('POST', 'https://api.openai.com/v1/chat/completions', (request) => { + const systemPrompt = request.body.messages?.[0]?.content ?? ''; + request.reply({ + choices: [ + { + message: { + content: systemPrompt.includes('chart generation assistant') + ? JSON.stringify(specs) + : '已对比可用数据。', + }, + }, + ], + }); + }); + + cy.visit('/zh/ai-chart'); + cy.get('input[placeholder="OpenAI API Key"]').type('test-api-key', { log: false }); + cy.get('textarea').type('生成两张吞吐量图表'); + cy.contains('button', '生成图表').click(); + + cy.wait('@multiChartBenchmarks'); + cy.contains('H100 吞吐量') + .closest('[data-slot="card"]') + .should('contain.text', '没有数据符合这项图表配置。'); + }); + }); + + it('does not expose a provider error body or API key on the Chinese route', () => { + cy.intercept('POST', 'https://api.openai.com/v1/chat/completions', { + statusCode: 401, + body: { error: { message: 'provider-internal-error sk-sensitive-example-key' } }, + }).as('failedProvider'); + cy.visit('/zh/ai-chart'); + cy.get('input[placeholder="OpenAI API Key"]').type('sk-sensitive-example-key', { log: false }); + cy.get('textarea').type('对比吞吐量'); + cy.contains('button', '生成图表').click(); + cy.wait('@failedProvider'); + + cy.contains('图表请求失败。请检查 API 密钥和服务商设置后重试。').should('be.visible'); + cy.contains('provider-internal-error').should('not.exist'); + cy.get('[data-testid="ai-chart-error"]').should('not.contain.text', 'sk-sensitive-example-key'); + }); + + for (const width of [375, 390]) { + it(`keeps Chinese provider controls and examples within ${width}px`, () => { + cy.viewport(width, 844); + cy.visit('/zh/ai-chart'); + cy.get('input[placeholder="OpenAI API Key"]').should('be.visible'); + cy.get('textarea[placeholder="描述您想查看的图表……"]').should('be.visible'); + cy.contains('示例提示').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } +}); diff --git a/packages/app/cypress/e2e/collectivex.cy.ts b/packages/app/cypress/e2e/collectivex.cy.ts index 204e7a69d..0db9eed05 100644 --- a/packages/app/cypress/e2e/collectivex.cy.ts +++ b/packages/app/cypress/e2e/collectivex.cy.ts @@ -418,6 +418,45 @@ describe('CollectiveX neutral run view', () => { .and('contain.text', 'EP') .and('contain.text', 'KV'); }); + + it('localizes the complete chart and run-table click path on the Chinese route', () => { + cy.viewport(1440, 900); + cy.visit('/zh/collectivex'); + cy.wait('@runs'); + cy.wait('@run'); + + cy.get('[data-testid="collectivex-run-conclusion"]') + .should('contain.text', `#${runId}`) + .and('contain.text', '成功'); + cy.get('[data-testid="collectivex-runs"]') + .should('contain.text', '运行记录') + .and('contain.text', '终态数据点'); + cy.get('[data-testid="collectivex-main-chart"]') + .should('contain.text', '往返(实测)') + .and('contain.text', '解码') + .and('contain.text', '延迟(µs)'); + + cy.get('[data-testid="collectivex-explorer-chart"] .point').first().click({ force: true }); + cy.get('[data-chart-tooltip]:visible') + .should('contain.text', '点击其他区域关闭') + .and('contain.text', '往返') + .and('contain.text', '延迟 p50 / p90 / p95 / p99'); + }); + + for (const width of [375, 390]) { + it(`keeps the Chinese explorer and runs table reachable at ${width}px`, () => { + cy.viewport(width, 844); + cy.visit('/zh/collectivex'); + cy.wait('@runs'); + cy.wait('@run'); + + cy.get('[data-testid="collectivex-main-chart"] svg').should('exist'); + cy.get('[data-testid="collectivex-runs-table"]').scrollTo('right').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } }); describe('CollectiveX run deletion', () => { @@ -545,7 +584,8 @@ describe('CollectiveX availability states', () => { cy.wait('@missing'); cy.get('[data-testid="collectivex-error"]') .should('be.visible') - .and('contain.text', 'API error: 404'); + .and('contain.text', 'The CollectiveX dataset failed to load.') + .and('not.contain.text', 'API error: 404'); cy.get('[data-testid="collectivex-error-version-select"]').should('contain.text', 'V1'); }); @@ -558,7 +598,21 @@ describe('CollectiveX availability states', () => { cy.wait('@down'); cy.get('[data-testid="collectivex-error"]') .should('be.visible') - .and('contain.text', 'API error: 503'); + .and('contain.text', 'The CollectiveX dataset failed to load.') + .and('not.contain.text', 'API error: 503'); + }); + + it('shows a safe localized error on the Chinese route', () => { + cy.intercept('GET', '/api/v1/collectivex/runs?*', { + statusCode: 503, + body: { error: 'collectivex-internal-storage-detail' }, + }).as('zhDown'); + cy.visit('/zh/collectivex'); + cy.wait('@zhDown'); + cy.get('[data-testid="collectivex-error"]') + .should('contain.text', 'CollectiveX 运行暂不可用') + .and('contain.text', 'CollectiveX 数据集加载失败。') + .and('not.contain.text', 'collectivex-internal-storage-detail'); }); it('renders the loading state while the run resolves', () => { diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index 51c9d5b21..4e9ec4520 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -76,8 +76,6 @@ describe('Historical Trends — Content & Interactions', () => { doc.body.style.removeProperty('pointer-events'); }); cy.get('[data-testid="model-selector"]').should('be.visible'); - // Radix Select may need a brief settle after scroll lock removal - cy.wait(100); cy.get('[data-testid="model-selector"]').click(); cy.get('[role="option"]').should('have.length.greaterThan', 0); cy.get('body').type('{esc}'); @@ -157,7 +155,6 @@ describe('Historical Trends — Content & Interactions', () => { doc.body.style.removeProperty('pointer-events'); }); cy.get('[data-testid="historical-trend-figure"]').should('exist'); - cy.wait(100); cy.get('[data-testid="historical-trend-figure"] figcaption p') .first() @@ -187,3 +184,56 @@ describe('Historical Trends — Content & Interactions', () => { }); }); }); + +describe('Historical Trends — Chinese route', () => { + beforeEach(() => { + cy.visit('/zh/historical', { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-star-modal-dismissed', String(Date.now())); + }, + }); + cy.get('[data-testid="historical-trends-display"]').should('be.visible'); + }); + + it('localizes the metric title, chart instructions, and point tooltip', () => { + cy.viewport(1440, 900); + cy.get('[data-testid="historical-trend-figure"] h2').should('contain.text', '随时间变化'); + cy.get('[data-testid="historical-trend-figure"]').should('contain.text', 'Shift+滚轮横向缩放'); + cy.get('[data-testid="trend-chart-svg"] circle').first().click({ force: true }); + cy.get('[data-chart-tooltip]:visible') + .should('contain.text', '点击其他区域关闭') + .invoke('text') + .should('match', /\d{4}年/u); + }); + + it('shows a settled Chinese empty state instead of leaving the skeleton mounted', () => { + cy.intercept('GET', '**/api/v1/benchmarks?*', []).as('emptyBenchmarks'); + cy.reload(); + cy.wait('@emptyBenchmarks'); + cy.contains('所选模型和序列无可用的交互性图表数据。').should('be.visible'); + cy.get('[data-testid="historical-trends-display"] .animate-pulse').should('not.exist'); + }); + + it('shows a safe Chinese error when benchmark loading fails', () => { + cy.intercept('GET', '**/api/v1/benchmarks?*', { + statusCode: 500, + body: { error: 'historical-database-internal-detail' }, + }).as('failedBenchmarks'); + cy.reload(); + cy.wait('@failedBenchmarks'); + cy.contains('历史基准测试数据加载失败。').should('be.visible'); + cy.contains('historical-database-internal-detail').should('not.exist'); + }); + + for (const width of [375, 390]) { + it(`keeps the target controls and chart reachable at ${width}px`, () => { + cy.viewport(width, 844); + cy.get('[data-testid="historical-trends-display"] input[type="range"]').should('be.visible'); + cy.get('[data-testid="historical-trends-display"] input[type="number"]').should('be.visible'); + cy.get('[data-testid="historical-trend-figure"] svg').should('exist'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } +}); diff --git a/packages/app/cypress/e2e/reliability-chart.cy.ts b/packages/app/cypress/e2e/reliability-chart.cy.ts index 8b17cf6c8..14c16a51a 100644 --- a/packages/app/cypress/e2e/reliability-chart.cy.ts +++ b/packages/app/cypress/e2e/reliability-chart.cy.ts @@ -156,3 +156,46 @@ describe('Reliability Chart — Content & Interactions', () => { }); }); }); + +describe('Reliability Chart — Chinese route and settled states', () => { + it('localizes chart chrome, SVG labels, and accessibility text', () => { + cy.viewport(1440, 900); + cy.visit('/zh/reliability'); + cy.get('[data-testid="reliability-chart-display"]').should('be.visible'); + cy.contains('h2', '芯片可靠性').should('be.visible'); + cy.get('#reliability-chart svg').should('contain.text', '成功率(%)'); + cy.get('#reliability-chart svg .overlay-label').first().should('contain.text', '次运行'); + cy.get('#reliability-chart').closest('figure').should('contain.text', 'Shift+滚轮横向缩放'); + }); + + it('shows a Chinese empty state only after an empty response settles', () => { + cy.intercept('GET', '**/api/v1/reliability', []).as('emptyReliability'); + cy.visit('/zh/reliability'); + cy.wait('@emptyReliability'); + cy.contains('所选时间范围内暂无可靠性数据。').should('be.visible'); + cy.contains('正在加载可靠性数据……').should('not.exist'); + }); + + it('shows a safe Chinese error instead of a raw API response', () => { + cy.intercept('GET', '**/api/v1/reliability', { + statusCode: 500, + body: { error: 'database-internal-detail' }, + }).as('failedReliability'); + cy.visit('/zh/reliability'); + cy.wait('@failedReliability'); + cy.contains('可靠性数据加载失败。').should('be.visible'); + cy.contains('database-internal-detail').should('not.exist'); + }); + + for (const width of [375, 390]) { + it(`keeps controls reachable without body overflow at ${width}px`, () => { + cy.viewport(width, 844); + cy.visit('/zh/reliability'); + cy.get('[data-testid="reliability-date-range"]').should('be.visible').click(); + cy.contains('[role="option"]', '全部时间').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } +}); diff --git a/packages/app/cypress/e2e/zh-pages.cy.ts b/packages/app/cypress/e2e/zh-pages.cy.ts index 7e114d37c..0b8ca2d80 100644 --- a/packages/app/cypress/e2e/zh-pages.cy.ts +++ b/packages/app/cypress/e2e/zh-pages.cy.ts @@ -153,6 +153,142 @@ describe('Chinese (/zh) pages', () => { }); }); + describe('zh submissions workflow', () => { + beforeEach(() => { + cy.visit('/zh/submissions'); + cy.get('[data-testid="submissions-display"]').should('be.visible'); + }); + + it('localizes chart controls, table headers, sorting, and expanded details', () => { + cy.viewport(1440, 900); + cy.contains('h2', '基准测试提交').should('be.visible'); + cy.get('[data-testid="submissions-mode-toggle"]') + .should('have.attr', 'aria-label', '图表模式') + .and('contain.text', '按周') + .and('contain.text', '累计'); + cy.contains('th button', '投机解码').should('be.visible'); + cy.contains('th button', '数据点').click().parent('th').should('have.attr', 'aria-sort'); + cy.get('button[aria-label="展开配置详情"]').first().click(); + cy.get('[data-testid="submissions-display"]') + .should('contain.text', '投机解码方法:') + .and('contain.text', '预填充') + .and('contain.text', '解码'); + }); + + it('separates localized empty chart and table states', () => { + cy.intercept('GET', '**/api/v1/submissions', { body: { summary: [], volume: [] } }).as( + 'emptySubmissions', + ); + cy.reload(); + cy.wait('@emptySubmissions'); + cy.contains('暂无提交活动数据。').should('be.visible'); + cy.contains('暂无提交记录。').should('be.visible'); + }); + + it('shows a safe error and retries through a real button click', () => { + let attempts = 0; + cy.intercept('GET', '**/api/v1/submissions', (request) => { + attempts += 1; + request.reply( + attempts <= 2 + ? { statusCode: 500, body: { error: 'submissions-database-internal-detail' } } + : { body: { summary: [], volume: [] } }, + ); + }).as('retrySubmissions'); + cy.reload(); + cy.wait('@retrySubmissions'); + cy.wait('@retrySubmissions'); + cy.contains('加载提交数据失败。').should('be.visible'); + cy.contains('submissions-database-internal-detail').should('not.exist'); + cy.contains('button', '重试').click(); + cy.wait('@retrySubmissions'); + cy.contains('暂无提交记录。').should('be.visible'); + }); + + for (const width of [375, 390]) { + it(`keeps the table available through horizontal scrolling at ${width}px`, () => { + cy.viewport(width, 844); + cy.get('[data-testid="submissions-display"] table').should('be.visible'); + cy.get('[data-testid="submissions-display"] .overflow-x-auto') + .scrollTo('right') + .should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } + }); + + describe('zh feedback viewer workflow', () => { + beforeEach(() => { + cy.intercept('GET', '**/api/v1/feedback/list', { body: { rows: [] } }).as('feedbackList'); + cy.visit('/zh/feedback', { + onBeforeLoad(win) { + win.localStorage.setItem('inferencex-feature-gate', '1'); + }, + }); + cy.wait('@feedbackList'); + }); + + it('localizes the empty state, key validation, and accessibility label', () => { + cy.viewport(1440, 900); + cy.get('[data-testid="feedback-viewer"]') + .should('contain.text', '用户反馈') + .and('contain.text', '暂无反馈记录。'); + cy.get('button[aria-label="显示密钥"]').should('be.visible'); + cy.get('[data-testid="feedback-key-input"]').type('invalid-key'); + cy.get('[data-testid="feedback-key-submit"]').click(); + cy.get('[role="alert"]').should('contain.text', '解密密钥必须是有效的 base64 编码'); + }); + + it('shows a safe fetch error and retries through a real button click', () => { + let attempts = 0; + cy.intercept('GET', '**/api/v1/feedback/list', (request) => { + attempts += 1; + request.reply( + attempts <= 2 + ? { statusCode: 500, body: { error: 'feedback-database-internal-detail' } } + : { body: { rows: [] } }, + ); + }).as('retryFeedbackList'); + cy.reload(); + cy.wait('@retryFeedbackList'); + cy.wait('@retryFeedbackList'); + cy.contains('无法加载反馈数据。').should('be.visible'); + cy.contains('feedback-database-internal-detail').should('not.exist'); + cy.contains('button', '重试').click(); + cy.wait('@retryFeedbackList'); + cy.contains('暂无反馈记录。').should('be.visible'); + }); + + for (const width of [375, 390]) { + it(`keeps the key controls and content within ${width}px`, () => { + cy.viewport(width, 844); + cy.get('[data-testid="feedback-key-input"]').should('be.visible'); + cy.get('[data-testid="feedback-key-submit"]').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); + }); + }); + } + }); + + it('uses the route locale in the global feedback modal and dismisses it through the UI', () => { + cy.viewport(1440, 900); + cy.visit('/zh/inference', { + onBeforeLoad(win) { + win.localStorage.removeItem('inferencex-feedback-modal-snoozed'); + }, + }); + + cy.get('[data-testid="feedback-modal"]') + .should('be.visible') + .and('contain.text', '帮助我们改进 InferenceX') + .and('contain.text', '您的反馈会加密保存'); + cy.get('[data-testid="feedback-modal-dismiss"]').click(); + cy.get('[data-testid="feedback-modal"]').should('not.exist'); + }); + describe('English pages expose the Chinese sibling', () => { before(() => { cy.visit('/blog'); diff --git a/packages/app/src/components/ai-chart/AiChartDisplay.tsx b/packages/app/src/components/ai-chart/AiChartDisplay.tsx index 4cb5229af..11c5a37e4 100644 --- a/packages/app/src/components/ai-chart/AiChartDisplay.tsx +++ b/packages/app/src/components/ai-chart/AiChartDisplay.tsx @@ -43,7 +43,7 @@ const STRINGS = { zh: { title: 'AI 图表生成', description: - '用自然语言描述您想要的图表。您的 API 密钥仅存储在浏览器中,只发送给您选择的服务商,我们绝不会读取。', + '用自然语言描述所需图表。API 密钥仅保存在浏览器中,并只发送给所选服务商;InferenceX 无法读取密钥。', placeholder: '描述您想查看的图表……', enterToGenerate: '+Enter 生成', generating: '生成中……', @@ -67,9 +67,10 @@ export default function AiChartDisplay() { const [prompt, setPrompt] = useState(''); const [showKey, setShowKey] = useState(false); const [isMac, setIsMac] = useState(false); - const { result, isLoading, error, generate, reset } = useAiChart(); const locale = useLocale(); + const { result, isLoading, error, generate, reset } = useAiChart(locale); const t = STRINGS[locale]; + const examples = EXAMPLE_PROMPTS[locale]; useEffect(() => { setIsMac(navigator.userAgent.includes('Mac')); @@ -144,7 +145,10 @@ export default function AiChartDisplay() { @@ -216,7 +228,7 @@ export default function AiChartDisplay() {

{t.examplePrompts}

- {EXAMPLE_PROMPTS.map((example, i) => ( + {examples.map((example, i) => (
- ${spec.yAxisLabel}: ${d.value.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ${spec.yAxisLabel}: ${d.value.toLocaleString(numberLocale, { maximumFractionDigits: 2 })}
`), }), - [spec.yAxisLabel], + [numberLocale, spec.yAxisLabel], ); return ( - +
+ +
); } @@ -132,6 +168,9 @@ function ScatterChart({ spec: AiChartSpec; colorMap: Record; }) { + const locale = useLocale(); + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : undefined; const xExtent = useMemo(() => { const xs = data.map((d) => d.x); return [Math.min(...xs) * 0.9, Math.max(...xs) * 1.1] as [number, number]; @@ -151,7 +190,7 @@ function ScatterChart({ [yExtent], ); - const xAxis = useMemo(() => ({ label: 'Interactivity (tok/s/user)' }), []); + const xAxis = useMemo(() => ({ label: t.interactivityAxis }), [t.interactivityAxis]); const yAxis = useMemo(() => ({ label: spec.yAxisLabel }), [spec.yAxisLabel]); const layers = useMemo(() => { @@ -175,38 +214,40 @@ function ScatterChart({ ${hwKey}
- Interactivity: ${d.x.toFixed(1)} tok/s/user + ${t.interactivity}: ${d.x.toFixed(1)} tok/s/user
- ${spec.yAxisLabel}: ${d.y.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ${spec.yAxisLabel}: ${d.y.toLocaleString(numberLocale, { maximumFractionDigits: 2 })}
`); }, }), - [colorMap, spec.yAxisLabel], + [colorMap, numberLocale, spec.yAxisLabel, t.interactivity], ); return ( - +
+ +
); } @@ -230,6 +271,9 @@ function LineChart({ spec: AiChartSpec; colorMap: Record; }) { + const locale = useLocale(); + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : undefined; const flatPoints = useMemo( () => Object.entries(lineData).flatMap(([hwKey, pts]) => @@ -259,7 +303,7 @@ function LineChart({ [yExtent], ); - const xAxis = useMemo(() => ({ label: 'Interactivity (tok/s/user)' }), []); + const xAxis = useMemo(() => ({ label: t.interactivityAxis }), [t.interactivityAxis]); const yAxis = useMemo(() => ({ label: spec.yAxisLabel }), [spec.yAxisLabel]); const layers = useMemo(() => { @@ -298,10 +342,10 @@ function LineChart({ ${label}
- Interactivity: ${d.x.toFixed(1)} tok/s/user + ${t.interactivity}: ${d.x.toFixed(1)} tok/s/user
- ${spec.yAxisLabel}: ${d.y.toLocaleString(undefined, { maximumFractionDigits: 2 })} + ${spec.yAxisLabel}: ${d.y.toLocaleString(numberLocale, { maximumFractionDigits: 2 })}
`); }, @@ -309,30 +353,32 @@ function LineChart({ getRulerY: (d, yS) => (yS as any)(d.y), attachToLayer: 1, }), - [colorMap, spec.yAxisLabel], + [colorMap, numberLocale, spec.yAxisLabel, t.interactivity], ); return ( - +
+ +
); } @@ -347,6 +393,9 @@ function RadarChart({ data: AiRadarItem[]; axes: { label: string; unit?: string }[]; }) { + const locale = useLocale(); + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : undefined; const layers = useMemo(() => { const radarLayer: RadarLayerConfig = { type: 'radar', @@ -374,7 +423,7 @@ function RadarChart({ const raw = d.rawValues[i]; return raw === null ? '' - : `
${axis.label}: ${raw.toLocaleString(undefined, { maximumFractionDigits: 2 })}
`; + : `
${axis.label}: ${raw.toLocaleString(numberLocale, { maximumFractionDigits: 2 })}
`; }) .filter(Boolean) .join(''); @@ -387,23 +436,25 @@ function RadarChart({ `); }, }), - [axes], + [axes, numberLocale], ); // Radar ignores scales/axes — it draws its own grid. Provide dummy scales. const dummyScale = useMemo(() => ({ type: 'linear', domain: [0, 1] }), []); return ( - +
+ +
); } @@ -442,11 +493,18 @@ function buildLegendItems(colorMap: Record): { label: string; co // --------------------------------------------------------------------------- export default function AiChartResult({ charts, summary }: AiChartResultProps) { + const locale = useLocale(); + const t = STRINGS[locale]; return (
{charts.map((chart, i) => { const chartId = `ai-chart-${chart.spec.chartType}`; const hasZoom = chart.spec.chartType === 'scatter' || chart.spec.chartType === 'line'; + const hasData = + chart.barData.length > 0 || + chart.scatterData.length > 0 || + Object.keys(chart.lineData).length > 0 || + chart.radarData.length > 0; return (
@@ -456,6 +514,9 @@ export default function AiChartResult({ charts, summary }: AiChartResultProps) { {chart.spec.description} + {!hasData && ( +

{t.noData}

+ )} {chart.spec.chartType === 'bar' && chart.barData.length > 0 && ( )} @@ -496,7 +557,7 @@ export default function AiChartResult({ charts, summary }: AiChartResultProps) { {summary && ( - AI Summary + {t.summary}

{summary}

diff --git a/packages/app/src/components/ai-chart/example-prompts.ts b/packages/app/src/components/ai-chart/example-prompts.ts index 3eaec2581..75e8c1f08 100644 --- a/packages/app/src/components/ai-chart/example-prompts.ts +++ b/packages/app/src/components/ai-chart/example-prompts.ts @@ -1,8 +1,18 @@ -export const EXAMPLE_PROMPTS = [ - 'Compare throughput per chip across all chips for DeepSeek R1 at 8k/1k', - 'Bar chart: H100 vs B200 vs GB200 cost per million tokens (hyperscaler) for DeepSeek R1', - 'Compare Kimi K2.5 vs DeepSeek R1 throughput per chip at 8k/1k', - 'Which chip has the best GSM8K accuracy score for DeepSeek R1?', - 'Compare reliability/success rate across all chips', - 'Show a scatter plot of all chip configs for DeepSeek R1 at 8k/1k with throughput per chip', -]; +export const EXAMPLE_PROMPTS = { + en: [ + 'Compare throughput per chip across all chips for DeepSeek R1 at 8k/1k', + 'Bar chart: H100 vs B200 vs GB200 cost per million tokens (hyperscaler) for DeepSeek R1', + 'Compare Kimi K2.5 vs DeepSeek R1 throughput per chip at 8k/1k', + 'Which chip has the best GSM8K accuracy score for DeepSeek R1?', + 'Compare reliability/success rate across all chips', + 'Show a scatter plot of all chip configs for DeepSeek R1 at 8k/1k with throughput per chip', + ], + zh: [ + '对比 DeepSeek R1 在 8k/1k 下各类芯片的单芯片吞吐量', + '用条形图对比 DeepSeek R1 在 H100、B200 和 GB200 上每百万 token 的 hyperscaler 成本', + '对比 Kimi K2.5 与 DeepSeek R1 在 8k/1k 下的单芯片吞吐量', + 'DeepSeek R1 在哪种芯片上的 GSM8K 准确率最高?', + '对比各类芯片的可靠性和运行成功率', + '绘制 DeepSeek R1 在 8k/1k 下所有芯片配置的散点图,纵轴为单芯片吞吐量', + ], +} as const; diff --git a/packages/app/src/components/ai-chart/prompt-templates.test.ts b/packages/app/src/components/ai-chart/prompt-templates.test.ts new file mode 100644 index 000000000..70bbac40c --- /dev/null +++ b/packages/app/src/components/ai-chart/prompt-templates.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest'; + +import { buildParsePrompt, buildSummaryPrompt } from './prompt-templates'; + +describe('AI chart locale instructions', () => { + it('keeps English chart fields and prose instructions unchanged by default', () => { + const prompt = buildParsePrompt(); + const summaryPrompt = buildSummaryPrompt( + [ + { + title: 'B200 throughput', + yAxisLabel: 'Throughput/Chip', + model: 'DeepSeek-R1-0528', + sequence: '8k/1k', + }, + ], + 'B200: 12345.67 tok/s', + ); + + expect(prompt).toContain('"title": "short chart title"'); + expect(prompt).not.toContain('Write title, description, and yAxisLabel in Simplified Chinese'); + expect(summaryPrompt) + .toBe(`You are an expert performance analyst. Based on the following benchmark data, provide a concise 2-3 sentence summary highlighting the key takeaway. + +Chart: B200 throughput | Metric: Throughput/Chip | Model: DeepSeek-R1-0528, Seq: 8k/1k + +Data: +B200: 12345.67 tok/s + +Rules: +- Be technical and precise. Mention specific values and percentage differences. +- Focus on the most interesting comparison or finding. +- No markdown formatting, just plain text.`); + }); + + it('asks for Simplified Chinese presentation fields on Chinese routes', () => { + const prompt = buildParsePrompt('zh'); + + expect(prompt).toContain( + 'Write title, description, and yAxisLabel in natural Simplified Chinese', + ); + expect(prompt).toContain('Keep identifiers, model names, hardware SKUs, and units unchanged'); + }); + + it('asks for a Chinese summary while preserving the benchmark data', () => { + const prompt = buildSummaryPrompt( + [ + { + title: 'B200 vs H100', + yAxisLabel: 'Throughput/Chip', + model: 'DeepSeek-R1-0528', + sequence: '8k/1k', + }, + ], + 'B200: 12345.67 tok/s', + 'zh', + ); + + expect(prompt).toContain('用自然、准确的简体中文回答'); + expect(prompt).toContain('B200: 12345.67 tok/s'); + }); +}); diff --git a/packages/app/src/components/ai-chart/prompt-templates.ts b/packages/app/src/components/ai-chart/prompt-templates.ts index 91b2e5e91..2a07ccaf3 100644 --- a/packages/app/src/components/ai-chart/prompt-templates.ts +++ b/packages/app/src/components/ai-chart/prompt-templates.ts @@ -7,6 +7,7 @@ import { HW_REGISTRY, } from '@semianalysisai/inferencex-constants'; import { Y_AXIS_METRICS } from '@/lib/chart-utils'; +import type { Locale } from '@/lib/i18n'; // --------------------------------------------------------------------------- // Derived enum strings (built once at import time) @@ -32,7 +33,15 @@ const Y_METRIC_LIST = Y_AXIS_METRICS.map((m) => `${m}`).join(', '); * System prompt for the LLM that parses user natural language into AiChartSpec(s). * Domain context is derived from shared constants so it stays in sync automatically. */ -export function buildParsePrompt(): string { +export function buildParsePrompt(locale: Locale = 'en'): string { + const localeInstruction = + locale === 'zh' + ? ` + +## Presentation Language + +Write title, description, and yAxisLabel in natural Simplified Chinese. Keep identifiers, model names, hardware SKUs, and units unchanged. JSON keys and enum values must remain exactly as specified below.` + : ''; return `You are InferenceX's chart generation assistant. Parse natural language into chart specs. ## What InferenceX Is @@ -104,7 +113,7 @@ Be generous with name matching: "deepseek r1" / "DSR1" / "deepseek" → DeepSeek ## Output -Return ONLY valid JSON. No markdown, no preamble, no explanation. +Return ONLY valid JSON. No markdown, no preamble, no explanation.${localeInstruction} Single chart object or array of 2 for comparisons: { @@ -131,7 +140,12 @@ Single chart object or array of 2 for comparisons: export function buildSummaryPrompt( specs: { title: string; yAxisLabel: string; model: string; sequence: string }[], dataDescription: string, + locale: Locale = 'en', ): string { + const localeInstruction = + locale === 'zh' + ? '- 用自然、准确的简体中文回答;保留模型名、硬件 SKU、指标缩写、数字和单位。\n' + : ''; const specSummary = specs .map( (s) => `Chart: ${s.title} | Metric: ${s.yAxisLabel} | Model: ${s.model}, Seq: ${s.sequence}`, @@ -148,5 +162,5 @@ ${dataDescription} Rules: - Be technical and precise. Mention specific values and percentage differences. - Focus on the most interesting comparison or finding. -- No markdown formatting, just plain text.`; +${localeInstruction}- No markdown formatting, just plain text.`; } diff --git a/packages/app/src/components/ai-chart/types.test.ts b/packages/app/src/components/ai-chart/types.test.ts index 1837e1c04..280ca0c77 100644 --- a/packages/app/src/components/ai-chart/types.test.ts +++ b/packages/app/src/components/ai-chart/types.test.ts @@ -13,3 +13,9 @@ describe('validateSpec measured power axes', () => { expect(spec.yAxisMetric).toBe(yAxisMetric); }); }); + +describe('validateSpec locale fallbacks', () => { + it('uses a Chinese fallback title for incomplete Chinese chart specs', () => { + expect(validateSpec({}, 'zh').title).toBe('AI 生成的图表'); + }); +}); diff --git a/packages/app/src/components/ai-chart/types.ts b/packages/app/src/components/ai-chart/types.ts index 30ec6ed47..334b54ab8 100644 --- a/packages/app/src/components/ai-chart/types.ts +++ b/packages/app/src/components/ai-chart/types.ts @@ -1,5 +1,6 @@ import { Model, Sequence, Precision } from '@/lib/data-mappings'; import { Y_AXIS_METRICS } from '@/lib/chart-utils'; +import type { Locale } from '@/lib/i18n'; import { HW_REGISTRY, FRAMEWORK_KEYS } from '@semianalysisai/inferencex-constants'; export type AiProvider = 'openai' | 'anthropic' | 'xai' | 'google'; @@ -80,7 +81,7 @@ const VALID_Y_METRICS = new Set([...Y_AXIS_METRICS, 'eval_score', 'relia const VALID_SORT_ORDERS = new Set(['desc', 'asc', 'registry']); /** Validate and clamp an LLM-generated spec to known values. Throws on unrecoverable input. */ -export function validateSpec(raw: Record): AiChartSpec { +export function validateSpec(raw: Record, locale: Locale = 'en'): AiChartSpec { const chartType = VALID_CHART_TYPES.has(raw.chartType as string) ? (raw.chartType as AiChartType) : 'bar'; @@ -149,7 +150,12 @@ export function validateSpec(raw: Record): AiChartSpec { ? Math.round(raw.topN) : undefined, topNDistinctGpus: raw.topNDistinctGpus !== false, - title: typeof raw.title === 'string' ? raw.title.slice(0, 200) : 'AI Generated Chart', + title: + typeof raw.title === 'string' + ? raw.title.slice(0, 200) + : locale === 'zh' + ? 'AI 生成的图表' + : 'AI Generated Chart', description: typeof raw.description === 'string' ? raw.description.slice(0, 500) : '', }; } diff --git a/packages/app/src/components/collectivex/CollectiveXChart.tsx b/packages/app/src/components/collectivex/CollectiveXChart.tsx index 1d22cc84c..7338fb800 100644 --- a/packages/app/src/components/collectivex/CollectiveXChart.tsx +++ b/packages/app/src/components/collectivex/CollectiveXChart.tsx @@ -4,6 +4,8 @@ import * as d3 from 'd3'; import { useMemo } from 'react'; import { D3Chart } from '@/lib/d3-chart/D3Chart'; +import { useLocale } from '@/lib/use-locale'; +import type { Locale } from '@/lib/i18n'; import { chartPoints, collectiveXColorKey, collectiveXRunDasharray, fitAlphaBeta } from './data'; import type { @@ -26,19 +28,65 @@ interface CollectiveXChartProps { testId?: string; } -const OPERATION_LABELS: Record = { - dispatch: 'Dispatch', - stage: 'Stage', - combine: 'Combine', - roundtrip: 'Round trip (measured)', -}; - -const Y_AXIS_LABELS: Record = { - latency: 'Latency (µs)', - 'tokens-per-second': 'Token rate at selected latency percentile (tokens/s)', - 'activation-rate': 'Activation-data rate at selected latency percentile (GB/s)', - 'payload-rate': 'Payload bandwidth at selected latency percentile (GB/s, per chip)', -}; +const STRINGS = { + en: { + operation: { + dispatch: 'Dispatch', + stage: 'Stage', + combine: 'Combine', + roundtrip: 'Round trip (measured)', + }, + yAxis: { + latency: 'Latency (µs)', + 'tokens-per-second': 'Token rate at selected latency percentile (tokens/s)', + 'activation-rate': 'Activation-data rate at selected latency percentile (GB/s)', + 'payload-rate': 'Payload bandwidth at selected latency percentile (GB/s, per chip)', + }, + xAxis: 'Source tokens / rank (log)', + unavailable: (operation: string) => `${operation} is unavailable for the selected series.`, + noSeries: 'No matching CollectiveX series.', + instructions: + 'Shift+Scroll to zoom · Drag to pan · Double-click to reset · Click a point to pin tooltip', + dismiss: 'Click elsewhere to dismiss', + atLatency: (percentile: string) => `at ${percentile} latency`, + tokenCounts: (perRank: string, global: string) => + `${perRank} tokens/rank · ${global} global tokens`, + latencyRanks: 'Latency p50 / p90 / p95 / p99', + fit: (beta: string, alpha: string) => `Fit β=${beta} GB/s · α=${alpha} µs (p50, per chip)`, + unavailableValue: 'unavailable', + measured: ' (measured)', + roundTrip: 'Round trip', + aria: 'CollectiveX expert-parallel performance chart', + }, + zh: { + operation: { + dispatch: '分发', + stage: '中间处理', + combine: '合并', + roundtrip: '往返(实测)', + }, + yAxis: { + latency: '延迟(µs)', + 'tokens-per-second': '所选延迟分位点的 token 速率(tokens/s)', + 'activation-rate': '所选延迟分位点的激活数据速率(GB/s)', + 'payload-rate': '所选延迟分位点的载荷带宽(GB/s,每芯片)', + }, + xAxis: '每 rank 源 token 数(对数)', + unavailable: (operation: string) => `所选序列没有可用的${operation}数据。`, + noSeries: '没有匹配的 CollectiveX 序列。', + instructions: 'Shift+滚轮缩放 · 拖动平移 · 双击重置 · 点击数据点固定提示框', + dismiss: '点击其他区域关闭', + atLatency: (percentile: string) => `(${percentile} 延迟分位点)`, + tokenCounts: (perRank: string, global: string) => + `每 rank ${perRank} 个 token · 全局 ${global} 个 token`, + latencyRanks: '延迟 p50 / p90 / p95 / p99', + fit: (beta: string, alpha: string) => `拟合 β=${beta} GB/s · α=${alpha} µs(p50,每芯片)`, + unavailableValue: '不可用', + measured: '(实测)', + roundTrip: '往返', + aria: 'CollectiveX 专家并行性能图表', + }, +} as const; function paddedDomain(values: number[]): [number, number] { if (values.length === 0) return [1, 10]; @@ -56,8 +104,10 @@ function formatCompact(value: number): string { return value.toFixed(2); } -function formatTokenCount(value: number): string { - return Number.isInteger(value) ? value.toLocaleString('en-US') : formatCompact(value); +function formatTokenCount(value: number, locale: Locale): string { + return Number.isInteger(value) + ? value.toLocaleString(locale === 'zh' ? 'zh-CN' : 'en-US') + : formatCompact(value); } function formatMetric(value: number, yAxis: CollectiveXYAxis): string { @@ -68,8 +118,9 @@ function formatMetric(value: number, yAxis: CollectiveXYAxis): string { function formatPercentiles( value: CollectiveXRunSeries['points'][number]['components']['dispatch'], + unavailable: string, ): string { - if (value === null) return 'unavailable'; + if (value === null) return unavailable; return `${value.latency_us.p50.toFixed(1)} / ${value.latency_us.p90.toFixed(1)} / ${value.latency_us.p95.toFixed(1)} / ${value.latency_us.p99.toFixed(1)} µs`; } @@ -93,6 +144,8 @@ export function CollectiveXChart({ legendElement, testId, }: CollectiveXChartProps) { + const locale = useLocale(); + const t = STRINGS[locale]; const points = useMemo( () => chartPoints(series, operation, percentile, yAxis), [series, operation, percentile, yAxis], @@ -127,116 +180,118 @@ export function CollectiveXChart({ points.length === 0 ? (

- {series.length > 0 - ? `${OPERATION_LABELS[operation]} is unavailable for the selected series.` - : 'No matching CollectiveX series.'} + {series.length > 0 ? t.unavailable(t.operation[operation]) : t.noSeries}

) : undefined; return ( - - chartId={chartId} - data={points} - height={560} - margin={{ top: 24, right: 20, bottom: 62, left: 78 }} - watermark="logo" - testId={testId} - grabCursor - instructions="Shift+Scroll to zoom · Drag to pan · Double-click to reset · Click a point to pin tooltip" - xScale={{ type: 'log', domain: xDomain, nice: false }} - yScale={{ type: 'log', domain: yDomain, nice: false }} - xAxis={{ - label: 'Source tokens / rank (log)', - tickCount: 8, - tickValues: xTickValues, - tickFormat: (value) => formatTokenCount(Number(value)), - }} - yAxis={{ - label: Y_AXIS_LABELS[yAxis], - tickCount: 5, - tickFormat: (value) => formatCompact(Number(value)), - }} - layers={[ - { - type: 'line', - key: 'collectivex-lines', - lines, - config: { - getColor: (key) => { - const item = seriesById.get(key); - return colors[item ? collectiveXColorKey(item) : ''] ?? '#888'; - }, - getStrokeDasharray: (key) => { - const item = seriesById.get(key); - return item ? collectiveXRunDasharray(item.run_index) : 'none'; +
+ + chartId={chartId} + data={points} + height={560} + margin={{ top: 24, right: 20, bottom: 62, left: 78 }} + watermark="logo" + testId={testId} + grabCursor + instructions={t.instructions} + xScale={{ type: 'log', domain: xDomain, nice: false }} + yScale={{ type: 'log', domain: yDomain, nice: false }} + xAxis={{ + label: t.xAxis, + tickCount: 8, + tickValues: xTickValues, + tickFormat: (value) => formatTokenCount(Number(value), locale), + }} + yAxis={{ + label: t.yAxis[yAxis], + tickCount: 5, + tickFormat: (value) => formatCompact(Number(value)), + }} + layers={[ + { + type: 'line', + key: 'collectivex-lines', + lines, + config: { + getColor: (key) => { + const item = seriesById.get(key); + return colors[item ? collectiveXColorKey(item) : ''] ?? '#888'; + }, + getStrokeDasharray: (key) => { + const item = seriesById.get(key); + return item ? collectiveXRunDasharray(item.run_index) : 'none'; + }, + strokeWidth: 2.25, + curve: d3.curveLinear, }, - strokeWidth: 2.25, - curve: d3.curveLinear, }, - }, - { - type: 'point', - key: 'collectivex-points', - data: points, - config: { - getCx: () => 0, - getCy: () => 0, - getX: (point) => point.x, - getY: (point) => point.y, - getColor: (point) => colors[point.colorKey] ?? '#888', - getRadius: () => 3.5, - stroke: 'var(--background)', - strokeWidth: 1, - keyFn: (point) => `${point.seriesId}-${point.x}`, - maxPoints: Infinity, + { + type: 'point', + key: 'collectivex-points', + data: points, + config: { + getCx: () => 0, + getCy: () => 0, + getX: (point) => point.x, + getY: (point) => point.y, + getColor: (point) => colors[point.colorKey] ?? '#888', + getRadius: () => 3.5, + stroke: 'var(--background)', + strokeWidth: 1, + keyFn: (point) => `${point.seriesId}-${point.x}`, + maxPoints: Infinity, + }, }, - }, - ]} - zoom={{ - enabled: true, - axes: 'both', - scaleExtent: [1, 20], - resetEventName: `collectivex_zoom_reset_${chartId}`, - }} - tooltip={{ - rulerType: 'crosshair', - attachToLayer: 1, - content: (point, isPinned) => { - const color = colors[point.colorKey] ?? '#888'; - const measurement = point.point; - const measuredRoundtrip = measurement.components.roundtrip; - const fit = fitsBySeries.get(point.seriesId); - const fitLine = fit - ? `
Fit β=${fit.betaGbps.toFixed(fit.betaGbps >= 100 ? 0 : 1)} GB/s · α=${fit.alphaUs.toFixed(1)} µs (p50, per chip)
` - : ''; - return `
- ${isPinned ? '
Click elsewhere to dismiss
' : ''} + ]} + zoom={{ + enabled: true, + axes: 'both', + scaleExtent: [1, 20], + resetEventName: `collectivex_zoom_reset_${chartId}`, + }} + tooltip={{ + rulerType: 'crosshair', + attachToLayer: 1, + content: (point, isPinned) => { + const color = colors[point.colorKey] ?? '#888'; + const measurement = point.point; + const measuredRoundtrip = measurement.components.roundtrip; + const fit = fitsBySeries.get(point.seriesId); + const fitLine = fit + ? `
${t.fit(fit.betaGbps.toFixed(fit.betaGbps >= 100 ? 0 : 1), fit.alphaUs.toFixed(1))}
` + : ''; + return `
+ ${isPinned ? `
${t.dismiss}
` : ''}
${escapeHtml(point.seriesLabel)}
-
${escapeHtml(OPERATION_LABELS[operation])} ${yAxis === 'latency' ? percentile : `at ${percentile} latency`}: ${formatMetric(point.y, yAxis)}
-
${measurement.tokens_per_rank} tokens/rank · ${measurement.global_tokens} global tokens
-
Latency p50 / p90 / p95 / p99
-
Dispatch: ${formatPercentiles(measurement.components.dispatch)}
-
Stage: ${formatPercentiles(measurement.components.stage)}
-
Combine: ${formatPercentiles(measurement.components.combine)}
-
Round trip: ${formatPercentiles(measuredRoundtrip)}${measuredRoundtrip ? ' (measured)' : ''}
+
${escapeHtml(t.operation[operation])} ${yAxis === 'latency' ? percentile : t.atLatency(percentile)}: ${formatMetric(point.y, yAxis)}
+
${t.tokenCounts(locale === 'zh' ? measurement.tokens_per_rank.toLocaleString('zh-CN') : String(measurement.tokens_per_rank), locale === 'zh' ? measurement.global_tokens.toLocaleString('zh-CN') : String(measurement.global_tokens))}
+
${t.latencyRanks}
+
${t.operation.dispatch}: ${formatPercentiles(measurement.components.dispatch, t.unavailableValue)}
+
${t.operation.stage}: ${formatPercentiles(measurement.components.stage, t.unavailableValue)}
+
${t.operation.combine}: ${formatPercentiles(measurement.components.combine, t.unavailableValue)}
+
${t.roundTrip}: ${formatPercentiles(measuredRoundtrip, t.unavailableValue)}${measuredRoundtrip ? t.measured : ''}
${fitLine}
`; - }, - getRulerX: (point, scale) => - (scale as d3.ScaleLinear | d3.ScaleLogarithmic)(point.x), - getRulerY: (point, scale) => scale(point.y), - onHoverStart: (selection) => { - selection.attr('r', 6); - }, - onHoverEnd: (selection) => { - selection.attr('r', 3.5); - }, - }} - transitionDuration={200} - legendElement={legendElement} - noDataOverlay={noDataOverlay} - caption={caption} - /> + }, + getRulerX: (point, scale) => + (scale as d3.ScaleLinear | d3.ScaleLogarithmic)( + point.x, + ), + getRulerY: (point, scale) => scale(point.y), + onHoverStart: (selection) => { + selection.attr('r', 6); + }, + onHoverEnd: (selection) => { + selection.attr('r', 3.5); + }, + }} + transitionDuration={200} + legendElement={legendElement} + noDataOverlay={noDataOverlay} + caption={caption} + /> +
); } diff --git a/packages/app/src/components/collectivex/CollectiveXDisplay.tsx b/packages/app/src/components/collectivex/CollectiveXDisplay.tsx index 881e9d648..c141887af 100644 --- a/packages/app/src/components/collectivex/CollectiveXDisplay.tsx +++ b/packages/app/src/components/collectivex/CollectiveXDisplay.tsx @@ -68,11 +68,13 @@ const STRINGS = { en: { operation: { dispatch: 'Dispatch', + stage: 'Stage', combine: 'Combine', roundtrip: 'Round trip', }, operationHeading: { dispatch: 'Dispatch', + stage: 'Stage', combine: 'Combine', roundtrip: 'Round trip (measured)', }, @@ -83,6 +85,7 @@ const STRINGS = { yAxis: { latency: 'Latency', 'tokens-per-second': 'Token rate at selected latency percentile', + 'activation-rate': 'Activation-data rate at selected latency percentile', 'payload-rate': 'Payload bandwidth at selected latency percentile (per chip)', }, all: 'All', @@ -108,6 +111,7 @@ const STRINGS = { suiteAria: 'Filter CollectiveX runs by suite', allSuites: 'All', noSuiteRuns: 'No runs contain the selected suite.', + shownRunCount: (count: number) => `${count} runs shown`, selectRuns: 'Select one or more runs from the table to show their data.', selectedRunsFailed: 'One or more selected runs failed to load.', runControl: 'Run', @@ -128,6 +132,7 @@ const STRINGS = { backend: 'Backend', yAxisControl: 'Y axis', tokenRateOption: 'Token rate at latency percentile', + activationRateOption: 'Activation-data rate at latency percentile', noSeries: 'No measured series match these filters.', resetFilter: 'Reset filter', payloadNote: @@ -146,16 +151,20 @@ const STRINGS = { deleteFailed: 'Deleting the run failed. Try again.', deleteShownFailed: (deleted: number, total: number) => `Deleted ${deleted} of ${total} shown runs before the operation failed. Try again.`, + conclusion: { success: 'success', failure: 'failure', pending: 'pending' }, + atLatency: (percentile: string) => `at ${percentile} latency`, }, zh: { operation: { dispatch: '分发', + stage: '中间处理', combine: '合并', roundtrip: '往返', 'isolated-sum': '分项之和', }, operationHeading: { dispatch: '分发', + stage: '中间处理', combine: '合并', roundtrip: '往返(实测)', 'isolated-sum': '分项之和(派生)', @@ -171,6 +180,7 @@ const STRINGS = { yAxis: { latency: '延迟', 'tokens-per-second': '所选延迟分位点的 token 速率', + 'activation-rate': '所选延迟分位点的激活数据速率', 'payload-rate': '所选延迟分位点的载荷带宽(每芯片)', }, mode: { normal: '常规', 'low-latency': '低延迟' }, @@ -182,45 +192,44 @@ const STRINGS = { 'gate-weighted': '门控加权合并', }, tabs: { - case: 'Selected matrix case', + inventory: '矩阵测试用例清单', + case: '所选矩阵测试用例', evidence: '证据', }, - noCases: 'This run has no matrix cases to inspect.', + noCases: '该运行没有可查看的矩阵测试用例。', all: '全部', - loading: 'Resolving CollectiveX run...', - unavailable: 'CollectiveX run unavailable', - sourceUnavailable: 'The GitHub Actions run source is temporarily unavailable.', - runsErrorMessage: 'No CollectiveX run has been published yet.', - loadError: 'The CollectiveX dataset failed to load.', + loading: '正在加载 CollectiveX 运行数据……', + unavailable: 'CollectiveX 运行暂不可用', + sourceUnavailable: 'GitHub Actions 运行来源暂时不可用。', + runsErrorMessage: '尚未发布 CollectiveX 运行。', + loadError: 'CollectiveX 数据集加载失败。', retry: '重试', description: '对比集合通信库与系统的专家并行(EP)延迟和逻辑载荷速率。', source: '源代码', methodology: '测试方法', - sourceLinkUnavailable: 'Source unavailable because measured series span different revisions', + sourceLinkUnavailable: '实测序列来自不同版本,无法提供统一的源代码链接', refresh: '刷新', - seriesCount: 'Series', - measuredCases: 'Measured cases', + seriesCount: '序列数', + measuredCases: '实测用例', terminalCases: '已终结用例', retainedAttempts: '保留尝试', allocations: '独立分配', publishedUtc: '发布时间(UTC)', version: '基准版本', - // English placeholders per the repository's temporary language override - // (no new Chinese translations); localize when the override lifts. - runsHeading: 'Runs', - runsDescription: - 'Every stored run for the selected benchmark version. Check one or more runs to compare them in the explorer.', - runsShown: 'Runs shown', + runsHeading: '运行记录', + runsDescription: '该基准测试版本下保存的全部运行记录。勾选一条或多条记录,即可在图表中对比。', + runsShown: '已显示运行', + shownRunCount: (count: number) => `已显示 ${count} 个运行`, suiteControl: '测试套件', suiteAria: '按测试套件筛选 CollectiveX 运行', allSuites: '全部', noSuiteRuns: '没有包含所选测试套件的运行。', - selectRuns: 'Select one or more runs from the table to show their data.', - selectedRunsFailed: 'One or more selected runs failed to load.', - runControl: 'Run', - loadRuns: 'Load runs', - loadingRuns: 'Loading runs…', - latestPublished: 'Latest run', + selectRuns: '请从表格中选择至少一个运行以显示数据。', + selectedRunsFailed: '部分所选运行记录加载失败。', + runControl: '运行', + loadRuns: '加载运行', + loadingRuns: '正在加载运行……', + latestPublished: '最新运行', modeControl: '模式', modeAria: 'CollectiveX 模式', epControl: 'EP 并行度', @@ -241,9 +250,10 @@ const STRINGS = { xScaleAria: 'CollectiveX X 轴刻度', yAxisControl: 'Y 轴', tokenRateOption: '延迟分位点对应的 token 速率', + activationRateOption: '延迟分位点对应的激活数据速率', yScale: 'Y 轴刻度', yScaleAria: 'CollectiveX Y 轴刻度', - noSeries: 'No measured series match these filters.', + noSeries: '没有实测序列符合当前筛选条件。', highContrast: '高对比度', resetFilter: '重置筛选', stableOrdering: '排名顺序稳定性已通过', @@ -281,13 +291,13 @@ const STRINGS = { payloadBandwidthNote: '载荷带宽为完整逻辑载荷(含 FP8 缩放字节)÷ 延迟(每芯片),是基于逻辑字节的派生速率,不代表物理链路带宽。工具提示中的 β/α 为延迟对字节在整个梯度上的最小二乘拟合(β = 每芯片带宽项,α = 固定开销)。', provenance: '发布数据溯源', - runLabel: 'Run', - attemptLabel: 'Attempt', - matrixLabel: 'Matrix', + runLabel: '运行', + attemptLabel: '尝试', + matrixLabel: '矩阵', sourceBundles: '源产物包', deleteRun: '删除运行', deleteShownRuns: '删除已显示的运行', - deletingShownRuns: '正在删除已显示的运行…', + deletingShownRuns: '正在删除已显示的运行……', deleteConfirm: (id: string) => `从仪表板数据库中删除运行 #${id}?此操作无法撤销。`, deleteShownConfirm: (ids: readonly string[]) => `从仪表板数据库中删除当前显示的 ${ids.length} 个运行?此操作无法撤销。\n\n${ids.map((id) => `#${id}`).join('\n')}`, @@ -296,6 +306,8 @@ const STRINGS = { deleteFailed: '删除运行失败,请重试。', deleteShownFailed: (deleted: number, total: number) => `操作失败前已删除 ${total} 个已显示运行中的 ${deleted} 个,请重试。`, + conclusion: { success: '成功', failure: '失败', pending: '待处理' }, + atLatency: (percentile: string) => `${percentile} 延迟分位点`, }, } as const; const CONCLUSION_CLASSES: Record = { @@ -399,7 +411,7 @@ export default function CollectiveXDisplay() { const [legendExpanded, setLegendExpanded] = useState(true); const operationOptions: SelectOption[] = [ { value: 'dispatch', label: t.operation.dispatch }, - { value: 'stage', label: 'Stage' }, + { value: 'stage', label: t.operation.stage }, { value: 'combine', label: t.operation.combine }, { value: 'roundtrip', label: t.operation.roundtrip }, ]; @@ -707,11 +719,10 @@ export default function CollectiveXDisplay() { ); } if (runsQuery.error || !runsQuery.data) { - const message = runsQuery.error instanceof Error ? runsQuery.error.message : t.loadError; return (

{t.unavailable}

-

{message}

+

{t.loadError}

{singleDataset - ? `#${singleDataset.run.run_id} · ${singleDataset.run.conclusion ?? 'pending'}` - : `${datasets.length} ${t.runsShown.toLowerCase()}`} + ? `#${singleDataset.run.run_id} · ${t.conclusion[singleDataset.run.conclusion === 'success' || singleDataset.run.conclusion === 'failure' ? singleDataset.run.conclusion : 'pending']}` + : t.shownRunCount(datasets.length)}

{t.description}

@@ -921,19 +932,10 @@ export default function CollectiveXDisplay() { caption={ <>

- {operation === 'stage' ? 'Stage' : t.operationHeading[operation]} ·{' '} - {t.phaseValue[phase]} ·{' '} - {yAxis === 'latency' - ? percentile - : locale === 'zh' - ? `${percentile} 延迟分位点` - : `at ${percentile} latency`} + {t.operationHeading[operation]} · {t.phaseValue[phase]} ·{' '} + {yAxis === 'latency' ? percentile : t.atLatency(percentile)}

-

- {yAxis === 'activation-rate' - ? 'Activation-data rate at selected latency percentile' - : t.yAxis[yAxis]} -

+

{t.yAxis[yAxis]}

} legendElement={ @@ -948,7 +950,10 @@ export default function CollectiveXDisplay() { track('collectivex_series_toggled', { series: id, visible: false }); }} isLegendExpanded={legendExpanded} - onExpandedChange={setLegendExpanded} + onExpandedChange={(expanded) => { + setLegendExpanded(expanded); + track('collectivex_legend_expanded', { expanded }); + }} actions={ activeSeries.length < phaseSeries.length ? [ @@ -1113,7 +1118,7 @@ export default function CollectiveXDisplay() { : []), { value: 'activation-rate', - label: 'Activation-data rate at latency percentile', + label: t.activationRateOption, }, { value: 'payload-rate', diff --git a/packages/app/src/components/collectivex/CollectiveXKvChart.tsx b/packages/app/src/components/collectivex/CollectiveXKvChart.tsx index e1a701aa6..c0c65d87f 100644 --- a/packages/app/src/components/collectivex/CollectiveXKvChart.tsx +++ b/packages/app/src/components/collectivex/CollectiveXKvChart.tsx @@ -4,6 +4,7 @@ import * as d3 from 'd3'; import { useMemo } from 'react'; import { D3Chart } from '@/lib/d3-chart/D3Chart'; +import { useLocale } from '@/lib/use-locale'; import { type CollectiveXKvChartPoint, @@ -23,16 +24,49 @@ interface CollectiveXKvChartProps { testId?: string; } -const X_LABELS: Record = { - batch: 'Requests per burst (log)', - isl: 'Input sequence length, tokens (log)', -}; - -function yLabel(selection: CollectiveXKvChartSelection): string { - return selection.y === 'bandwidth' - ? `Aggregate ${selection.op} bandwidth at p50 (GB/s)` - : 'Burst completion latency p50 (ms)'; -} +const STRINGS = { + en: { + xLabels: { + batch: 'Requests per burst (log)', + isl: 'Input sequence length, tokens (log)', + }, + yLabel: (selection: CollectiveXKvChartSelection) => + selection.y === 'bandwidth' + ? `Aggregate ${selection.op} bandwidth at p50 (GB/s)` + : 'Burst completion latency p50 (ms)', + noData: 'No measured kv rows match the selected page size and direction.', + instructions: + 'Shift+Scroll to zoom · Drag to pan · Double-click to reset · Click a point to pin tooltip', + dismiss: 'Click elsewhere to dismiss', + point: (op: string, page: string, batch: number, isl: string) => + `${op} · page ${page} · batch ${batch} · ISL ${isl}`, + latency: 'Latency p50 / p95 / min / max', + details: (descs: string, bytes: string, prep: string) => + `${descs} descriptors/request · ${bytes} MB/request · prep ${prep} ms`, + verify: (passed: boolean) => `verify: ${passed ? 'passed' : 'FAILED'}`, + aria: 'CollectiveX KV-cache transfer chart', + }, + zh: { + xLabels: { + batch: '每次 burst 的请求数(对数)', + isl: '输入序列长度(token,对数)', + }, + yLabel: (selection: CollectiveXKvChartSelection) => + selection.y === 'bandwidth' + ? `p50 聚合 ${selection.op} 带宽(GB/s)` + : 'burst 完成延迟 p50(ms)', + noData: '所选分页大小和传输方向下暂无实测 KV 数据。', + instructions: 'Shift+滚轮缩放 · 拖动平移 · 双击重置 · 点击数据点固定提示框', + dismiss: '点击其他区域关闭', + point: (op: string, page: string, batch: number, isl: string) => + `${op} · 每页 ${page} token · batch ${batch} · ISL ${isl}`, + latency: '延迟 p50 / p95 / 最小值 / 最大值', + details: (descs: string, bytes: string, prep: string) => + `每个请求 ${descs} 个描述符 · 每个请求 ${bytes} MB · 准备时间 ${prep} ms`, + verify: (passed: boolean) => `校验:${passed ? '通过' : '失败'}`, + aria: 'CollectiveX KV 缓存传输图表', + }, +} as const; function paddedDomain(values: number[]): [number, number] { if (values.length === 0) return [1, 10]; @@ -66,6 +100,9 @@ export function CollectiveXKvChart({ legendElement, testId, }: CollectiveXKvChartProps) { + const locale = useLocale(); + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : 'en-US'; const points = useMemo(() => collectiveXKvChartPoints(cases, selection), [cases, selection]); const runIndexBySeries = useMemo( () => new Map(cases.map((kase) => [`${kase.run_id}:${kase.case_id}`, kase.run_index])), @@ -96,104 +133,106 @@ export function CollectiveXKvChart({ const noDataOverlay = points.length === 0 ? (
-

- No measured kv rows match the selected page size and direction. -

+

{t.noData}

) : undefined; return ( - - chartId={chartId} - data={points} - height={420} - margin={{ top: 24, right: 20, bottom: 62, left: 78 }} - watermark="logo" - testId={testId} - grabCursor - instructions="Shift+Scroll to zoom · Drag to pan · Double-click to reset · Click a point to pin tooltip" - xScale={{ type: 'log', domain: xDomain, nice: false }} - yScale={{ type: 'log', domain: yDomain, nice: false }} - xAxis={{ - label: X_LABELS[selection.x], - tickCount: 6, - tickValues: xTickValues, - tickFormat: (value) => formatCompact(Number(value)), - }} - yAxis={{ - label: yLabel(selection), - tickCount: 5, - tickFormat: (value) => formatCompact(Number(value)), - }} - layers={[ - { - type: 'line', - key: 'collectivex-kv-lines', - lines, - config: { - getColor: (key) => colors[colorBySeries.get(key) ?? ''] ?? '#888', - getStrokeDasharray: (key) => collectiveXRunDasharray(runIndexBySeries.get(key) ?? 0), - strokeWidth: 2.25, - curve: d3.curveLinear, +
+ + chartId={chartId} + data={points} + height={420} + margin={{ top: 24, right: 20, bottom: 62, left: 78 }} + watermark="logo" + testId={testId} + grabCursor + instructions={t.instructions} + xScale={{ type: 'log', domain: xDomain, nice: false }} + yScale={{ type: 'log', domain: yDomain, nice: false }} + xAxis={{ + label: t.xLabels[selection.x], + tickCount: 6, + tickValues: xTickValues, + tickFormat: (value) => formatCompact(Number(value)), + }} + yAxis={{ + label: t.yLabel(selection), + tickCount: 5, + tickFormat: (value) => formatCompact(Number(value)), + }} + layers={[ + { + type: 'line', + key: 'collectivex-kv-lines', + lines, + config: { + getColor: (key) => colors[colorBySeries.get(key) ?? ''] ?? '#888', + getStrokeDasharray: (key) => collectiveXRunDasharray(runIndexBySeries.get(key) ?? 0), + strokeWidth: 2.25, + curve: d3.curveLinear, + }, }, - }, - { - type: 'point', - key: 'collectivex-kv-points', - data: points, - config: { - getCx: () => 0, - getCy: () => 0, - getX: (point) => point.x, - getY: (point) => point.y, - getColor: (point) => colors[point.colorKey] ?? '#888', - getRadius: () => 3.5, - stroke: 'var(--background)', - strokeWidth: 1, - keyFn: (point) => `${point.seriesId}-${point.x}`, - maxPoints: Infinity, + { + type: 'point', + key: 'collectivex-kv-points', + data: points, + config: { + getCx: () => 0, + getCy: () => 0, + getX: (point) => point.x, + getY: (point) => point.y, + getColor: (point) => colors[point.colorKey] ?? '#888', + getRadius: () => 3.5, + stroke: 'var(--background)', + strokeWidth: 1, + keyFn: (point) => `${point.seriesId}-${point.x}`, + maxPoints: Infinity, + }, }, - }, - ]} - zoom={{ - enabled: true, - axes: 'both', - scaleExtent: [1, 20], - resetEventName: `collectivex_zoom_reset_${chartId}`, - }} - tooltip={{ - rulerType: 'crosshair', - attachToLayer: 1, - content: (point, isPinned) => { - const color = colors[point.colorKey] ?? '#888'; - const { row } = point; - const value = - selection.y === 'bandwidth' - ? `${point.y.toFixed(point.y >= 100 ? 0 : 2)} GB/s` - : `${point.y.toFixed(point.y >= 100 ? 0 : 1)} ms`; - return `
- ${isPinned ? '
Click elsewhere to dismiss
' : ''} + ]} + zoom={{ + enabled: true, + axes: 'both', + scaleExtent: [1, 20], + resetEventName: `collectivex_zoom_reset_${chartId}`, + }} + tooltip={{ + rulerType: 'crosshair', + attachToLayer: 1, + content: (point, isPinned) => { + const color = colors[point.colorKey] ?? '#888'; + const { row } = point; + const value = + selection.y === 'bandwidth' + ? `${point.y.toFixed(point.y >= 100 ? 0 : 2)} GB/s` + : `${point.y.toFixed(point.y >= 100 ? 0 : 1)} ms`; + return `
+ ${isPinned ? `
${t.dismiss}
` : ''}
${escapeHtml(point.seriesLabel)}
-
${row.op} · page ${row.page_tokens} · batch ${row.batch} · ISL ${row.isl.toLocaleString('en-US')}: ${value}
-
Latency p50 / p95 / min / max: ${row.latency_ms.p50.toFixed(1)} / ${row.latency_ms.p95.toFixed(1)} / ${row.latency_ms.min.toFixed(1)} / ${row.latency_ms.max.toFixed(1)} ms
-
${row.descs.toLocaleString('en-US')} descriptors/request · ${(row.req_bytes / 1e6).toFixed(1)} MB/request · prep ${row.prep_ms.toFixed(1)} ms
-
verify: ${row.verify_passed ? 'passed' : 'FAILED'}
+
${t.point(row.op, row.page_tokens === null ? 'bulk' : String(row.page_tokens), row.batch, row.isl.toLocaleString(numberLocale))}: ${value}
+
${t.latency}: ${row.latency_ms.p50.toFixed(1)} / ${row.latency_ms.p95.toFixed(1)} / ${row.latency_ms.min.toFixed(1)} / ${row.latency_ms.max.toFixed(1)} ms
+
${t.details(row.descs.toLocaleString(numberLocale), (row.req_bytes / 1e6).toFixed(1), row.prep_ms.toFixed(1))}
+
${t.verify(row.verify_passed)}
`; - }, - getRulerX: (point, scale) => - (scale as d3.ScaleLinear | d3.ScaleLogarithmic)(point.x), - getRulerY: (point, scale) => scale(point.y), - onHoverStart: (selectionEl) => { - selectionEl.attr('r', 6); - }, - onHoverEnd: (selectionEl) => { - selectionEl.attr('r', 3.5); - }, - }} - transitionDuration={200} - legendElement={legendElement} - noDataOverlay={noDataOverlay} - caption={caption} - /> + }, + getRulerX: (point, scale) => + (scale as d3.ScaleLinear | d3.ScaleLogarithmic)( + point.x, + ), + getRulerY: (point, scale) => scale(point.y), + onHoverStart: (selectionEl) => { + selectionEl.attr('r', 6); + }, + onHoverEnd: (selectionEl) => { + selectionEl.attr('r', 3.5); + }, + }} + transitionDuration={200} + legendElement={legendElement} + noDataOverlay={noDataOverlay} + caption={caption} + /> +
); } diff --git a/packages/app/src/components/collectivex/CollectiveXKvSection.tsx b/packages/app/src/components/collectivex/CollectiveXKvSection.tsx index 9da42fe7f..7255a533a 100644 --- a/packages/app/src/components/collectivex/CollectiveXKvSection.tsx +++ b/packages/app/src/components/collectivex/CollectiveXKvSection.tsx @@ -48,11 +48,31 @@ const STRINGS = { xAriaLabel: 'CollectiveX KV X axis', pageAriaLabel: 'CollectiveX KV page size', opAriaLabel: 'CollectiveX KV direction', + summary: (cases: number, measured: number) => `${cases} cases · ${measured} measured · `, + headers: { + run: 'Run', + backend: 'Backend', + fabric: 'Fabric', + workload: 'Workload', + precision: 'Precision', + outcome: 'Outcome', + handoff: 'Handoff ms', + }, + outcomes: { + success: 'success', + unsupported: 'unsupported', + failed: 'failed', + invalid: 'invalid', + diagnostic: 'diagnostic', + pending: 'pending', + }, + batch: 'Batch', + caption: (op: string, page: string, suffix: string) => `${op} · page ${page} · ${suffix}`, }, zh: { heading: 'KV 缓存传输', description: - '预填充到解码的 KV 交接(2 节点 x 1 GPU,按 vLLM 为 DeepSeek-V4-Pro 分配的缓存布局)。' + + '预填充到解码的 KV 缓存交接(2 节点 × 1 GPU,采用 vLLM 为 DeepSeek-V4-Pro 分配的缓存布局)。' + '分页行按随机块表以逐层描述符列表搬运每个请求;bulk 为单描述符线速上限。' + 'GB/s 为最大 ISL 处按突发聚合的 pull 带宽;b1/bmax 表示每次突发提交的请求数。', batchCaption: '取最大实测 ISL', @@ -69,6 +89,26 @@ const STRINGS = { xAriaLabel: 'CollectiveX KV X 轴', pageAriaLabel: 'CollectiveX KV 页大小', opAriaLabel: 'CollectiveX KV 传输方向', + summary: (cases: number, measured: number) => `${cases} 个用例 · 已测 ${measured} 个 · `, + headers: { + run: '运行', + backend: '后端', + fabric: '互联方式', + workload: '工作负载', + precision: '精度', + outcome: '结果', + handoff: '交接延迟(ms)', + }, + outcomes: { + success: '成功', + unsupported: '不支持', + failed: '失败', + invalid: '无效', + diagnostic: '诊断', + pending: '待处理', + }, + batch: '批量请求数', + caption: (op: string, page: string, suffix: string) => `${op} · 每页 ${page} token · ${suffix}`, }, } as const; @@ -193,27 +233,35 @@ export function CollectiveXKvSection({ const columns = useMemo[]>( () => [ { - header: 'Run', + header: strings.headers.run, cell: (row) => #{row.run_id}, sortValue: (row) => Number(row.run_id), className: 'whitespace-nowrap', }, { header: 'SKU', cell: (row) => collectiveXSkuLabel(row.sku), sortValue: (row) => row.sku }, { - header: 'Backend', + header: strings.headers.backend, cell: (row) => row.backend, sortValue: (row) => row.backend, className: 'whitespace-nowrap', }, - { header: 'Fabric', cell: (row) => row.fabric, sortValue: (row) => row.fabric }, - { header: 'Workload', cell: (row) => row.workload, sortValue: (row) => row.workload }, - { header: 'Precision', cell: (row) => row.precision, sortValue: (row) => row.precision }, + { header: strings.headers.fabric, cell: (row) => row.fabric, sortValue: (row) => row.fabric }, { - header: 'Outcome', + header: strings.headers.workload, + cell: (row) => row.workload, + sortValue: (row) => row.workload, + }, + { + header: strings.headers.precision, + cell: (row) => row.precision, + sortValue: (row) => row.precision, + }, + { + header: strings.headers.outcome, cell: (row) => (
- {row.outcome} + {strings.outcomes[row.outcome]} {(row.detail || row.reason) && (

{row.detail ?? row.reason}

@@ -251,7 +299,7 @@ export function CollectiveXKvSection({ className: 'text-right tabular-nums', }, { - header: 'Handoff ms', + header: strings.headers.handoff, cell: (row) => { const cell = cellsOf(row).p64b1; return cell ? cell.latency_ms.p50.toFixed(1) : '-'; @@ -260,7 +308,7 @@ export function CollectiveXKvSection({ className: 'text-right tabular-nums', }, ], - [], + [strings], ); if (rows.length === 0) return null; @@ -275,7 +323,8 @@ export function CollectiveXKvSection({

{strings.heading}

- {rows.length} cases · {measured} measured · {strings.description} + {strings.summary(rows.length, measured)} + {strings.description}

{measuredCases.length > 0 && ( <> @@ -309,7 +358,7 @@ export function CollectiveXKvSection({ ariaLabel={strings.xAriaLabel} testId="collectivex-kv-xaxis-toggle" options={[ - { value: 'batch', label: 'Batch' }, + { value: 'batch', label: strings.batch }, { value: 'isl', label: 'ISL' }, { value: 'frontier', label: strings.frontierOption }, ]} @@ -319,7 +368,10 @@ export function CollectiveXKvSection({ { + setPageTokens(value); + track('collectivex_kv_page_size_changed', { page_tokens: value }); + }} ariaLabel={strings.pageAriaLabel} testId="collectivex-kv-page-toggle" options={[ @@ -352,7 +404,7 @@ export function CollectiveXKvSection({ selection={{ op, pageTokens: Number(pageTokens) }} caption={

- {op} · page {pageTokens} · {strings.frontierCaption} + {strings.caption(op, pageTokens, strings.frontierCaption)}

} legendElement={ @@ -361,7 +413,10 @@ export function CollectiveXKvSection({ legendItems={legendItems} disableActiveSort isLegendExpanded={legendExpanded} - onExpandedChange={setLegendExpanded} + onExpandedChange={(expanded) => { + setLegendExpanded(expanded); + track('collectivex_kv_legend_expanded', { expanded }); + }} /> } /> @@ -374,8 +429,11 @@ export function CollectiveXKvSection({ selection={selection} caption={

- {op} · page {pageTokens} ·{' '} - {xAxis === 'batch' ? strings.batchCaption : strings.islCaption} + {strings.caption( + op, + pageTokens, + xAxis === 'batch' ? strings.batchCaption : strings.islCaption, + )}

} legendElement={ @@ -384,7 +442,10 @@ export function CollectiveXKvSection({ legendItems={legendItems} disableActiveSort isLegendExpanded={legendExpanded} - onExpandedChange={setLegendExpanded} + onExpandedChange={(expanded) => { + setLegendExpanded(expanded); + track('collectivex_kv_legend_expanded', { expanded }); + }} /> } /> diff --git a/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx b/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx index e4009f8ac..0fcf1ea4e 100644 --- a/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx +++ b/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx @@ -38,24 +38,30 @@ const STRINGS = { openRun: (id: string) => `Open GitHub Actions run #${id}`, deleteRun: (id: string) => `Delete run #${id}`, empty: 'No runs match this benchmark version.', + conclusion: { success: 'success', failure: 'failure' }, + epSuite: (measured: number, requested: number) => `EP: ${measured}/${requested} measured`, + kvSuite: (measured: number, requested: number) => + `KV transfer: ${measured}/${requested} measured`, }, - // English placeholders per the repository's temporary language override. zh: { - shown: 'Shown', - run: 'Run', - result: 'Result', + shown: '显示', + run: '运行', + result: '结果', suites: '测试套件', - cases: 'Measured cases', - points: 'Terminal points', + cases: '实测用例', + points: '终态数据点', skus: 'SKUs', - published: 'Published (UTC)', - actions: 'Actions', - pending: 'pending', - showRun: (id: string) => `Show run #${id}`, - lineStyle: (id: string) => `Line style for run #${id}`, - openRun: (id: string) => `Open GitHub Actions run #${id}`, - deleteRun: (id: string) => `Delete run #${id}`, - empty: 'No runs match this benchmark version.', + published: '发布时间(UTC)', + actions: '操作', + pending: '待处理', + showRun: (id: string) => `显示运行 #${id}`, + lineStyle: (id: string) => `运行 #${id} 的线型`, + openRun: (id: string) => `打开 GitHub Actions 运行 #${id}`, + deleteRun: (id: string) => `删除运行 #${id}`, + empty: '该基准测试版本下暂无运行记录。', + conclusion: { success: '成功', failure: '失败' }, + epSuite: (measured: number, requested: number) => `EP:已测量 ${measured}/${requested}`, + kvSuite: (measured: number, requested: number) => `KV 传输:已测量 ${measured}/${requested}`, }, } as const; @@ -205,14 +211,16 @@ export function CollectiveXRunsTable({ CONCLUSION_CLASSES[conclusion] ?? CONCLUSION_FALLBACK_CLASS, )} > - {conclusion} + {conclusion === 'success' || conclusion === 'failure' + ? t.conclusion[conclusion] + : conclusion}
{epRequested > 0 && ( 0 && ( void; + /** Test/embedded-surface override. Production defaults to the current route locale. */ + locale?: Locale; } -export function FeedbackForm({ onDismiss }: FeedbackFormProps) { +const STRINGS = { + en: { + validation: 'Please fill in at least one field.', + rateLimit: 'Too many submissions — please try again later.', + rejected: 'Submission rejected. Please check the fields and try again.', + saveFailed: 'Could not save your feedback. Please try again.', + unknownError: 'Something went wrong.', + successTitle: 'Thanks for your feedback!', + successBody: 'We read every response.', + title: 'Help us improve InferenceX', + description: "We'd love to hear what's working and what isn't.", + worksWell: 'What works well?', + improve: 'What could be better?', + want: 'What would you like to see?', + privacy: 'Your response is encrypted and only visible to the InferenceX team.', + dismiss: 'Maybe later', + sending: 'Sending…', + submit: 'Send feedback', + }, + zh: { + validation: '请至少填写一项。', + rateLimit: '提交次数过多,请稍后再试。', + rejected: '提交未通过校验,请检查填写内容后重试。', + saveFailed: '反馈保存失败,请重试。', + unknownError: '出现意外错误,请重试。', + successTitle: '感谢您的反馈!', + successBody: '我们会认真阅读每一条反馈。', + title: '帮助我们改进 InferenceX', + description: '欢迎告诉我们哪些体验不错,以及哪些地方需要改进。', + worksWell: '哪些地方做得好?', + improve: '哪些地方可以改进?', + want: '还希望看到哪些功能?', + privacy: '您的反馈会加密保存,只有 InferenceX 团队可以查看。', + dismiss: '稍后再说', + sending: '正在发送……', + submit: '发送反馈', + }, +} as const; + +export function FeedbackForm({ onDismiss, locale: localeOverride }: FeedbackFormProps) { const [doingWell, setDoingWell] = useState(''); const [doingPoorly, setDoingPoorly] = useState(''); const [wantToSee, setWantToSee] = useState(''); @@ -28,6 +71,9 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) { const [status, setStatus] = useState('idle'); const [errorMsg, setErrorMsg] = useState(null); const pathname = usePathname(); + const routeLocale = useLocale(); + const locale = localeOverride ?? routeLocale; + const t = STRINGS[locale]; const titleId = useId(); const descId = useId(); @@ -38,9 +84,10 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) { doingPoorly.trim() && 'doing_poorly', wantToSee.trim() && 'want_to_see', ].filter(Boolean) as string[]; + track('feedback_modal_submit_clicked', { filled_fields: filledFields.join(',') }); if (filledFields.length === 0) { - setErrorMsg('Please fill in at least one field.'); + setErrorMsg(t.validation); setStatus('error'); return; } @@ -63,12 +110,12 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) { if (!res.ok) { if (res.status === 429) { - throw new Error('Too many submissions — please try again later.'); + throw new Error(t.rateLimit); } if (res.status === 400) { - throw new Error('Submission rejected. Please check the fields and try again.'); + throw new Error(t.rejected); } - throw new Error('Could not save your feedback. Please try again.'); + throw new Error(t.saveFailed); } window.dispatchEvent(new Event(FEEDBACK_SUBMITTED_EVENT)); @@ -76,10 +123,22 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) { setStatus('success'); window.setTimeout(onDismiss, SUCCESS_HOLD_MS); } catch (error) { - setErrorMsg(error instanceof Error ? error.message : 'Something went wrong.'); + const knownMessage = + error instanceof Error && + (error.message === t.rateLimit || + error.message === t.rejected || + error.message === t.saveFailed) + ? error.message + : t.unknownError; + setErrorMsg(knownMessage); setStatus('error'); } - }, [doingWell, doingPoorly, wantToSee, honeypot, pathname, status, onDismiss]); + }, [doingWell, doingPoorly, wantToSee, honeypot, pathname, status, onDismiss, t]); + + const handleDismiss = useCallback(() => { + track('feedback_modal_later_clicked'); + onDismiss(); + }, [onDismiss]); const submitting = status === 'submitting'; @@ -88,10 +147,10 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) {

- Thanks for your feedback! + {t.successTitle}

- We read every response. + {t.successBody}

); @@ -102,29 +161,29 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) {

- Help us improve InferenceX + {t.title}

- We'd love to hear what's working and what isn't. + {t.description}

-

- Your response is encrypted and only visible to the InferenceX team. -

+

{t.privacy}

{errorMsg && (

@@ -155,17 +212,17 @@ export function FeedbackForm({ onDismiss }: FeedbackFormProps) {

)} -
+
diff --git a/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx b/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx index b877a8470..58fe3f131 100644 --- a/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx +++ b/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx @@ -81,6 +81,7 @@ const STRINGS = { showKey: 'Show key', allDecryptsFailed: "All rows failed to decrypt — the key parses but doesn't match the data.", fetchError: 'Failed to load feedback rows.', + retry: 'Retry', loadingRows: 'Loading rows…', noRows: 'No feedback rows yet.', enterKey: 'Enter the key above to decrypt.', @@ -91,6 +92,7 @@ const STRINGS = { whatWorksWell: 'What works well', whatCouldBeBetter: 'What could be better', wouldLikeToSee: 'Would like to see', + invalidKey: 'The decryption key must be valid base64 for exactly 32 bytes.', }, zh: { heading: '用户反馈', @@ -105,6 +107,7 @@ const STRINGS = { showKey: '显示密钥', allDecryptsFailed: '所有行均解密失败——密钥格式正确但与数据不匹配。', fetchError: '无法加载反馈数据。', + retry: '重试', loadingRows: '加载中……', noRows: '暂无反馈记录。', enterKey: '请在上方输入密钥进行解密。', @@ -115,12 +118,13 @@ const STRINGS = { whatWorksWell: '做得好的地方', whatCouldBeBetter: '可以改进的地方', wouldLikeToSee: '希望看到的功能', + invalidKey: '解密密钥必须是有效的 base64 编码,解码后长度为 32 字节。', }, } as const; export default function FeedbackViewer() { const router = useRouter(); - const { data, isLoading, error: fetchError } = useFeedbackList(); + const { data, isLoading, error: fetchError, refetch } = useFeedbackList(); const locale = useLocale(); const t = STRINGS[locale]; const [keyInput, setKeyInput] = useState(''); @@ -147,14 +151,13 @@ export default function FeedbackViewer() { setCipherKey(k); setKeyError(null); track('feedback_viewer_key_accepted'); - } catch (error) { - const msg = error instanceof Error ? error.message : 'invalid key'; - setKeyError(msg); + } catch { + setKeyError(t.invalidKey); setCipherKey(null); - track('feedback_viewer_key_rejected', { reason: msg }); + track('feedback_viewer_key_rejected'); } }, - [keyInput], + [keyInput, t.invalidKey], ); const handleForget = useCallback(() => { @@ -202,7 +205,7 @@ export default function FeedbackViewer() { -
+
+
)} @@ -300,15 +319,18 @@ export default function FeedbackViewer() { function FeedbackRow({ row }: { row: DecryptedRow }) { const locale = useLocale(); const t = STRINGS[locale]; + const createdAt = new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { + dateStyle: 'medium', + timeStyle: 'short', + timeZone: 'UTC', + }).format(new Date(row.createdAt)); if (row.decryptError) { return (
#{row.id} {t.decryptFailed} - - {new Date(row.createdAt).toISOString()} - + {createdAt} UTC
); @@ -318,7 +340,7 @@ function FeedbackRow({ row }: { row: DecryptedRow }) {
#{row.id} - {new Date(row.createdAt).toISOString()} + {createdAt} UTC {row.pagePath ?? '?'}
{row.doingWell && ( diff --git a/packages/app/src/components/inference/ui/TrendChart.tsx b/packages/app/src/components/inference/ui/TrendChart.tsx index 25b232697..72b8b0dcd 100644 --- a/packages/app/src/components/inference/ui/TrendChart.tsx +++ b/packages/app/src/components/inference/ui/TrendChart.tsx @@ -18,6 +18,7 @@ import { logTickFormat, } from '@/lib/chart-rendering'; import { getChartWatermark } from '@/lib/data-mappings'; +import { useLocale } from '@/lib/use-locale'; import type { TrendDataPoint, TrendLineConfig } from '../types'; @@ -36,6 +37,21 @@ interface TrendChartProps { const CHART_MARGIN = { top: 20, right: 30, bottom: 50, left: 60 }; +const STRINGS = { + en: { + dismiss: 'Click elsewhere to dismiss', + noData: 'No historical data found for the tracked configurations.', + instructions: 'Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset', + aria: 'Historical performance trend chart', + }, + zh: { + dismiss: '点击其他区域关闭', + noData: '当前追踪的配置暂无历史数据。', + instructions: 'Shift+滚轮横向缩放 · 拖动平移 · 双击重置', + aria: '历史性能趋势图表', + }, +} as const; + /** Prepared line data point with parsed date and timestamp for D3 scales. */ interface PreparedPoint { date: Date; @@ -63,6 +79,27 @@ const TrendChart = React.memo( caption, selectedPrecisions, }: TrendChartProps) => { + const locale = useLocale(); + const t = STRINGS[locale]; + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + [locale], + ); + const axisDateFormatter = useMemo( + () => + new Intl.DateTimeFormat('zh-CN', { + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + [], + ); // All data points flattened for computing axis domains — only from VISIBLE configs const visibleConfigIds = useMemo(() => new Set(lineConfigs.map((c) => c.id)), [lineConfigs]); @@ -229,9 +266,9 @@ const TrendChart = React.memo( rulerType: 'crosshair' as const, content: (d: PreparedPoint, isPinned: boolean) => `
- ${isPinned ? '
Click elsewhere to dismiss
' : ''} + ${isPinned ? `
${t.dismiss}
` : ''}
${getPointConfig(d)?.label ?? ''}
-
${d.raw.date}
+
${locale === 'zh' ? dateFormatter.format(new Date(d.raw.date)) : d.raw.date}
${yLabel}: ${formatLargeNumber(d.value)}
`, getRulerX: (d: PreparedPoint, xScale: any) => xScale(d.x), @@ -253,18 +290,21 @@ const TrendChart = React.memo( }, attachToLayer: 1, }), - [getPointConfig, yLabel, selectedPrecisions], + [dateFormatter, getPointConfig, locale, selectedPrecisions, t.dismiss, yLabel], ); const xAxisConfig = useMemo( () => ({ - tickFormat: d3.timeFormat('%b %d') as any, + tickFormat: ((value: d3.AxisDomain) => + locale === 'zh' + ? axisDateFormatter.format(new Date(Number(value))) + : d3.timeFormat('%b %d')(new Date(Number(value)))) as any, tickCount: 10, customize: (g: d3.Selection) => { g.selectAll('.tick text').attr('transform', 'rotate(-30)').attr('text-anchor', 'end'); }, }), - [], + [axisDateFormatter, locale], ); const yAxisConfig = useMemo( @@ -289,34 +329,34 @@ const TrendChart = React.memo( if (allPoints.length === 0) { return (
-

- No historical data found for the tracked configurations. -

+

{t.noData}

); } return ( - - chartId={chartId} - data={flatPointData} - height={600} - margin={CHART_MARGIN} - watermark={getChartWatermark()} - testId="trend-chart-svg" - grabCursor - instructions="Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset" - xScale={xScaleConfig} - yScale={yScaleConfig} - xAxis={xAxisConfig} - yAxis={yAxisConfig} - layers={layers} - zoom={zoomConfig} - tooltip={tooltipConfig} - onRender={onRender} - legendElement={legendElement} - caption={caption} - /> +
+ + chartId={chartId} + data={flatPointData} + height={600} + margin={CHART_MARGIN} + watermark={getChartWatermark()} + testId="trend-chart-svg" + grabCursor + instructions={t.instructions} + xScale={xScaleConfig} + yScale={yScaleConfig} + xAxis={xAxisConfig} + yAxis={yAxisConfig} + layers={layers} + zoom={zoomConfig} + tooltip={tooltipConfig} + onRender={onRender} + legendElement={legendElement} + caption={caption} + /> +
); }, ); diff --git a/packages/app/src/components/reliability/ui/BarChartD3.tsx b/packages/app/src/components/reliability/ui/BarChartD3.tsx index e443351b6..cddb72fb7 100644 --- a/packages/app/src/components/reliability/ui/BarChartD3.tsx +++ b/packages/app/src/components/reliability/ui/BarChartD3.tsx @@ -60,6 +60,7 @@ function positionLabelPairs( group: d3.Selection, xScale: d3.ScaleLinear, getBarColor: (d: ChartItem) => string, + locale: Locale, ) { const valueLabels = group.selectAll('.value-label'); const overlayLabels = group.selectAll('.overlay-label'); @@ -73,7 +74,10 @@ function positionLabelPairs( }); overlayLabels.each((d) => { const prev = maxWidths.get(d.modelLabel) ?? 0; - const w = measureTextWidth(`${d.n_success}/${d.total} runs`, '500 10px sans-serif'); + const w = measureTextWidth( + locale === 'zh' ? `${d.n_success}/${d.total} 次运行` : `${d.n_success}/${d.total} runs`, + '500 10px sans-serif', + ); maxWidths.set(d.modelLabel, Math.max(prev, w)); }); @@ -99,16 +103,30 @@ const RELIABILITY_STRINGS = { en: { highContrast: 'High Contrast', resetFilter: 'Reset filter', + xAxis: 'Success Rate (%)', + loading: 'Loading reliability data…', + loadError: 'Failed to load reliability data.', + noData: 'No reliability data available for this date range.', + instructions: + 'Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset · Hover for details', + runCount: (success: number, total: number) => `${success}/${total} runs`, }, zh: { highContrast: '高对比度', resetFilter: '重置筛选', + xAxis: '成功率(%)', + loading: '正在加载可靠性数据……', + loadError: '可靠性数据加载失败。', + noData: '所选时间范围内暂无可靠性数据。', + instructions: 'Shift+滚轮横向缩放 · 拖动平移 · 双击重置 · 悬停查看详情', + runCount: (success: number, total: number) => `${success}/${total} 次运行`, }, } as const; export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode }) { const hoveredBarXRef = useRef(0); const { + loading, error, chartData, highContrast, @@ -311,10 +329,13 @@ export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode .attr('font-size', '10px') .attr('font-weight', '500') .style('pointer-events', 'none') - .text((d) => `${d.n_success}/${d.total} runs`); + .text((d) => legendT.runCount(d.n_success, d.total)); - positionLabelPairs(group, ctx.xScale as d3.ScaleLinear, (d) => - getCssColor(resolveColor(d.model)), + positionLabelPairs( + group, + ctx.xScale as d3.ScaleLinear, + (d) => getCssColor(resolveColor(d.model)), + locale, ); }, onDisplayUpdate: (group, ctx) => { @@ -322,17 +343,20 @@ export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode const svgNode = ctx.layout.svg.node(); const transform = svgNode ? d3.zoomTransform(svgNode) : d3.zoomIdentity; const currentXScale = baseXScale.copy().domain([0, 100 / transform.k]); - positionLabelPairs(group, currentXScale, (datum) => - getCssColor(resolveColor(datum.model)), + positionLabelPairs( + group, + currentXScale, + (datum) => getCssColor(resolveColor(datum.model)), + locale, ); }, onZoom: (group, ctx) => { const newXScale = ctx.newXScale as d3.ScaleLinear; - positionLabelPairs(group, newXScale, (d) => getCssColor(resolveColor(d.model))); + positionLabelPairs(group, newXScale, (d) => getCssColor(resolveColor(d.model)), locale); }, }, ], - [sortedChartData, getCssColor, paletteIdentity, resolveColor], + [sortedChartData, getCssColor, legendT, locale, paletteIdentity, resolveColor], ); const yAxisConfig = useMemo(() => ({ customize: twoRowYAxisLabels() }), []); @@ -344,21 +368,19 @@ export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode const xAxisConfig = useMemo( () => ({ - label: 'Success Rate (%)', + label: legendT.xAxis, tickFormat: (d: d3.AxisDomain) => `${d}%`, tickCount: 5, }), - [], + [legendT.xAxis], ); - const isEmpty = error || chartData.length === 0; + const isEmpty = loading || error || chartData.length === 0; const emptyOverlay = isEmpty ? (

- {error - ? 'Failed to load reliability data.' - : 'No reliability data available for this date range.'} + {loading ? legendT.loading : error ? legendT.loadError : legendT.noData}

) : null; @@ -378,7 +400,7 @@ export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode clipContent={false} caption={caption} noDataOverlay={emptyOverlay} - instructions="Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset · Hover for details" + instructions={legendT.instructions} xScale={xScaleConfig} yScale={yScaleConfig} xAxis={xAxisConfig} diff --git a/packages/app/src/components/reliability/ui/ChartControls.tsx b/packages/app/src/components/reliability/ui/ChartControls.tsx index 56e8a9c42..374243377 100644 --- a/packages/app/src/components/reliability/ui/ChartControls.tsx +++ b/packages/app/src/components/reliability/ui/ChartControls.tsx @@ -29,7 +29,7 @@ const STRINGS = { zh: { dateRangeLabel: '时间范围', dateRangeTooltip: - '计算芯片可靠性指标的时间窗口。更长的范围可提供更稳定的统计数据,但可能无法反映近期的硬件性能变化。', + '该时间范围用于计算芯片可靠性。范围越长,统计结果通常越稳定,但可能弱化近期变化。', dateRangePlaceholder: '选择时间范围', last3Days: '最近 3 天', last7Days: '最近 7 天', diff --git a/packages/app/src/components/reliability/ui/ChartDisplay.tsx b/packages/app/src/components/reliability/ui/ChartDisplay.tsx index 0b8240b5e..f8a63d31e 100644 --- a/packages/app/src/components/reliability/ui/ChartDisplay.tsx +++ b/packages/app/src/components/reliability/ui/ChartDisplay.tsx @@ -24,7 +24,7 @@ const STRINGS = { }, zh: { heading: '芯片可靠性', - description: '各芯片型号推理运行的成功率百分比,展示硬件在一段时间内的推理运行可靠性。', + description: '汇总所选时间范围内各芯片型号的推理运行成功率,用于比较硬件可靠性。', captionHeading: '各芯片型号成功率', captionSource: '数据来源:SemiAnalysis InferenceX™', }, diff --git a/packages/app/src/components/submissions/SubmissionsChart.tsx b/packages/app/src/components/submissions/SubmissionsChart.tsx index d4f8a7541..2d5187024 100644 --- a/packages/app/src/components/submissions/SubmissionsChart.tsx +++ b/packages/app/src/components/submissions/SubmissionsChart.tsx @@ -5,6 +5,7 @@ import { type ReactNode, useCallback, useMemo, useState } from 'react'; import { track } from '@/lib/analytics'; import { useLocale } from '@/lib/use-locale'; +import type { Locale } from '@/lib/i18n'; import ChartLegend from '@/components/ui/chart-legend'; import { D3Chart, @@ -47,40 +48,57 @@ function lineColor(key: string): string { const LINE_KEYS = ['nvidia', 'amd', 'total'] as const; type LineKey = (typeof LINE_KEYS)[number]; -const LINE_META: Record = { - nvidia: { label: 'NVIDIA', color: NVIDIA_COLOR }, - amd: { label: 'AMD', color: AMD_COLOR }, - total: { label: 'Total', color: TOTAL_COLOR }, -}; - -function generateTooltipContent(d: ChartPoint, isPinned: boolean): string { - const dateStr = new Date(d.date).toLocaleDateString('en-US', { +function generateTooltipContent(d: ChartPoint, isPinned: boolean, locale: Locale): string { + const t = SUBMISSIONS_STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : 'en-US'; + const dateStr = new Date(d.date).toLocaleDateString(numberLocale, { year: 'numeric', month: 'short', day: 'numeric', }); return `
- ${isPinned ? '
Click elsewhere to dismiss
' : ''} + ${isPinned ? `
${t.dismiss}
` : ''}
${dateStr}
- NVIDIA: ${d.nvidia.toLocaleString()} + NVIDIA: ${d.nvidia.toLocaleString(numberLocale)}
- AMD: ${d.amd.toLocaleString()} + AMD: ${d.amd.toLocaleString(numberLocale)}
- Total: ${d.total.toLocaleString()} + ${t.total}: ${d.total.toLocaleString(numberLocale)}
`; } const SUBMISSIONS_STRINGS = { - en: { onChangeOnly: 'On-change only' }, - zh: { onChangeOnly: '仅变更' }, + en: { + onChangeOnly: 'On-change only', + total: 'Total', + dismiss: 'Click elsewhere to dismiss', + markerLine1: 'Switched to', + markerLine2: 'on-change runs', + noData: 'No submission data to display.', + instructions: + 'Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset · Click a point to pin tooltip', + aria: 'Benchmark submission activity chart', + yAxis: 'Datapoints', + }, + zh: { + onChangeOnly: '仅显示变更触发的运行', + total: '合计', + dismiss: '点击其他区域关闭', + markerLine1: '自此改为', + markerLine2: '仅在变更时运行', + noData: '暂无可显示的提交数据。', + instructions: 'Shift+滚轮横向缩放 · 拖动平移 · 双击重置 · 点击数据点固定提示框', + aria: '基准测试提交活动图表', + yAxis: '数据点数量', + }, } as const; export default function SubmissionsChart({ volume, mode, caption }: SubmissionsChartProps) { @@ -89,6 +107,16 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC const [onChangeOnly, setOnChangeOnly] = useState(true); const locale = useLocale(); const legendT = SUBMISSIONS_STRINGS[locale]; + const dateFormatter = useMemo( + () => + new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), + [locale], + ); const toggleLine = useCallback((name: string) => { setEnabledLines((prev) => { @@ -107,12 +135,12 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC () => LINE_KEYS.map((key) => ({ name: key, - label: LINE_META[key].label, - color: LINE_META[key].color, + label: key === 'total' ? legendT.total : key === 'nvidia' ? 'NVIDIA' : 'AMD', + color: lineColor(key), isActive: enabledLines.has(key), onClick: toggleLine, })), - [enabledLines, toggleLine], + [enabledLines, legendT.total, toggleLine], ); const filteredVolume = useMemo(() => { @@ -196,8 +224,8 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC .attr('fill', 'var(--foreground)') .attr('font-size', '11px') .attr('font-weight', '500'); - text.append('tspan').attr('x', 0).attr('dy', '0.8em').text('Switched to'); - text.append('tspan').attr('x', 0).attr('dy', '1.3em').text('on-change runs'); + text.append('tspan').attr('x', 0).attr('dy', '0.8em').text(legendT.markerLine1); + text.append('tspan').attr('x', 0).attr('dy', '1.3em').text(legendT.markerLine2); text .append('tspan') .attr('x', 0) @@ -205,7 +233,7 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC .attr('font-size', '9px') .attr('font-weight', '400') .attr('fill', 'var(--muted-foreground)') - .text('Dec 16, 2025'); + .text(dateFormatter.format(new Date(NIGHTLY_END_DATE))); const bbox = (text.node() as SVGTextElement).getBBox(); label .insert('rect', 'text') @@ -238,19 +266,19 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC }, }, ], - [lineData], + [dateFormatter, legendT.markerLine1, legendT.markerLine2, lineData], ); if (chartPoints.length === 0) { return (
-

No submission data to display.

+

{legendT.noData}

); } return ( -
+
chartId={CHART_ID} data={chartPoints} @@ -259,11 +287,19 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC watermark="logo" testId="submissions-chart-svg" grabCursor - instructions="Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset · Click a point to pin tooltip" + instructions={legendT.instructions} xScale={{ type: 'time', domain: [new Date(xDomain[0]), new Date(xDomain[1])], nice: false }} yScale={{ type: 'linear', domain: yDomain, nice: true }} - xAxis={{ tickCount: 6 }} + xAxis={ + locale === 'zh' + ? { + tickCount: 6, + tickFormat: (value) => dateFormatter.format(new Date(Number(value))), + } + : { tickCount: 6 } + } yAxis={{ + label: locale === 'zh' ? legendT.yAxis : undefined, tickCount: 5, tickFormat: (d) => { const n = d as number; @@ -279,7 +315,7 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC }} tooltip={{ rulerType: 'vertical', - content: generateTooltipContent, + content: (point, isPinned) => generateTooltipContent(point, isPinned, locale), getRulerX: (d, xScale) => (xScale as unknown as d3.ScaleTime)(d.date), getRulerY: (d, yScale) => yScale(d.total), proximityHover: true, diff --git a/packages/app/src/components/submissions/SubmissionsDisplay.tsx b/packages/app/src/components/submissions/SubmissionsDisplay.tsx index 8fdbad612..ea9da18d4 100644 --- a/packages/app/src/components/submissions/SubmissionsDisplay.tsx +++ b/packages/app/src/components/submissions/SubmissionsDisplay.tsx @@ -4,6 +4,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'; import { track } from '@/lib/analytics'; import { Card } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; import { ChartButtons } from '@/components/ui/chart-buttons'; import { ChartShareActions } from '@/components/ui/chart-display-helpers'; import { SegmentedToggle, type SegmentedToggleOption } from '@/components/ui/segmented-toggle'; @@ -28,6 +29,10 @@ const STRINGS = { loadingChart: 'Loading chart data...', loadingTable: 'Loading submissions...', errorText: 'Failed to load submission data.', + retry: 'Retry', + noChartData: 'No submission activity data is available.', + noTableData: 'No submission records are available.', + chartModeAria: 'Chart mode', chartCaption: 'Submission Activity', chartSource: 'Source: SemiAnalysis InferenceX™', statDatapoints: 'Datapoints Generated', @@ -44,9 +49,13 @@ const STRINGS = { description: '所有提交至 InferenceX 的基准测试配置。查看提交历史、活动趋势和数据点数量。', modeWeekly: '按周', modeCumulative: '累计', - loadingChart: '正在加载图表数据...', - loadingTable: '正在加载提交记录...', + loadingChart: '正在加载图表数据……', + loadingTable: '正在加载提交记录……', errorText: '加载提交数据失败。', + retry: '重试', + noChartData: '暂无提交活动数据。', + noTableData: '暂无提交记录。', + chartModeAria: '图表模式', chartCaption: '提交活动', chartSource: '数据来源:SemiAnalysis InferenceX™', statDatapoints: '已生成数据点', @@ -54,15 +63,17 @@ const STRINGS = { statModels: '模型数', statHardware: '硬件类型', subtitleResults: '条结果', - subtitleTested: '已测试', + subtitleTested: '个配置', subtitleLLMs: '个 LLM', subtitleSKUs: '种 SKU', }, } as const; export default function SubmissionsDisplay() { - const { data, isLoading, error } = useSubmissions(); - const t = STRINGS[useLocale()]; + const { data, isLoading, error, refetch } = useSubmissions(); + const locale = useLocale(); + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : undefined; const [chartMode, setChartMode] = useState('weekly'); const chartModeOptions = useMemo[]>( @@ -96,7 +107,20 @@ export default function SubmissionsDisplay() { if (error) { return ( -

{t.errorText}

+
+

{t.errorText}

+ +
); } @@ -137,7 +161,7 @@ export default function SubmissionsDisplay() { ))} @@ -159,7 +183,7 @@ export default function SubmissionsDisplay() { value={chartMode} options={chartModeOptions} onValueChange={handleModeChange} - ariaLabel="Chart mode" + ariaLabel={t.chartModeAria} testId="submissions-mode-toggle" className="shrink-0" /> @@ -170,7 +194,7 @@ export default function SubmissionsDisplay() {
{t.loadingChart}
- ) : data?.volume ? ( + ) : data?.volume && data.volume.length > 0 ? ( } /> - ) : null} + ) : ( +
+ {t.noChartData} +
+ )}
@@ -193,9 +221,13 @@ export default function SubmissionsDisplay() {
{t.loadingTable}
- ) : data?.summary ? ( + ) : data?.summary && data.summary.length > 0 ? ( - ) : null} + ) : ( +
+ {t.noTableData} +
+ )}
diff --git a/packages/app/src/components/submissions/SubmissionsTable.tsx b/packages/app/src/components/submissions/SubmissionsTable.tsx index 54550d4fb..2c51b73d8 100644 --- a/packages/app/src/components/submissions/SubmissionsTable.tsx +++ b/packages/app/src/components/submissions/SubmissionsTable.tsx @@ -21,6 +21,7 @@ import { } from '@/components/ui/tooltip'; import { useLocale } from '@/lib/use-locale'; +import type { Locale } from '@/lib/i18n'; import { buildInferenceCompareUrl, @@ -88,38 +89,41 @@ const STRINGS = { maxPrefix: 'max ', yes: 'Yes', no: 'No', + expandRow: 'Expand configuration details', + collapseRow: 'Collapse configuration details', + changedTo: 'changed to', }, zh: { - searchPlaceholder: '搜索配置...', + searchPlaceholder: '搜索配置……', colGpu: '芯片', colModel: '模型', colPrecision: '精度', - colSpecMethod: '推测解码', + colSpecMethod: '投机解码', colFramework: '框架', colDate: '日期', colDatapoints: '数据点', colCompare: '对比', noMatch: '未找到匹配的提交记录。', noData: '暂无提交数据。', - vsPrev: '对比', + vsPrev: '与上次对比', vendorLabel: '厂商:', vendorTip: '芯片制造商', - specMethodLabel: '推测解码方法:', - specMethodTip: '推测解码方法(如 MTP、Eagle)', + specMethodLabel: '投机解码方法:', + specMethodTip: '投机解码方法(如 MTP、EAGLE)', disaggLabel: '分离式部署:', - disaggTip: 'Prefill 和 Decode 在不同芯片池上运行', + disaggTip: '预填充和解码在不同芯片池上运行', multinodeLabel: '多节点:', multinodeTip: '配置跨多个物理节点', totalGpusLabel: '总芯片数:', - totalGpusTip: '物理芯片总数。分离式部署时,Prefill 和 Decode 使用不同的芯片池', - prefillGpusLabel: 'Prefill 芯片数:', - prefillGpusTip: '用于 Prefill(提示处理)阶段的芯片', - decodeGpusLabel: 'Decode 芯片数:', - decodeGpusTip: '用于 Decode(Token 生成)阶段的芯片', - prefillTpEpLabel: 'Prefill TP/EP:', - prefillTpEpTip: 'Prefill 的张量并行 / 专家并行', - decodeTpEpLabel: 'Decode TP/EP:', - decodeTpEpTip: 'Decode 的张量并行 / 专家并行', + totalGpusTip: '物理芯片总数。分离式部署时,预填充和解码使用不同的芯片池', + prefillGpusLabel: '预填充芯片数:', + prefillGpusTip: '用于预填充(提示处理)阶段的芯片', + decodeGpusLabel: '解码芯片数:', + decodeGpusTip: '用于解码(token 生成)阶段的芯片', + prefillTpEpLabel: '预填充 TP/EP:', + prefillTpEpTip: '预填充阶段的张量并行 / 专家并行', + decodeTpEpLabel: '解码 TP/EP:', + decodeTpEpTip: '解码阶段的张量并行 / 专家并行', aggregateGpusLabel: '聚合推理芯片数:', aggregateGpusTip: '单个聚合推理引擎使用的芯片数', aggregateTpEpLabel: '聚合推理 TP/EP:', @@ -145,6 +149,9 @@ const STRINGS = { maxPrefix: '最大 ', yes: '是', no: '否', + expandRow: '展开配置详情', + collapseRow: '收起配置详情', + changedTo: '更新为', }, } as const; @@ -211,21 +218,27 @@ function SortHeader({ }) { return ( onSort(field)} + className="px-3 py-2 text-left text-xs font-medium text-muted-foreground select-none" + aria-sort={sortKey === field ? (sortDir === 'asc' ? 'ascending' : 'descending') : 'none'} > - + ); } export default function SubmissionsTable({ data }: SubmissionsTableProps) { - const t = STRINGS[useLocale()]; + const locale = useLocale(); + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : undefined; const [sortKey, setSortKey] = useState('date'); const [sortDir, setSortDir] = useState('desc'); const [search, setSearch] = useState(''); @@ -358,6 +371,7 @@ export default function SubmissionsTable({ data }: SubmissionsTableProps) { previousImage={previousImages.get(key) ?? null} previousRun={previousRuns.get(key) ?? null} onToggle={() => toggleRow(key)} + locale={locale} /> ); })} @@ -397,7 +411,7 @@ export default function SubmissionsTable({ data }: SubmissionsTableProps) { {t.showingOf} {filtered.length} {filtered.length === 1 ? t.configSingular : t.configPlural} ·{' '} - {filtered.reduce((sum, r) => sum + r.total_datapoints, 0).toLocaleString()} + {filtered.reduce((sum, r) => sum + r.total_datapoints, 0).toLocaleString(numberLocale)} {t.totalDatapointsSuffix}

@@ -410,14 +424,17 @@ function SubmissionRow({ previousImage, previousRun, onToggle, + locale, }: { row: SubmissionSummaryRow; isExpanded: boolean; previousImage: string | null; previousRun: SubmissionSummaryRow | null; onToggle: () => void; + locale: Locale; }) { - const t = STRINGS[useLocale()]; + const t = STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : undefined; const vendor = getVendor(row.hardware); const compareUrl = previousRun ? buildInferenceCompareUrl(row, previousRun) : null; @@ -425,7 +442,17 @@ function SubmissionRow({ <> - {isExpanded ? : } + @@ -445,8 +472,16 @@ function SubmissionRow({ )} {getFrameworkLabel(row.framework)} - {row.date} - {row.total_datapoints.toLocaleString()} + + {locale === 'zh' + ? new Intl.DateTimeFormat('zh-CN', { timeZone: 'UTC' }).format( + new Date(`${row.date}T00:00:00Z`), + ) + : row.date} + + + {row.total_datapoints.toLocaleString(numberLocale)} + {compareUrl && previousRun ? ( @@ -571,7 +606,7 @@ function SubmissionRow({ className="font-mono text-xs break-all" > {previousImage} - + {row.image} diff --git a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx index a227718e0..af0702b4a 100644 --- a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx +++ b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx @@ -42,6 +42,8 @@ import { JalapenoOfficialPreviewNotice, VeraRubinOfficialPreviewNotice, } from '@/components/official-preview-notice'; +import { metricLabel, metricTitle } from '@/lib/chart-utils'; +import { Button } from '@/components/ui/button'; const STRINGS = { en: { @@ -59,12 +61,14 @@ const STRINGS = { highContrast: 'High Contrast', resetFilter: 'Reset filter', noData: 'No interactivity chart data available for the selected model and sequence.', + loadError: 'Historical benchmark data could not be loaded.', + retry: 'Reload page', }, zh: { heading: '历史趋势', - description: '在固定交互性操作点下,各性能指标随时间的插值变化。', + description: '将交互性固定在指定水平后,展示各项性能指标随时间的变化;数据经插值计算。', targetLabel: '目标交互性 (tok/s/user)', - targetTooltip: '用于插值的交互性操作点。移动滑块可查看各芯片在不同交互性水平下的性能变化。', + targetTooltip: '设置插值计算采用的交互性水平。移动滑块可比较不同交互性水平下的芯片性能。', captionTitle: (yTitle: string, target: number) => `${yTitle} 随时间变化(交互性 ${target} tok/s/user)`, source: '来源:SemiAnalysis InferenceX™', @@ -73,12 +77,16 @@ const STRINGS = { highContrast: '高对比度', resetFilter: '重置筛选', noData: '所选模型和序列无可用的交互性图表数据。', + loadError: '历史基准测试数据加载失败。', + retry: '重新加载页面', }, }; export default function HistoricalTrendsDisplay() { - const t = STRINGS[useLocale()]; - const { graphs, loading, hardwareConfig, hwTypesWithData, availableDates } = useInferenceData(); + const locale = useLocale(); + const t = STRINGS[locale]; + const { graphs, loading, error, hardwareConfig, hwTypesWithData, availableDates } = + useInferenceData(); const { selectedModel, selectedSequence, selectedPrecisions, activeHwTypes, selectedRunDate } = useInferenceFilters(); const { selectedYAxisMetric, tokenRevenuePricing, logScale, isLegendExpanded, highContrast } = @@ -98,15 +106,13 @@ export default function HistoricalTrendsDisplay() { // Get Y-axis label and title from chart definition const currentYLabel = useMemo(() => { if (graphs.length === 0) return ''; - const yLabelKey = `${selectedYAxisMetric}_label` as keyof (typeof graphs)[0]['chartDefinition']; - return (graphs[0].chartDefinition[yLabelKey] as string) || ''; - }, [graphs, selectedYAxisMetric]); + return metricLabel(graphs[0].chartDefinition, selectedYAxisMetric, locale); + }, [graphs, locale, selectedYAxisMetric]); const currentYTitle = useMemo(() => { if (graphs.length === 0) return ''; - const yTitleKey = `${selectedYAxisMetric}_title` as keyof (typeof graphs)[0]['chartDefinition']; - return (graphs[0].chartDefinition[yTitleKey] as string) || ''; - }, [graphs, selectedYAxisMetric]); + return metricTitle(graphs[0].chartDefinition, selectedYAxisMetric, locale); + }, [graphs, locale, selectedYAxisMetric]); // Interactivity range from current chart data const interactivityRange = useMemo(() => { @@ -196,7 +202,7 @@ export default function HistoricalTrendsDisplay() { const showsJalapenoPreview = includesJalapenoResult(lineConfigs.map((config) => config.hwKey)); const showsVeraRubinPreview = includesVeraRubinResult(lineConfigs.map((config) => config.hwKey)); - if (loading || graphs.length === 0 || trendLoading) { + if (loading || trendLoading) { return (
@@ -221,6 +227,31 @@ export default function HistoricalTrendsDisplay() { ); } + if (error) { + return ( +
+ +
+
+

{t.heading}

+

{t.loadError}

+
+ +
+
+
+ ); + } + return (
{/* Controls card — same selectors as Inference Performance tab */} diff --git a/packages/app/src/hooks/api/ai-chart-data.test.ts b/packages/app/src/hooks/api/ai-chart-data.test.ts index b7350fd99..fbac750d0 100644 --- a/packages/app/src/hooks/api/ai-chart-data.test.ts +++ b/packages/app/src/hooks/api/ai-chart-data.test.ts @@ -1,9 +1,11 @@ import { describe, expect, it } from 'vitest'; import chartDefinitions from '@/components/inference/metric-registry'; +import { Y_AXIS_METRICS } from '@/lib/chart-utils'; import { buildAiLineData, + getAiRadarMetricLabel, getAiMetricDirection, normalizeAiRadarRows, rankAiHardwareKeys, @@ -12,6 +14,19 @@ import { const chartDefinition = chartDefinitions[0] as Record; +describe('getAiRadarMetricLabel', () => { + it('resolves a valid non-default radar metric through the Chinese metric registry', () => { + expect(getAiRadarMetricLabel('y_measuredAvgPower', chartDefinitions[0], 'zh')).toBe( + '每芯片实测平均功耗(W)', + ); + }); + + it.each(Y_AXIS_METRICS)('never returns a blank axis label for allowed metric %s', (metric) => { + expect(getAiRadarMetricLabel(metric, chartDefinitions[0], 'en')).not.toBe(''); + expect(getAiRadarMetricLabel(metric, chartDefinitions[0], 'zh')).not.toBe(''); + }); +}); + describe('readAiMetric', () => { it('preserves an explicitly measured zero while rejecting a missing telemetry property', () => { expect( diff --git a/packages/app/src/hooks/api/ai-chart-data.ts b/packages/app/src/hooks/api/ai-chart-data.ts index 99787b2cc..b450bd616 100644 --- a/packages/app/src/hooks/api/ai-chart-data.ts +++ b/packages/app/src/hooks/api/ai-chart-data.ts @@ -1,3 +1,7 @@ +import { metricLabel } from '@/lib/chart-utils'; +import type { Locale } from '@/lib/i18n'; +import type { ChartDefinition } from '@/components/inference/types'; + export type AiMetricDirection = 'higher' | 'lower'; export interface AiMetricPoint { @@ -13,6 +17,43 @@ export interface RankAiHardwareOptions { distinctGpus: boolean; } +const AI_RADAR_METRIC_LABELS = { + en: { + y_tpPerGpu: 'Throughput/Chip', + y_outputTputPerGpu: 'Output Tput/Chip', + y_inputTputPerGpu: 'Input Tput/Chip', + y_tpPerMw: 'Tput/MW', + y_costh: 'Cost (Hyper)', + y_costn: 'Cost (Neo)', + y_costr: 'Cost (Rental)', + y_jTotal: 'J/Token', + y_jOutput: 'J/Output', + y_jInput: 'J/Input', + }, + zh: { + y_tpPerGpu: '每芯片吞吐量', + y_outputTputPerGpu: '每芯片输出吞吐量', + y_inputTputPerGpu: '每芯片输入吞吐量', + y_tpPerMw: '每 MW 吞吐量', + y_costh: '成本(Hyperscaler)', + y_costn: '成本(NeoCloud)', + y_costr: '成本(租赁)', + y_jTotal: '每 token 能耗', + y_jOutput: '每输出 token 能耗', + y_jInput: '每输入 token 能耗', + }, +} as const; + +export function getAiRadarMetricLabel( + metric: string, + chartDefinition: ChartDefinition, + locale: Locale, +): string { + const conciseLabel = (AI_RADAR_METRIC_LABELS[locale] as Record)[metric]; + if (conciseLabel) return conciseLabel; + return metricLabel(chartDefinition, metric, locale) || metric; +} + function isMeasuredTelemetryMetric(metric: string): boolean { return metric.startsWith('y_measured'); } diff --git a/packages/app/src/hooks/api/use-ai-chart.ts b/packages/app/src/hooks/api/use-ai-chart.ts index adddb90d5..9e02983c6 100644 --- a/packages/app/src/hooks/api/use-ai-chart.ts +++ b/packages/app/src/hooks/api/use-ai-chart.ts @@ -28,9 +28,11 @@ import { } from '@/lib/benchmark-run-selection'; import { normalizeEvalHardwareKey, generateHighContrastColors } from '@/lib/chart-utils'; import { getHardwareConfig, getModelSortIndex } from '@/lib/constants'; +import type { Locale } from '@/lib/i18n'; import { buildAiLineData, + getAiRadarMetricLabel, normalizeAiRadarRows, rankAiHardwareKeys, readAiMetric, @@ -77,7 +79,7 @@ interface UseAiChartReturn { // LLM response parsing // --------------------------------------------------------------------------- -function parseSpecsFromLlm(raw: string): AiChartSpec[] { +function parseSpecsFromLlm(raw: string, locale: Locale): AiChartSpec[] { const cleaned = raw .replaceAll(/```json\s*/gu, '') .replaceAll('```', '') @@ -85,7 +87,7 @@ function parseSpecsFromLlm(raw: string): AiChartSpec[] { const parsed = JSON.parse(cleaned); const arr = Array.isArray(parsed) ? parsed : [parsed]; // Validate each spec and limit to 2 - return arr.slice(0, 2).map((s: unknown) => validateSpec(s as Record)); + return arr.slice(0, 2).map((s: unknown) => validateSpec(s as Record, locale)); } function sortBars(bars: AiChartBarPoint[], order: AiChartSpec['sortOrder']): void { @@ -248,19 +250,6 @@ function buildReliabilityBarData( // Resolve a single spec into chart data // --------------------------------------------------------------------------- -const METRIC_LABELS: Record = { - y_tpPerGpu: 'Throughput/Chip', - y_outputTputPerGpu: 'Output Tput/Chip', - y_inputTputPerGpu: 'Input Tput/Chip', - y_tpPerMw: 'Tput/MW', - y_costh: 'Cost (Hyper)', - y_costn: 'Cost (Neo)', - y_costr: 'Cost (Rental)', - y_jTotal: 'J/Token', - y_jOutput: 'J/Output', - y_jInput: 'J/Input', -}; - const EMPTY_RESULT: Pick = { lineData: {}, radarData: [], @@ -281,6 +270,7 @@ function buildRadarData( points: InferenceData[], spec: AiChartSpec, colorMap: Record, + locale: Locale, ): { items: AiRadarItem[]; axes: { label: string; unit?: string }[] } { const metrics = spec.radarMetrics ?? ['y_tpPerGpu', 'y_outputTputPerGpu', 'y_costh', 'y_jTotal']; const chartDef = (chartDefinitions as any[])[0]; @@ -320,11 +310,13 @@ function buildRadarData( }); } - const axes = metrics.map((m) => ({ label: METRIC_LABELS[m] ?? m })); + const axes = metrics.map((metric) => ({ + label: getAiRadarMetricLabel(metric, chartDef, locale), + })); return { items, axes }; } -async function resolveSpec(spec: AiChartSpec): Promise { +async function resolveSpec(spec: AiChartSpec, locale: Locale): Promise { if (spec.dataSource === 'evaluations') { const rows = await fetchEvaluations(); const hwKeys = [ @@ -445,7 +437,9 @@ async function resolveSpec(spec: AiChartSpec): Promise { const lineData = spec.chartType === 'line' ? buildLineData(points, spec, colorMap) : {}; const { items: radarData, axes: radarAxes } = - spec.chartType === 'radar' ? buildRadarData(points, spec, colorMap) : { items: [], axes: [] }; + spec.chartType === 'radar' + ? buildRadarData(points, spec, colorMap, locale) + : { items: [], axes: [] }; return { spec, @@ -462,79 +456,94 @@ async function resolveSpec(spec: AiChartSpec): Promise { // Main hook // --------------------------------------------------------------------------- -export function useAiChart(): UseAiChartReturn { +const ERROR_STRINGS = { + en: { + parse: 'Could not parse your request. Try rephrasing.', + noData: (models: string) => + `No data found for ${models}. Try a different model or configuration.`, + providerFailed: 'The chart request failed. Check the API key and provider, then try again.', + }, + zh: { + parse: '无法理解这条请求,请换一种说法。', + noData: (models: string) => `没有找到 ${models} 的数据,请尝试其他模型或配置。`, + providerFailed: '图表请求失败。请检查 API 密钥和服务商设置后重试。', + }, +} as const; + +export function useAiChart(locale: Locale = 'en'): UseAiChartReturn { const [result, setResult] = useState(null); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); - const generate = useCallback(async (prompt: string, provider: AiProvider, apiKey: string) => { - setIsLoading(true); - setError(null); - setResult(null); - - try { - // Step 1: Parse prompt into validated spec(s) - const rawResponse = await callLlm(provider, apiKey, buildParsePrompt(), prompt); - const specs = parseSpecsFromLlm(rawResponse); - - if (specs.length === 0) { - setError('Could not parse your request. Try rephrasing.'); - setIsLoading(false); - return; - } + const generate = useCallback( + async (prompt: string, provider: AiProvider, apiKey: string) => { + setIsLoading(true); + setError(null); + setResult(null); - // Step 2: Resolve each spec into chart data (parallel for multi-chart) - const charts = await Promise.all(specs.map(resolveSpec)); - - // Check if any chart has data - const hasData = charts.some( - (c) => - c.barData.length > 0 || - c.scatterData.length > 0 || - Object.keys(c.lineData).length > 0 || - c.radarData.length > 0, - ); - if (!hasData) { - const models = [...new Set(specs.map((s) => s.model))].join(', '); - setError(`No data found for ${models}. Try a different model or configuration.`); - setIsLoading(false); - return; - } - - // Step 3: Generate summary (best-effort) - let summary: string | null = null; try { - const allBars = charts.flatMap((c) => c.barData); - const allScatter = charts.flatMap((c) => c.scatterData); - const hwKeys = [ - ...new Set([...allBars.map((b) => b.hwKey), ...allScatter.map((p) => p.hwKey ?? '')]), - ].filter(Boolean); - - const dataDesc = - allBars.length > 0 - ? allBars.map((b) => `${b.label}: ${b.value.toFixed(2)}`).join('\n') - : `${allScatter.length} data points across ${hwKeys.length} hardware configs`; - - const summaryRaw = await callLlm( - provider, - apiKey, - buildSummaryPrompt(specs, dataDesc), - 'Provide the summary.', + // Step 1: Parse prompt into validated spec(s) + const rawResponse = await callLlm(provider, apiKey, buildParsePrompt(locale), prompt); + const specs = parseSpecsFromLlm(rawResponse, locale); + + if (specs.length === 0) { + setError(ERROR_STRINGS[locale].parse); + setIsLoading(false); + return; + } + + // Step 2: Resolve each spec into chart data (parallel for multi-chart) + const charts = await Promise.all(specs.map((spec) => resolveSpec(spec, locale))); + + // Check if any chart has data + const hasData = charts.some( + (c) => + c.barData.length > 0 || + c.scatterData.length > 0 || + Object.keys(c.lineData).length > 0 || + c.radarData.length > 0, ); - summary = summaryRaw.trim(); + if (!hasData) { + const models = [...new Set(specs.map((s) => s.model))].join(', '); + setError(ERROR_STRINGS[locale].noData(models)); + setIsLoading(false); + return; + } + + // Step 3: Generate summary (best-effort) + let summary: string | null = null; + try { + const allBars = charts.flatMap((c) => c.barData); + const allScatter = charts.flatMap((c) => c.scatterData); + const hwKeys = [ + ...new Set([...allBars.map((b) => b.hwKey), ...allScatter.map((p) => p.hwKey ?? '')]), + ].filter(Boolean); + + const dataDesc = + allBars.length > 0 + ? allBars.map((b) => `${b.label}: ${b.value.toFixed(2)}`).join('\n') + : `${allScatter.length} data points across ${hwKeys.length} hardware configs`; + + const summaryRaw = await callLlm( + provider, + apiKey, + buildSummaryPrompt(specs, dataDesc, locale), + locale === 'zh' ? '请给出总结。' : 'Provide the summary.', + ); + summary = summaryRaw.trim(); + } catch { + // Summary generation is non-critical + } + + setResult({ charts, summary }); } catch { - // Summary generation is non-critical + setError(ERROR_STRINGS[locale].providerFailed); + } finally { + setIsLoading(false); } - - setResult({ charts, summary }); - } catch (caughtError) { - setError( - caughtError instanceof Error ? caughtError.message : 'An unexpected error occurred.', - ); - } finally { - setIsLoading(false); - } - }, []); + }, + [locale], + ); const reset = useCallback(() => { setResult(null); From 1a76c8988ad2c112ea96120a077967c1fd5326a1 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Sun, 23 Aug 2026 08:19:19 -0700 Subject: [PATCH 2/6] fix(zh): restore localized data workflow recovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Propagate secondary query retry state, preserve feedback modal ARIA labels, format date-only tooltips in UTC, and make reliability failures recoverable. 中文:补齐中文数据页面的错误恢复流程,修复反馈弹窗无障碍标签、UTC 日期格式与可靠性重试。 --- .../cypress/component/feedback-modal.cy.tsx | 45 ++++++++++++ .../component/reliability-bar-chart.cy.tsx | 20 +++++- .../app/cypress/e2e/historical-trends.cy.ts | 25 +++++++ packages/app/cypress/e2e/nudge-system.cy.ts | 21 ++++++ .../app/cypress/e2e/reliability-chart.cy.ts | 27 ++++++-- packages/app/cypress/e2e/zh-pages.cy.ts | 8 +++ packages/app/cypress/support/mock-data.ts | 1 + .../app/src/components/feedback-modal.tsx | 20 ++++-- .../useInterpolatedTrendData.query.test.tsx | 65 ++++++++++++++++++ .../hooks/useInterpolatedTrendData.ts | 13 +++- packages/app/src/components/nudge-engine.tsx | 12 ++-- .../ReliabilityContext.query.test.tsx | 68 +++++++++++++++++++ .../reliability/ReliabilityContext.tsx | 3 + .../app/src/components/reliability/types.ts | 1 + .../components/reliability/ui/BarChartD3.tsx | 33 +++++++-- .../submissions/SubmissionsChart.test.ts | 23 +++++++ .../submissions/SubmissionsChart.tsx | 15 ++-- .../trends/HistoricalTrendsDisplay.tsx | 36 +++++++++- packages/app/src/lib/nudges/registry.tsx | 4 +- packages/app/src/lib/nudges/types.ts | 3 + 20 files changed, 410 insertions(+), 33 deletions(-) create mode 100644 packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx create mode 100644 packages/app/src/components/reliability/ReliabilityContext.query.test.tsx create mode 100644 packages/app/src/components/submissions/SubmissionsChart.test.ts diff --git a/packages/app/cypress/component/feedback-modal.cy.tsx b/packages/app/cypress/component/feedback-modal.cy.tsx index a27f45052..3623e5d6b 100644 --- a/packages/app/cypress/component/feedback-modal.cy.tsx +++ b/packages/app/cypress/component/feedback-modal.cy.tsx @@ -46,6 +46,28 @@ describe('FeedbackForm', () => { cy.get('@onDismiss').should('have.been.calledOnce'); }); + it('keeps engine-provided English accessible labels valid after success', () => { + cy.intercept('POST', '/api/v1/feedback', { statusCode: 204 }).as('postAccessibleEn'); + cy.mount( + , + ); + + cy.get('#feedback-modal-title').should('have.text', 'Help us improve InferenceX'); + cy.get('#feedback-modal-description').should( + 'have.text', + "We'd love to hear what's working and what isn't.", + ); + cy.get('[data-testid="feedback-doing-well"]').type('Clear charts'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.wait('@postAccessibleEn'); + cy.get('#feedback-modal-title').should('have.text', 'Thanks for your feedback!'); + cy.get('#feedback-modal-description').should('have.text', 'We read every response.'); + }); + it('surfaces a 429 as a user-readable error', () => { cy.intercept('POST', '/api/v1/feedback', { statusCode: 429 }).as('post'); cy.mount(); @@ -71,6 +93,29 @@ describe('FeedbackForm', () => { cy.contains('感谢您的反馈!').should('be.visible'); }); + it('keeps engine-provided Chinese accessible labels valid after success', () => { + cy.intercept('POST', '/api/v1/feedback', { statusCode: 204 }).as('postAccessibleZh'); + cy.mount( + , + ); + + cy.get('#feedback-modal-title').should('have.text', '帮助我们改进 InferenceX'); + cy.get('#feedback-modal-description').should( + 'have.text', + '欢迎告诉我们哪些体验不错,以及哪些地方需要改进。', + ); + cy.get('[data-testid="feedback-doing-well"]').type('图表很清晰'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.wait('@postAccessibleZh'); + cy.get('#feedback-modal-title').should('have.text', '感谢您的反馈!'); + cy.get('#feedback-modal-description').should('have.text', '我们会认真阅读每一条反馈。'); + }); + it('localizes rate-limit and server errors on Chinese routes', () => { cy.intercept('POST', '/api/v1/feedback', { statusCode: 429 }).as('rateLimited'); cy.mount(); diff --git a/packages/app/cypress/component/reliability-bar-chart.cy.tsx b/packages/app/cypress/component/reliability-bar-chart.cy.tsx index 94f494028..b20c9c9a1 100644 --- a/packages/app/cypress/component/reliability-bar-chart.cy.tsx +++ b/packages/app/cypress/component/reliability-bar-chart.cy.tsx @@ -2,13 +2,27 @@ import ReliabilityBarChartD3 from '@/components/reliability/ui/BarChartD3'; import { mountWithProviders } from '../support/test-utils'; import { createMockReliabilityData } from '../support/mock-data'; import { Model } from '@/lib/data-mappings'; +import { registerAnalyticsClient } from '@/lib/analytics'; describe('ReliabilityBarChartD3', () => { - it('shows error message when error is set', () => { + it('tracks retry before requesting reliability data again', () => { + const capture = cy.stub(); + const refetch = cy.stub().resolves(); + registerAnalyticsClient({ capture }); mountWithProviders(, { - reliability: { error: 'Server error', chartData: [] }, + reliability: { error: 'Server error', chartData: [], refetch }, + }); + cy.get('[data-testid="reliability-error"]') + .should('contain.text', 'Failed to load reliability data.') + .find('button') + .should('have.text', 'Retry') + .click(); + + cy.then(() => { + expect(capture).to.have.been.calledWith('reliability_retry_clicked'); + expect(refetch.callCount).to.eq(1); + expect(capture).to.have.been.calledBefore(refetch); }); - cy.contains('Failed to load reliability data.').should('be.visible'); }); it('shows "No reliability data" when chartData is empty', () => { diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index 4e9ec4520..f103d7ad0 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -225,6 +225,31 @@ describe('Historical Trends — Chinese route', () => { cy.contains('historical-database-internal-detail').should('not.exist'); }); + it('shows a distinct secondary-history error and recovers through the tracked retry', () => { + cy.fixture('api/benchmarks-history.json').then((historyRows) => { + let attempts = 0; + cy.intercept('GET', '**/api/v1/benchmarks/history?*', (request) => { + attempts += 1; + request.reply( + attempts <= 2 + ? { statusCode: 500, body: { error: 'secondary-history-internal-detail' } } + : { body: historyRows }, + ); + }).as('secondaryHistory'); + + cy.reload(); + cy.wait('@secondaryHistory'); + cy.wait('@secondaryHistory'); + cy.get('[data-testid="historical-trend-error"]') + .should('contain.text', '历史趋势数据加载失败。') + .and('not.contain.text', '历史基准测试数据加载失败。') + .and('not.contain.text', 'secondary-history-internal-detail'); + cy.contains('button', '重试加载趋势数据').click(); + cy.wait('@secondaryHistory'); + cy.get('[data-testid="historical-trend-figure"]').should('be.visible'); + }); + }); + for (const width of [375, 390]) { it(`keeps the target controls and chart reachable at ${width}px`, () => { cy.viewport(width, 844); diff --git a/packages/app/cypress/e2e/nudge-system.cy.ts b/packages/app/cypress/e2e/nudge-system.cy.ts index 530d8a775..a07c696a8 100644 --- a/packages/app/cypress/e2e/nudge-system.cy.ts +++ b/packages/app/cypress/e2e/nudge-system.cy.ts @@ -21,6 +21,8 @@ function clearAllNudgeStorage(win: Cypress.AUTWindow) { 'inferencex-gradient-nudge-shown', 'inferencex-eval-samples-nudge-dismissed', 'inferencex-filter-hint-nudge-dismissed', + 'inferencex-feedback-modal-snoozed', + 'inferencex-feedback-modal-submitted', ]; for (const key of keys) { win.localStorage.removeItem(key); @@ -28,6 +30,25 @@ function clearAllNudgeStorage(win: Cypress.AUTWindow) { } } +describe('Dashboard feedback modal accessibility', () => { + it('has a valid English accessible name and description', () => { + cy.visit('/inference', { + onBeforeLoad: clearAllNudgeStorage, + }); + + cy.get('[data-testid="feedback-modal"]') + .should('be.visible') + .and('have.attr', 'role', 'dialog') + .and('have.attr', 'aria-labelledby', 'feedback-modal-title') + .and('have.attr', 'aria-describedby', 'feedback-modal-description'); + cy.get('#feedback-modal-title').should('have.text', 'Help us improve InferenceX'); + cy.get('#feedback-modal-description').should( + 'have.text', + "We'd love to hear what's working and what isn't.", + ); + }); +}); + // `cypress.config.ts` runs with `testIsolation: false` — the browser context // (incl. localStorage / sessionStorage) survives across tests in this spec. // Defensively clear before each test so a missed `onBeforeLoad` in any test diff --git a/packages/app/cypress/e2e/reliability-chart.cy.ts b/packages/app/cypress/e2e/reliability-chart.cy.ts index 14c16a51a..c2f3d288b 100644 --- a/packages/app/cypress/e2e/reliability-chart.cy.ts +++ b/packages/app/cypress/e2e/reliability-chart.cy.ts @@ -176,15 +176,28 @@ describe('Reliability Chart — Chinese route and settled states', () => { cy.contains('正在加载可靠性数据……').should('not.exist'); }); - it('shows a safe Chinese error instead of a raw API response', () => { - cy.intercept('GET', '**/api/v1/reliability', { - statusCode: 500, - body: { error: 'database-internal-detail' }, - }).as('failedReliability'); + it('shows a safe Chinese error and recovers through the tracked retry control', () => { + let attempts = 0; + cy.fixture('api/reliability.json').then((fixture) => { + cy.intercept('GET', '**/api/v1/reliability', (req) => { + attempts += 1; + req.reply( + attempts <= 2 + ? { statusCode: 500, body: { error: 'database-internal-detail' } } + : { statusCode: 200, body: fixture }, + ); + }).as('retryReliability'); + }); cy.visit('/zh/reliability'); - cy.wait('@failedReliability'); - cy.contains('可靠性数据加载失败。').should('be.visible'); + cy.wait('@retryReliability'); + cy.wait('@retryReliability'); + cy.get('[data-testid="reliability-error"]') + .should('be.visible') + .and('contain.text', '可靠性数据加载失败。'); cy.contains('database-internal-detail').should('not.exist'); + cy.contains('[data-testid="reliability-error"] button', '重试').click(); + cy.wait('@retryReliability'); + cy.get('#reliability-chart svg rect.bar').should('have.length.greaterThan', 0); }); for (const width of [375, 390]) { diff --git a/packages/app/cypress/e2e/zh-pages.cy.ts b/packages/app/cypress/e2e/zh-pages.cy.ts index 0b8ca2d80..1bdaf2834 100644 --- a/packages/app/cypress/e2e/zh-pages.cy.ts +++ b/packages/app/cypress/e2e/zh-pages.cy.ts @@ -283,8 +283,16 @@ describe('Chinese (/zh) pages', () => { cy.get('[data-testid="feedback-modal"]') .should('be.visible') + .and('have.attr', 'role', 'dialog') + .and('have.attr', 'aria-labelledby', 'feedback-modal-title') + .and('have.attr', 'aria-describedby', 'feedback-modal-description') .and('contain.text', '帮助我们改进 InferenceX') .and('contain.text', '您的反馈会加密保存'); + cy.get('#feedback-modal-title').should('have.text', '帮助我们改进 InferenceX'); + cy.get('#feedback-modal-description').should( + 'have.text', + '欢迎告诉我们哪些体验不错,以及哪些地方需要改进。', + ); cy.get('[data-testid="feedback-modal-dismiss"]').click(); cy.get('[data-testid="feedback-modal"]').should('not.exist'); }); diff --git a/packages/app/cypress/support/mock-data.ts b/packages/app/cypress/support/mock-data.ts index 56f28ffce..8482bd693 100644 --- a/packages/app/cypress/support/mock-data.ts +++ b/packages/app/cypress/support/mock-data.ts @@ -413,6 +413,7 @@ export function createMockReliabilityContext( return { loading: false, error: null, + refetch: namedStub('refetchReliability').resolves(), dateRangeSuccessRateData: { 'last-7-days': { [Model.DeepSeek_R1]: { diff --git a/packages/app/src/components/feedback-modal.tsx b/packages/app/src/components/feedback-modal.tsx index 78a4d8dd0..95211fe9f 100644 --- a/packages/app/src/components/feedback-modal.tsx +++ b/packages/app/src/components/feedback-modal.tsx @@ -22,6 +22,9 @@ export interface FeedbackFormProps { onDismiss: () => void; /** Test/embedded-surface override. Production defaults to the current route locale. */ locale?: Locale; + /** Engine-owned IDs referenced by the containing dialog. */ + titleId?: string; + descriptionId?: string; } const STRINGS = { @@ -63,7 +66,12 @@ const STRINGS = { }, } as const; -export function FeedbackForm({ onDismiss, locale: localeOverride }: FeedbackFormProps) { +export function FeedbackForm({ + onDismiss, + locale: localeOverride, + titleId: titleIdOverride, + descriptionId: descriptionIdOverride, +}: FeedbackFormProps) { const [doingWell, setDoingWell] = useState(''); const [doingPoorly, setDoingPoorly] = useState(''); const [wantToSee, setWantToSee] = useState(''); @@ -74,8 +82,10 @@ export function FeedbackForm({ onDismiss, locale: localeOverride }: FeedbackForm const routeLocale = useLocale(); const locale = localeOverride ?? routeLocale; const t = STRINGS[locale]; - const titleId = useId(); - const descId = useId(); + const generatedTitleId = useId(); + const generatedDescriptionId = useId(); + const titleId = titleIdOverride ?? generatedTitleId; + const descriptionId = descriptionIdOverride ?? generatedDescriptionId; const handleSubmit = useCallback(async () => { if (status === 'submitting') return; @@ -149,7 +159,7 @@ export function FeedbackForm({ onDismiss, locale: localeOverride }: FeedbackForm

{t.successTitle}

-

+

{t.successBody}

@@ -163,7 +173,7 @@ export function FeedbackForm({ onDismiss, locale: localeOverride }: FeedbackForm {t.title} -

+

{t.description}

diff --git a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx new file mode 100644 index 000000000..0696b4922 --- /dev/null +++ b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx @@ -0,0 +1,65 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { Model, Sequence } from '@/lib/data-mappings'; + +const { mockFetchBenchmarkHistory } = vi.hoisted(() => ({ + mockFetchBenchmarkHistory: vi.fn(), +})); +vi.mock('@/lib/api', () => ({ fetchBenchmarkHistory: mockFetchBenchmarkHistory })); + +import { useInterpolatedTrendData } from './useInterpolatedTrendData'; + +let observed: + | { + error?: Error | null; + refetch?: () => Promise; + } + | undefined; + +function Probe() { + observed = useInterpolatedTrendData({ + selectedModel: Model.DeepSeek_R1, + selectedSequence: Sequence.OneK_OneK, + selectedPrecisions: ['fp8'], + selectedYAxisMetric: 'y_tpPerGpu', + targetInteractivity: 40, + availableDates: [], + enabled: true, + }); + return null; +} + +describe('useInterpolatedTrendData query state', () => { + let root: Root | undefined; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = undefined; + observed = undefined; + vi.clearAllMocks(); + }); + + it('propagates a history failure and exposes a refetch that can recover', async () => { + mockFetchBenchmarkHistory.mockRejectedValueOnce(new Error('secondary history failed')); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + root = createRoot(document.createElement('div')); + + await act(() => { + root?.render(createElement(QueryClientProvider, { client }, createElement(Probe))); + }); + + await vi.waitFor(() => expect(observed?.error?.message).toBe('secondary history failed')); + expect(observed?.refetch).toBeTypeOf('function'); + + mockFetchBenchmarkHistory.mockResolvedValueOnce([]); + await act(async () => { + await observed?.refetch?.(); + }); + + await vi.waitFor(() => expect(observed?.error).toBeNull()); + }); +}); diff --git a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts index 47c06962d..298e83d79 100644 --- a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts +++ b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.ts @@ -294,6 +294,8 @@ interface UseInterpolatedTrendDataResult { hwKeysWithData: string[]; loading: boolean; progress: number; + error: Error | null; + refetch: () => Promise; } /** @@ -314,7 +316,12 @@ export function useInterpolatedTrendData({ }: UseInterpolatedTrendDataParams): UseInterpolatedTrendDataResult { const seqIslOsl = useMemo(() => sequenceToIslOsl(selectedSequence), [selectedSequence]); - const { data: allRows, isLoading } = useBenchmarkHistory( + const { + data: allRows, + isLoading, + error, + refetch, + } = useBenchmarkHistory( enabled ? selectedModel : '', seqIslOsl?.isl ?? 0, seqIslOsl?.osl ?? 0, @@ -437,8 +444,10 @@ export function useInterpolatedTrendData({ hwKeysWithData: [], loading: false, progress: 0, + error: null, + refetch, }; } - return { trendLines, hwKeysWithData, loading: isLoading, progress }; + return { trendLines, hwKeysWithData, loading: isLoading, progress, error, refetch }; } diff --git a/packages/app/src/components/nudge-engine.tsx b/packages/app/src/components/nudge-engine.tsx index 76386c963..0d2f6f066 100644 --- a/packages/app/src/components/nudge-engine.tsx +++ b/packages/app/src/components/nudge-engine.tsx @@ -483,6 +483,8 @@ function ModalRenderer({ const { content } = def; const Icon = content.icon; const idPrefix = def.id; + const titleId = `${idPrefix}-title`; + const descriptionId = `${idPrefix}-description`; const centered = content.centered; const dialog = ( @@ -490,8 +492,8 @@ function ModalRenderer({ data-testid={content.testId} role="dialog" aria-modal={centered ? 'true' : 'false'} - aria-labelledby={`${idPrefix}-title`} - aria-describedby={`${idPrefix}-description`} + aria-labelledby={titleId} + aria-describedby={descriptionId} className={ centered ? `relative z-50 w-[calc(100vw-2rem)] max-w-md rounded-lg border bg-background p-6 shadow-lg ${content.containerClassName ?? ''}` @@ -507,11 +509,11 @@ function ModalRenderer({ {content.renderContent ? ( - content.renderContent({ dismiss: onDismiss }) + content.renderContent({ dismiss: onDismiss, titleId, descriptionId }) ) : (
-

+

{localized(locale, content.title, content.titleZh)} {content.badge && ( @@ -520,7 +522,7 @@ function ModalRenderer({ )}

-

+

{localized(locale, content.description, content.descriptionZh)}

diff --git a/packages/app/src/components/reliability/ReliabilityContext.query.test.tsx b/packages/app/src/components/reliability/ReliabilityContext.query.test.tsx new file mode 100644 index 000000000..7b22cb56e --- /dev/null +++ b/packages/app/src/components/reliability/ReliabilityContext.query.test.tsx @@ -0,0 +1,68 @@ +// @vitest-environment jsdom +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; +import { act, createElement } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +const { mockFetchReliability } = vi.hoisted(() => ({ + mockFetchReliability: vi.fn(), +})); + +vi.mock('@/lib/api', () => ({ fetchReliability: mockFetchReliability })); +vi.mock('@/hooks/useUrlState', () => ({ + useUrlState: () => ({ + getUrlParam: () => undefined, + setUrlParams: vi.fn(), + }), +})); + +import { ReliabilityProvider, useReliabilityContext } from './ReliabilityContext'; + +let observed: + | { + error?: string | null; + refetch?: () => Promise; + } + | undefined; + +function Probe() { + observed = useReliabilityContext(); + return null; +} + +describe('ReliabilityProvider query state', () => { + let root: Root | undefined; + + afterEach(() => { + if (root) act(() => root?.unmount()); + root = undefined; + observed = undefined; + vi.clearAllMocks(); + }); + + it('exposes a refetch that clears a reliability error after recovery', async () => { + mockFetchReliability.mockRejectedValueOnce(new Error('reliability failed')); + const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + root = createRoot(document.createElement('div')); + + await act(() => { + root?.render( + createElement( + QueryClientProvider, + { client }, + createElement(ReliabilityProvider, null, createElement(Probe)), + ), + ); + }); + + await vi.waitFor(() => expect(observed?.error).toBe('reliability failed')); + expect(observed?.refetch).toBeTypeOf('function'); + + mockFetchReliability.mockResolvedValueOnce([]); + await act(async () => { + await observed?.refetch?.(); + }); + + await vi.waitFor(() => expect(observed?.error).toBeNull()); + }); +}); diff --git a/packages/app/src/components/reliability/ReliabilityContext.tsx b/packages/app/src/components/reliability/ReliabilityContext.tsx index d883a4e18..61a9c9e12 100644 --- a/packages/app/src/components/reliability/ReliabilityContext.tsx +++ b/packages/app/src/components/reliability/ReliabilityContext.tsx @@ -79,6 +79,7 @@ export function ReliabilityProvider({ children }: { children: ReactNode }) { isLoading: loading, isSuccess: reliabilitySettled, error: queryError, + refetch, } = useReliability(); const error = queryError ? queryError.message : null; @@ -219,6 +220,7 @@ export function ReliabilityProvider({ children }: { children: ReactNode }) { () => ({ loading, error, + refetch, dateRangeSuccessRateData, filteredReliabilityData, chartData, @@ -240,6 +242,7 @@ export function ReliabilityProvider({ children }: { children: ReactNode }) { [ loading, error, + refetch, dateRangeSuccessRateData, filteredReliabilityData, chartData, diff --git a/packages/app/src/components/reliability/types.ts b/packages/app/src/components/reliability/types.ts index dc9f3312e..6f16a716a 100644 --- a/packages/app/src/components/reliability/types.ts +++ b/packages/app/src/components/reliability/types.ts @@ -78,6 +78,7 @@ export type DateRangeSuccessRateData = Record Promise; dateRangeSuccessRateData: DateRangeSuccessRateData; filteredReliabilityData: ModelSuccessRateData[]; chartData: (ModelSuccessRateData & { modelLabel: string })[]; diff --git a/packages/app/src/components/reliability/ui/BarChartD3.tsx b/packages/app/src/components/reliability/ui/BarChartD3.tsx index cddb72fb7..2110b0df4 100644 --- a/packages/app/src/components/reliability/ui/BarChartD3.tsx +++ b/packages/app/src/components/reliability/ui/BarChartD3.tsx @@ -17,6 +17,7 @@ import { useThemeColors } from '@/hooks/useThemeColors'; import { type Locale } from '@/lib/i18n'; import { useLocale } from '@/lib/use-locale'; import ChartLegend from '@/components/ui/chart-legend'; +import { Button } from '@/components/ui/button'; type ChartItem = ModelSuccessRateData & { modelLabel: string }; @@ -106,6 +107,7 @@ const RELIABILITY_STRINGS = { xAxis: 'Success Rate (%)', loading: 'Loading reliability data…', loadError: 'Failed to load reliability data.', + retry: 'Retry', noData: 'No reliability data available for this date range.', instructions: 'Shift+Scroll to zoom horizontally · Drag to pan · Double-click to reset · Hover for details', @@ -117,6 +119,7 @@ const RELIABILITY_STRINGS = { xAxis: '成功率(%)', loading: '正在加载可靠性数据……', loadError: '可靠性数据加载失败。', + retry: '重试', noData: '所选时间范围内暂无可靠性数据。', instructions: 'Shift+滚轮横向缩放 · 拖动平移 · 双击重置 · 悬停查看详情', runCount: (success: number, total: number) => `${success}/${total} 次运行`, @@ -128,6 +131,7 @@ export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode const { loading, error, + refetch, chartData, highContrast, setHighContrast, @@ -378,10 +382,31 @@ export default function ReliabilityBarChartD3({ caption }: { caption?: ReactNode const isEmpty = loading || error || chartData.length === 0; const emptyOverlay = isEmpty ? ( -
-

- {loading ? legendT.loading : error ? legendT.loadError : legendT.noData} -

+
+ {error ? ( +
+

{legendT.loadError}

+ +
+ ) : ( +

+ {loading ? legendT.loading : legendT.noData} +

+ )}
) : null; diff --git a/packages/app/src/components/submissions/SubmissionsChart.test.ts b/packages/app/src/components/submissions/SubmissionsChart.test.ts new file mode 100644 index 000000000..3913da3fa --- /dev/null +++ b/packages/app/src/components/submissions/SubmissionsChart.test.ts @@ -0,0 +1,23 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { formatSubmissionTooltipDate } from './SubmissionsChart'; + +const originalTimeZone = process.env.TZ; + +afterEach(() => { + if (originalTimeZone === undefined) delete process.env.TZ; + else process.env.TZ = originalTimeZone; +}); + +describe('formatSubmissionTooltipDate', () => { + it.each([ + ['en' as const, 'Jan 1, 2025'], + ['zh' as const, '2025年1月1日'], + ])('keeps a date-only UTC value on the same day for %s', (locale, expected) => { + process.env.TZ = 'America/Los_Angeles'; + const midnightUtc = Date.parse('2025-01-01T00:00:00Z'); + + expect(new Date(midnightUtc).getDate()).toBe(31); + expect(formatSubmissionTooltipDate(midnightUtc, locale)).toBe(expected); + }); +}); diff --git a/packages/app/src/components/submissions/SubmissionsChart.tsx b/packages/app/src/components/submissions/SubmissionsChart.tsx index 2d5187024..24012f48b 100644 --- a/packages/app/src/components/submissions/SubmissionsChart.tsx +++ b/packages/app/src/components/submissions/SubmissionsChart.tsx @@ -48,14 +48,19 @@ function lineColor(key: string): string { const LINE_KEYS = ['nvidia', 'amd', 'total'] as const; type LineKey = (typeof LINE_KEYS)[number]; -function generateTooltipContent(d: ChartPoint, isPinned: boolean, locale: Locale): string { - const t = SUBMISSIONS_STRINGS[locale]; - const numberLocale = locale === 'zh' ? 'zh-CN' : 'en-US'; - const dateStr = new Date(d.date).toLocaleDateString(numberLocale, { +export function formatSubmissionTooltipDate(date: number, locale: Locale): string { + return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { year: 'numeric', month: 'short', day: 'numeric', - }); + timeZone: 'UTC', + }).format(new Date(date)); +} + +function generateTooltipContent(d: ChartPoint, isPinned: boolean, locale: Locale): string { + const t = SUBMISSIONS_STRINGS[locale]; + const numberLocale = locale === 'zh' ? 'zh-CN' : 'en-US'; + const dateStr = formatSubmissionTooltipDate(d.date, locale); return `
${isPinned ? `
${t.dismiss}
` : ''} diff --git a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx index af0702b4a..bb3c45a94 100644 --- a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx +++ b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx @@ -63,6 +63,8 @@ const STRINGS = { noData: 'No interactivity chart data available for the selected model and sequence.', loadError: 'Historical benchmark data could not be loaded.', retry: 'Reload page', + trendLoadError: 'Historical trend data could not be loaded.', + trendRetry: 'Retry loading trend data', }, zh: { heading: '历史趋势', @@ -79,6 +81,8 @@ const STRINGS = { noData: '所选模型和序列无可用的交互性图表数据。', loadError: '历史基准测试数据加载失败。', retry: '重新加载页面', + trendLoadError: '历史趋势数据加载失败。', + trendRetry: '重试加载趋势数据', }, }; @@ -154,7 +158,12 @@ export default function HistoricalTrendsDisplay() { }, [interactivityInput, targetInteractivity, interactivityRange]); // Interpolated trend data - const { trendLines, loading: trendLoading } = useInterpolatedTrendData({ + const { + trendLines, + loading: trendLoading, + error: trendError, + refetch: refetchTrendData, + } = useInterpolatedTrendData({ selectedModel: selectedModel as Model, selectedSequence: selectedSequence as Sequence, selectedPrecisions, @@ -252,6 +261,31 @@ export default function HistoricalTrendsDisplay() { ); } + if (trendError) { + return ( +
+ +
+
+

{t.heading}

+

{t.trendLoadError}

+
+ +
+
+
+ ); + } + return (
{/* Controls card — same selectors as Inference Performance tab */} diff --git a/packages/app/src/lib/nudges/registry.tsx b/packages/app/src/lib/nudges/registry.tsx index f42b00f28..988a9835c 100644 --- a/packages/app/src/lib/nudges/registry.tsx +++ b/packages/app/src/lib/nudges/registry.tsx @@ -344,7 +344,9 @@ export const NUDGE_REGISTRY: NudgeDefinition[] = [ descriptionZh: '我们非常希望了解哪些方面做得好,哪些方面需要改进。', testId: 'feedback-modal', centered: true, - renderContent: ({ dismiss }) => , + renderContent: ({ dismiss, titleId, descriptionId }) => ( + + ), }, analytics: { shown: 'feedback_modal_shown', diff --git a/packages/app/src/lib/nudges/types.ts b/packages/app/src/lib/nudges/types.ts index d3e72fccf..c42d9a85b 100644 --- a/packages/app/src/lib/nudges/types.ts +++ b/packages/app/src/lib/nudges/types.ts @@ -57,6 +57,9 @@ export interface NudgeAction { export interface NudgeRenderContext { dismiss: () => void; + /** IDs owned by the engine's dialog accessibility contract. */ + titleId: string; + descriptionId: string; } export interface NudgeAnchor { From 01f4e0ae8a343f545b9bfb6009c84eda9b9244c3 Mon Sep 17 00:00:00 2001 From: wenyao Date: Sat, 29 Aug 2026 00:09:49 +0000 Subject: [PATCH 3/6] =?UTF-8?q?fix(zh):=20stabilize=20localized=20e2e=20sp?= =?UTF-8?q?ecs=20after=20rebase=20/=20=E4=BF=AE=E5=A4=8D=E5=8F=98=E5=9F=BA?= =?UTF-8?q?=E5=90=8E=E6=9C=AC=E5=9C=B0=E5=8C=96=E7=AB=AF=E5=88=B0=E7=AB=AF?= =?UTF-8?q?=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - nudge-system: keep the feedback-modal snooze keys out of the shared clearAllNudgeStorage helper — clearing them un-snoozed the support-file seed and let the immediate feedback modal claim the overlay slot, suppressing the reproducibility/filter-hint toasts in every other test. The accessibility test now clears those keys in its own onBeforeLoad. - zh-pages: the first table row (sorted by datapoints) is an aggregate deployment, so assert the 聚合推理 detail labels there and expand a disaggregated Mooncake ATOMesh row for the 预填充/解码 labels; make the error/retry intercept fail until the retry button is actually clicked instead of counting attempts (race-prone with query retries). - historical-trends: surface the error card before the loading skeleton in HistoricalTrendsDisplay — a failed benchmark query never produces rows, so the loading flag (which includes "no rows yet") pinned the page on the skeleton forever and 历史基准测试数据加载失败。 never rendered. --- packages/app/cypress/e2e/nudge-system.cy.ts | 19 +++++-- packages/app/cypress/e2e/zh-pages.cy.ts | 29 +++++++--- .../trends/HistoricalTrendsDisplay.tsx | 53 ++++++++++--------- 3 files changed, 65 insertions(+), 36 deletions(-) diff --git a/packages/app/cypress/e2e/nudge-system.cy.ts b/packages/app/cypress/e2e/nudge-system.cy.ts index a07c696a8..a14fc3242 100644 --- a/packages/app/cypress/e2e/nudge-system.cy.ts +++ b/packages/app/cypress/e2e/nudge-system.cy.ts @@ -21,8 +21,6 @@ function clearAllNudgeStorage(win: Cypress.AUTWindow) { 'inferencex-gradient-nudge-shown', 'inferencex-eval-samples-nudge-dismissed', 'inferencex-filter-hint-nudge-dismissed', - 'inferencex-feedback-modal-snoozed', - 'inferencex-feedback-modal-submitted', ]; for (const key of keys) { win.localStorage.removeItem(key); @@ -30,10 +28,25 @@ function clearAllNudgeStorage(win: Cypress.AUTWindow) { } } +// The support file seeds `inferencex-feedback-modal-snoozed` on every page +// load so the modal's backdrop stays out of other specs' way. This spec's +// accessibility test is the one place that wants the real modal, so it clears +// the seed (spec `onBeforeLoad` runs after the support hook). Keep the +// feedback keys OUT of `clearAllNudgeStorage`: the immediate feedback modal +// claims the shared overlay slot and would suppress the delayed +// reproducibility / filter-hint toasts every other test asserts on. +function clearNudgeStorageAndUnsnoozeFeedbackModal(win: Cypress.AUTWindow) { + clearAllNudgeStorage(win); + for (const key of ['inferencex-feedback-modal-snoozed', 'inferencex-feedback-modal-submitted']) { + win.localStorage.removeItem(key); + win.sessionStorage.removeItem(key); + } +} + describe('Dashboard feedback modal accessibility', () => { it('has a valid English accessible name and description', () => { cy.visit('/inference', { - onBeforeLoad: clearAllNudgeStorage, + onBeforeLoad: clearNudgeStorageAndUnsnoozeFeedbackModal, }); cy.get('[data-testid="feedback-modal"]') diff --git a/packages/app/cypress/e2e/zh-pages.cy.ts b/packages/app/cypress/e2e/zh-pages.cy.ts index 1bdaf2834..a32446ddf 100644 --- a/packages/app/cypress/e2e/zh-pages.cy.ts +++ b/packages/app/cypress/e2e/zh-pages.cy.ts @@ -171,8 +171,17 @@ describe('Chinese (/zh) pages', () => { cy.get('button[aria-label="展开配置详情"]').first().click(); cy.get('[data-testid="submissions-display"]') .should('contain.text', '投机解码方法:') - .and('contain.text', '预填充') - .and('contain.text', '解码'); + .and('contain.text', '分离式部署:') + .and('contain.text', '聚合推理芯片数:'); + // Disaggregated deployments split the chip pool, so their expanded + // details localize the prefill/decode fields instead of the aggregate ones. + cy.contains('tr', 'Mooncake ATOMesh') + .first() + .find('button[aria-label="展开配置详情"]') + .click(); + cy.get('[data-testid="submissions-display"]') + .should('contain.text', '预填充芯片数:') + .and('contain.text', '解码芯片数:'); }); it('separates localized empty chart and table states', () => { @@ -186,22 +195,26 @@ describe('Chinese (/zh) pages', () => { }); it('shows a safe error and retries through a real button click', () => { - let attempts = 0; + // Fail every request until the retry button is actually clicked — counting + // attempts is race-prone because query retries can consume the "healthy" + // response before the error UI is asserted. + let failRequests = true; cy.intercept('GET', '**/api/v1/submissions', (request) => { - attempts += 1; request.reply( - attempts <= 2 + failRequests ? { statusCode: 500, body: { error: 'submissions-database-internal-detail' } } : { body: { summary: [], volume: [] } }, ); }).as('retrySubmissions'); cy.reload(); cy.wait('@retrySubmissions'); - cy.wait('@retrySubmissions'); cy.contains('加载提交数据失败。').should('be.visible'); cy.contains('submissions-database-internal-detail').should('not.exist'); - cy.contains('button', '重试').click(); - cy.wait('@retrySubmissions'); + cy.contains('button', '重试') + .then(() => { + failRequests = false; + }) + .click(); cy.contains('暂无提交记录。').should('be.visible'); }); diff --git a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx index bb3c45a94..99a420043 100644 --- a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx +++ b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx @@ -211,31 +211,9 @@ export default function HistoricalTrendsDisplay() { const showsJalapenoPreview = includesJalapenoResult(lineConfigs.map((config) => config.hwKey)); const showsVeraRubinPreview = includesVeraRubinResult(lineConfigs.map((config) => config.hwKey)); - if (loading || trendLoading) { - return ( -
- -
-
-

{t.heading}

-

{t.description}

-
- -
- - -
-
-
- - - - - -
- ); - } - + // Check `error` before the loading skeleton: a failed benchmark query never + // produces rows, so `loading` (which includes "no rows yet") would otherwise + // pin the page on the skeleton forever instead of surfacing the error card. if (error) { return (
@@ -261,6 +239,31 @@ export default function HistoricalTrendsDisplay() { ); } + if (loading || trendLoading) { + return ( +
+ +
+
+

{t.heading}

+

{t.description}

+
+ +
+ + +
+
+
+ + + + + +
+ ); + } + if (trendError) { return (
From 41176c03bba47e50555095a32b422bc5522aa648 Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Fri, 28 Aug 2026 20:29:24 -0700 Subject: [PATCH 4/6] fix(zh): finish data-tool copy review MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address the valid accessibility and workflow-status review findings, refine the remaining Chinese data-tool copy, preserve English behavior, and record observed Cypress timings for the rebalanced specs.\n\n中文:处理有效的无障碍与工作流状态审查意见,完成数据与工具页面的中文文案终审,保持英文页面行为不变,并记录调整后 Cypress 测试的实测时长。 --- packages/app/cypress/component/tab-nav.cy.tsx | 9 ++ packages/app/cypress/e2e/ai-chart.cy.ts | 57 +++++++++++- packages/app/cypress/e2e/collectivex.cy.ts | 91 ++++++++++++++++++- .../app/cypress/e2e/historical-trends.cy.ts | 35 +++++-- packages/app/cypress/e2e/zh-pages.cy.ts | 40 +++++++- .../components/ai-chart/AiChartDisplay.tsx | 10 +- .../src/components/ai-chart/AiChartResult.tsx | 6 +- .../collectivex/CollectiveXDisplay.tsx | 11 +-- .../collectivex/CollectiveXKvChart.tsx | 4 +- .../CollectiveXKvFrontierChart.tsx | 2 +- .../collectivex/CollectiveXKvSection.tsx | 8 +- .../collectivex/CollectiveXRunsTable.tsx | 14 +-- .../src/components/collectivex/data.test.ts | 28 ++++++ .../app/src/components/collectivex/data.ts | 38 ++++++++ .../feedback-viewer/FeedbackViewer.tsx | 4 +- .../submissions/SubmissionsChart.tsx | 26 +++++- packages/app/src/components/tab-nav.tsx | 13 ++- .../trends/HistoricalTrendsDisplay.tsx | 4 +- 18 files changed, 339 insertions(+), 61 deletions(-) diff --git a/packages/app/cypress/component/tab-nav.cy.tsx b/packages/app/cypress/component/tab-nav.cy.tsx index 24f86098f..2298179c8 100644 --- a/packages/app/cypress/component/tab-nav.cy.tsx +++ b/packages/app/cypress/component/tab-nav.cy.tsx @@ -108,6 +108,15 @@ describe('TabNav — Hidden popover for gated tabs', () => { cy.get('[data-testid="tab-trigger-collectivex"]').should('not.exist'); }); + it('still names the current gated page in the locked mobile selector', () => { + cy.viewport(390, 844); + cy.window().then((win) => win.localStorage.removeItem('inferencex-feature-gate')); + mountTabNav({ pathname: '/zh/ai-chart' }); + cy.get('[data-testid="mobile-chart-select"]') + .should('be.visible') + .and('contain.text', 'AI 图表'); + }); + it('renders the Hidden trigger when unlocked; popover reveals gated links', () => { cy.window().then((win) => win.localStorage.setItem('inferencex-feature-gate', '1')); mountTabNav({}); diff --git a/packages/app/cypress/e2e/ai-chart.cy.ts b/packages/app/cypress/e2e/ai-chart.cy.ts index 262fdfb09..7769d34d9 100644 --- a/packages/app/cypress/e2e/ai-chart.cy.ts +++ b/packages/app/cypress/e2e/ai-chart.cy.ts @@ -199,7 +199,7 @@ describe('AI chart Chinese workflow', () => { cy.visit('/zh/ai-chart'); cy.get('input[placeholder="OpenAI API Key"]').type('test-api-key', { log: false }); - cy.get('textarea[placeholder="描述您想查看的图表……"]').type( + cy.get('textarea[placeholder="描述想查看的图表……"]').type( '对比 B200 和 MI355X 的每芯片吞吐量', ); cy.contains('button', '生成图表').click(); @@ -215,6 +215,48 @@ describe('AI chart Chinese workflow', () => { }); }); + it('keeps interactive scatter and line charts exposed as accessible groups', () => { + cy.fixture('api/benchmarks.json').then((fixtureRows) => { + const rows = [ + fixtureRow(fixtureRows, 'b200', 20, 8_000), + fixtureRow(fixtureRows, 'b200', 40, 12_000), + fixtureRow(fixtureRows, 'mi355x', 20, 7_000), + fixtureRow(fixtureRows, 'mi355x', 40, 10_000), + ]; + const specs = [ + benchmarkSpec({ chartType: 'scatter', title: '交互式散点图' }), + benchmarkSpec({ chartType: 'line', title: '交互式折线图' }), + ]; + + cy.intercept('GET', '**/api/v1/benchmarks?*', rows).as('interactiveBenchmarks'); + cy.intercept('POST', 'https://api.openai.com/v1/chat/completions', (request) => { + const systemPrompt = request.body.messages?.[0]?.content ?? ''; + request.reply({ + choices: [ + { + message: { + content: systemPrompt.includes('chart generation assistant') + ? JSON.stringify(specs) + : '已生成两张交互式图表。', + }, + }, + ], + }); + }); + + cy.visit('/zh/ai-chart'); + cy.get('input[placeholder="OpenAI API Key"]').type('test-api-key', { log: false }); + cy.get('textarea[placeholder="描述想查看的图表……"]').type('生成散点图和折线图'); + cy.contains('button', '生成图表').click(); + cy.wait('@interactiveBenchmarks'); + + cy.get('[role="group"][aria-label="AI 生成的散点图"]').should('be.visible'); + cy.get('[role="group"][aria-label="AI 生成的折线图"]').should('be.visible'); + cy.get('[role="img"][aria-label="AI 生成的散点图"]').should('not.exist'); + cy.get('[role="img"][aria-label="AI 生成的折线图"]').should('not.exist'); + }); + }); + it('shows a localized empty state for one unmatched chart in a multi-chart result', () => { cy.fixture('api/benchmarks.json').then((fixtureRows) => { const rows = [fixtureRow(fixtureRows, 'b200', 40, 12_000)]; @@ -257,7 +299,7 @@ describe('AI chart Chinese workflow', () => { cy.wait('@multiChartBenchmarks'); cy.contains('H100 吞吐量') .closest('[data-slot="card"]') - .should('contain.text', '没有数据符合这项图表配置。'); + .should('contain.text', '当前图表配置没有匹配的数据。'); }); }); @@ -275,6 +317,10 @@ describe('AI chart Chinese workflow', () => { cy.contains('图表请求失败。请检查 API 密钥和服务商设置后重试。').should('be.visible'); cy.contains('provider-internal-error').should('not.exist'); cy.get('[data-testid="ai-chart-error"]').should('not.contain.text', 'sk-sensitive-example-key'); + cy.contains('button', '返回修改').click(); + cy.get('[data-testid="ai-chart-error"]').should('not.exist'); + cy.get('input[placeholder="OpenAI API Key"]').should('have.value', 'sk-sensitive-example-key'); + cy.get('textarea').should('have.value', '对比吞吐量'); }); for (const width of [375, 390]) { @@ -282,8 +328,11 @@ describe('AI chart Chinese workflow', () => { cy.viewport(width, 844); cy.visit('/zh/ai-chart'); cy.get('input[placeholder="OpenAI API Key"]').should('be.visible'); - cy.get('textarea[placeholder="描述您想查看的图表……"]').should('be.visible'); - cy.contains('示例提示').should('be.visible'); + cy.get('textarea[placeholder="描述想查看的图表……"]').should('be.visible'); + cy.contains('提示词示例').should('be.visible'); + cy.contains(`${Cypress.platform === 'darwin' ? '⌘' : 'Ctrl'}+Enter 生成图表`).should( + 'be.visible', + ); cy.document().then((doc) => { expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); diff --git a/packages/app/cypress/e2e/collectivex.cy.ts b/packages/app/cypress/e2e/collectivex.cy.ts index 0db9eed05..df2c552d1 100644 --- a/packages/app/cypress/e2e/collectivex.cy.ts +++ b/packages/app/cypress/e2e/collectivex.cy.ts @@ -431,6 +431,7 @@ describe('CollectiveX neutral run view', () => { cy.get('[data-testid="collectivex-runs"]') .should('contain.text', '运行记录') .and('contain.text', '终态数据点'); + cy.get('[data-testid="collectivex-display"]').should('contain.text', '终态用例'); cy.get('[data-testid="collectivex-main-chart"]') .should('contain.text', '往返(实测)') .and('contain.text', '解码') @@ -443,6 +444,63 @@ describe('CollectiveX neutral run view', () => { .and('contain.text', '延迟 p50 / p90 / p95 / p99'); }); + it('localizes every real GitHub workflow conclusion and derives pending only from null', () => { + const cases = [ + ['success', '成功'], + ['failure', '失败'], + ['cancelled', '已取消'], + ['neutral', '中立'], + ['skipped', '已跳过'], + ['stale', '已过期'], + ['timed_out', '超时'], + ['startup_failure', '启动失败'], + ['action_required', '需要处理'], + [null, '待处理'], + ] as const; + const runs = cases.map(([conclusion], index) => + buildDataset({ + shards: [makeRawShard()], + meta: { + run_id: String(170 + index), + generated_at: `2026-08-${String(20 - index).padStart(2, '0')}T12:20:00Z`, + conclusion, + }, + }), + ); + + installRuns(runs); + cy.intercept('GET', '/api/v1/collectivex/runs/*', (request) => { + const runIdFromUrl = request.url.split('/').at(-1)?.split('?')[0]; + request.reply({ body: runs.find((run) => run.run.run_id === runIdFromUrl) ?? runs[0] }); + }).as('conclusionRun'); + cy.visit('/zh/collectivex'); + cy.wait('@runs'); + cy.wait('@conclusionRun'); + + cases.forEach(([conclusion, expected], index) => { + cy.get(`[data-testid="collectivex-run-row-${170 + index}"]`) + .should('contain.text', expected) + .and('not.contain.text', conclusion ?? 'pending'); + }); + }); + + it('shows a cancelled selected run as cancelled instead of pending', () => { + const cancelled = buildDataset({ + shards: [makeRawShard()], + meta: { run_id: '179', generated_at: '2026-08-29T12:20:00Z', conclusion: 'cancelled' }, + }); + installRuns([cancelled]); + installRun(cancelled, 'cancelledRun'); + cy.visit('/zh/collectivex'); + cy.wait('@runs'); + cy.wait('@cancelledRun'); + + cy.get('[data-testid="collectivex-run-conclusion"]') + .should('contain.text', '已取消') + .and('not.contain.text', '待处理') + .and('not.contain.text', 'cancelled'); + }); + for (const width of [375, 390]) { it(`keeps the Chinese explorer and runs table reachable at ${width}px`, () => { cy.viewport(width, 844); @@ -603,16 +661,36 @@ describe('CollectiveX availability states', () => { }); it('shows a safe localized error on the Chinese route', () => { - cy.intercept('GET', '/api/v1/collectivex/runs?*', { - statusCode: 503, - body: { error: 'collectivex-internal-storage-detail' }, + let failRequests = true; + cy.intercept('GET', '/api/v1/collectivex/runs?*', (request) => { + request.reply( + failRequests + ? { statusCode: 503, body: { error: 'collectivex-internal-storage-detail' } } + : { + body: { + version: 1, + runs: [buildRunSummary(dataset)], + discovery_complete: true, + }, + }, + ); }).as('zhDown'); + installRun(); cy.visit('/zh/collectivex'); cy.wait('@zhDown'); + cy.wait('@zhDown'); cy.get('[data-testid="collectivex-error"]') .should('contain.text', 'CollectiveX 运行暂不可用') .and('contain.text', 'CollectiveX 数据集加载失败。') .and('not.contain.text', 'collectivex-internal-storage-detail'); + cy.contains('button', '重试') + .then(() => { + failRequests = false; + }) + .click(); + cy.wait('@zhDown'); + cy.wait('@run'); + cy.get('[data-testid="collectivex-display"]').should('be.visible'); }); it('renders the loading state while the run resolves', () => { @@ -744,9 +822,14 @@ describe('CollectiveX kv-transfer card', () => { cy.get('[data-testid="collectivex-kv-xaxis-toggle"]').contains('button', '帕累托前沿').click(); cy.get('[data-testid="collectivex-kv-frontier-chart"]') - .should('contain.text', 'p50 聚合 pull 带宽(GB/s,对数)') + .should('contain.text', '聚合 pull 带宽(p50,GB/s,对数)') .and('contain.text', '每个在途请求的突发 p95 延迟(ms,对数)') .and('contain.text', '越靠右下越优'); + cy.get('[data-testid="collectivex-kv-table"]') + .closest('[data-slot="card"]') + .should('contain.text', 'paged 测试基于随机 block table') + .and('contain.text', '页大小') + .and('contain.text', '批大小'); }); it('renders no kv card and no KV suite badge for an EP-only run', () => { diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index f103d7ad0..2445ca8c9 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -197,6 +197,7 @@ describe('Historical Trends — Chinese route', () => { it('localizes the metric title, chart instructions, and point tooltip', () => { cy.viewport(1440, 900); + cy.contains('目标交互性(tok/s/user)').should('be.visible'); cy.get('[data-testid="historical-trend-figure"] h2').should('contain.text', '随时间变化'); cy.get('[data-testid="historical-trend-figure"]').should('contain.text', 'Shift+滚轮横向缩放'); cy.get('[data-testid="trend-chart-svg"] circle').first().click({ force: true }); @@ -210,19 +211,33 @@ describe('Historical Trends — Chinese route', () => { cy.intercept('GET', '**/api/v1/benchmarks?*', []).as('emptyBenchmarks'); cy.reload(); cy.wait('@emptyBenchmarks'); - cy.contains('所选模型和序列无可用的交互性图表数据。').should('be.visible'); + cy.contains('所选模型和序列暂无交互性图表数据。').should('be.visible'); cy.get('[data-testid="historical-trends-display"] .animate-pulse').should('not.exist'); }); - it('shows a safe Chinese error when benchmark loading fails', () => { - cy.intercept('GET', '**/api/v1/benchmarks?*', { - statusCode: 500, - body: { error: 'historical-database-internal-detail' }, - }).as('failedBenchmarks'); - cy.reload(); - cy.wait('@failedBenchmarks'); - cy.contains('历史基准测试数据加载失败。').should('be.visible'); - cy.contains('historical-database-internal-detail').should('not.exist'); + it('shows a safe Chinese primary error and recovers through the reload control', () => { + cy.fixture('api/benchmarks.json').then((benchmarkRows) => { + let failRequests = true; + cy.intercept('GET', '**/api/v1/benchmarks?*', (request) => { + request.reply( + failRequests + ? { statusCode: 500, body: { error: 'historical-database-internal-detail' } } + : { body: benchmarkRows }, + ); + }).as('failedBenchmarks'); + cy.reload(); + cy.wait('@failedBenchmarks'); + cy.wait('@failedBenchmarks'); + cy.contains('历史基准测试数据加载失败。').should('be.visible'); + cy.contains('historical-database-internal-detail').should('not.exist'); + cy.contains('button', '重新加载页面') + .then(() => { + failRequests = false; + }) + .click(); + cy.wait('@failedBenchmarks'); + cy.get('[data-testid="historical-trend-figure"]').should('be.visible'); + }); }); it('shows a distinct secondary-history error and recovers through the tracked retry', () => { diff --git a/packages/app/cypress/e2e/zh-pages.cy.ts b/packages/app/cypress/e2e/zh-pages.cy.ts index a32446ddf..febf0b887 100644 --- a/packages/app/cypress/e2e/zh-pages.cy.ts +++ b/packages/app/cypress/e2e/zh-pages.cy.ts @@ -219,8 +219,19 @@ describe('Chinese (/zh) pages', () => { }); for (const width of [375, 390]) { - it(`keeps the table available through horizontal scrolling at ${width}px`, () => { + it(`keeps the chart labels readable and the table scrollable at ${width}px`, () => { cy.viewport(width, 844); + cy.get('[data-testid="submissions-chart-svg"] .x-axis .tick text').then(($ticks) => { + expect($ticks.length, 'mobile date tick count').to.be.at.most(3); + const boxes = [...$ticks] + .map((tick) => tick.getBoundingClientRect()) + .sort((left, right) => left.left - right.left); + for (let index = 1; index < boxes.length; index += 1) { + expect(boxes[index - 1].right, 'adjacent mobile date ticks').to.be.at.most( + boxes[index].left, + ); + } + }); cy.get('[data-testid="submissions-display"] table').should('be.visible'); cy.get('[data-testid="submissions-display"] .overflow-x-auto') .scrollTo('right') @@ -252,14 +263,29 @@ describe('Chinese (/zh) pages', () => { cy.get('[data-testid="feedback-key-input"]').type('invalid-key'); cy.get('[data-testid="feedback-key-submit"]').click(); cy.get('[role="alert"]').should('contain.text', '解密密钥必须是有效的 base64 编码'); + cy.get('[data-testid="feedback-key-input"]') + .clear() + .type('AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA='); + cy.get('[data-testid="feedback-key-submit"]').click(); + cy.contains('button', '清除密钥').should('be.visible'); + }); + + it('names the feedback loading state instead of showing an objectless spinner label', () => { + cy.intercept('GET', '**/api/v1/feedback/list', { + delay: 500, + body: { rows: [] }, + }).as('slowFeedbackList'); + cy.reload(); + cy.contains('正在加载反馈记录……').should('be.visible'); + cy.wait('@slowFeedbackList'); + cy.contains('暂无反馈记录。').should('be.visible'); }); it('shows a safe fetch error and retries through a real button click', () => { - let attempts = 0; + let failRequests = true; cy.intercept('GET', '**/api/v1/feedback/list', (request) => { - attempts += 1; request.reply( - attempts <= 2 + failRequests ? { statusCode: 500, body: { error: 'feedback-database-internal-detail' } } : { body: { rows: [] } }, ); @@ -269,7 +295,11 @@ describe('Chinese (/zh) pages', () => { cy.wait('@retryFeedbackList'); cy.contains('无法加载反馈数据。').should('be.visible'); cy.contains('feedback-database-internal-detail').should('not.exist'); - cy.contains('button', '重试').click(); + cy.contains('button', '重试') + .then(() => { + failRequests = false; + }) + .click(); cy.wait('@retryFeedbackList'); cy.contains('暂无反馈记录。').should('be.visible'); }); diff --git a/packages/app/src/components/ai-chart/AiChartDisplay.tsx b/packages/app/src/components/ai-chart/AiChartDisplay.tsx index 11c5a37e4..e442cbc43 100644 --- a/packages/app/src/components/ai-chart/AiChartDisplay.tsx +++ b/packages/app/src/components/ai-chart/AiChartDisplay.tsx @@ -43,14 +43,14 @@ const STRINGS = { zh: { title: 'AI 图表生成', description: - '用自然语言描述所需图表。API 密钥仅保存在浏览器中,并只发送给所选服务商;InferenceX 无法读取密钥。', - placeholder: '描述您想查看的图表……', - enterToGenerate: '+Enter 生成', + '用自然语言描述所需图表。API 密钥只保存在浏览器中,仅发送给所选服务商;InferenceX 不会读取该密钥。', + placeholder: '描述想查看的图表……', + enterToGenerate: '+Enter 生成图表', generating: '生成中……', generateChart: '生成图表', error: '错误', - tryAgain: '重试', - examplePrompts: '示例提示', + tryAgain: '返回修改', + examplePrompts: '提示词示例', hideKey: '隐藏 API 密钥', showKey: '显示 API 密钥', }, diff --git a/packages/app/src/components/ai-chart/AiChartResult.tsx b/packages/app/src/components/ai-chart/AiChartResult.tsx index ef14580d6..bab657749 100644 --- a/packages/app/src/components/ai-chart/AiChartResult.tsx +++ b/packages/app/src/components/ai-chart/AiChartResult.tsx @@ -58,7 +58,7 @@ const STRINGS = { interactivity: '交互性', instructions: 'Shift+滚轮缩放 · 拖动平移 · 双击重置 · 点击数据点固定提示框', summary: 'AI 总结', - noData: '没有数据符合这项图表配置。', + noData: '当前图表配置没有匹配的数据。', chartAria: { bar: 'AI 生成的条形图', scatter: 'AI 生成的散点图', @@ -226,7 +226,7 @@ function ScatterChart({ ); return ( -
+
+
`Deleted ${deleted} of ${total} shown runs before the operation failed. Try again.`, - conclusion: { success: 'success', failure: 'failure', pending: 'pending' }, atLatency: (percentile: string) => `at ${percentile} latency`, }, zh: { @@ -211,7 +211,7 @@ const STRINGS = { refresh: '刷新', seriesCount: '序列数', measuredCases: '实测用例', - terminalCases: '已终结用例', + terminalCases: '终态用例', retainedAttempts: '保留尝试', allocations: '独立分配', publishedUtc: '发布时间(UTC)', @@ -281,7 +281,7 @@ const STRINGS = { dtypes: '数据类型', 'resource profile': '资源配置', measurement: '测量协议', - 'token ladder': 'token 梯度', + 'token ladder': 'token 档位', 'component availability': '测量分项可用性', correctness: '正确性', }, @@ -289,7 +289,7 @@ const STRINGS = { isolatedNote: '分项之和为派生值,不用于计算吞吐量。', payloadNote: '逻辑载荷速率按所选延迟分位点派生,不代表物理链路带宽。', payloadBandwidthNote: - '载荷带宽为完整逻辑载荷(含 FP8 缩放字节)÷ 延迟(每芯片),是基于逻辑字节的派生速率,不代表物理链路带宽。工具提示中的 β/α 为延迟对字节在整个梯度上的最小二乘拟合(β = 每芯片带宽项,α = 固定开销)。', + '载荷带宽为完整逻辑载荷(含 FP8 缩放字节)÷ 延迟(每芯片),是基于逻辑字节的派生速率,不代表物理链路带宽。工具提示中的 β/α 根据各 token 档位下的字节数与延迟做最小二乘拟合(β = 每芯片带宽项,α = 固定开销)。', provenance: '发布数据溯源', runLabel: '运行', attemptLabel: '尝试', @@ -306,7 +306,6 @@ const STRINGS = { deleteFailed: '删除运行失败,请重试。', deleteShownFailed: (deleted: number, total: number) => `操作失败前已删除 ${total} 个已显示运行中的 ${deleted} 个,请重试。`, - conclusion: { success: '成功', failure: '失败', pending: '待处理' }, atLatency: (percentile: string) => `${percentile} 延迟分位点`, }, } as const; @@ -767,7 +766,7 @@ export default function CollectiveXDisplay() { }`} > {singleDataset - ? `#${singleDataset.run.run_id} · ${t.conclusion[singleDataset.run.conclusion === 'success' || singleDataset.run.conclusion === 'failure' ? singleDataset.run.conclusion : 'pending']}` + ? `#${singleDataset.run.run_id} · ${collectiveXConclusionLabel(singleDataset.run.conclusion, locale)}` : t.shownRunCount(datasets.length)}
diff --git a/packages/app/src/components/collectivex/CollectiveXKvChart.tsx b/packages/app/src/components/collectivex/CollectiveXKvChart.tsx index c0c65d87f..91d3eea3f 100644 --- a/packages/app/src/components/collectivex/CollectiveXKvChart.tsx +++ b/packages/app/src/components/collectivex/CollectiveXKvChart.tsx @@ -53,9 +53,9 @@ const STRINGS = { }, yLabel: (selection: CollectiveXKvChartSelection) => selection.y === 'bandwidth' - ? `p50 聚合 ${selection.op} 带宽(GB/s)` + ? `聚合 ${selection.op} 带宽(p50,GB/s)` : 'burst 完成延迟 p50(ms)', - noData: '所选分页大小和传输方向下暂无实测 KV 数据。', + noData: '所选页大小和传输方向下暂无实测 KV 数据。', instructions: 'Shift+滚轮缩放 · 拖动平移 · 双击重置 · 点击数据点固定提示框', dismiss: '点击其他区域关闭', point: (op: string, page: string, batch: number, isl: string) => diff --git a/packages/app/src/components/collectivex/CollectiveXKvFrontierChart.tsx b/packages/app/src/components/collectivex/CollectiveXKvFrontierChart.tsx index 3857d6bdc..8dc6f71bf 100644 --- a/packages/app/src/components/collectivex/CollectiveXKvFrontierChart.tsx +++ b/packages/app/src/components/collectivex/CollectiveXKvFrontierChart.tsx @@ -47,7 +47,7 @@ const STRINGS = { zh: { noData: '没有与所选页大小和传输方向匹配的 KV 实测数据。', instructions: 'Shift+滚轮缩放 · 拖动平移 · 双击重置 · 点击数据点固定提示框', - xAxis: (op: CollectiveXKvFrontierSelection['op']) => `p50 聚合 ${op} 带宽(GB/s,对数)`, + xAxis: (op: CollectiveXKvFrontierSelection['op']) => `聚合 ${op} 带宽(p50,GB/s,对数)`, yAxis: '每个在途请求的突发 p95 延迟(ms,对数)', dismiss: '点击其他位置关闭', skuFrontier: 'SKU 级帕累托前沿', diff --git a/packages/app/src/components/collectivex/CollectiveXKvSection.tsx b/packages/app/src/components/collectivex/CollectiveXKvSection.tsx index 7255a533a..0fcd19869 100644 --- a/packages/app/src/components/collectivex/CollectiveXKvSection.tsx +++ b/packages/app/src/components/collectivex/CollectiveXKvSection.tsx @@ -73,12 +73,12 @@ const STRINGS = { heading: 'KV 缓存传输', description: '预填充到解码的 KV 缓存交接(2 节点 × 1 GPU,采用 vLLM 为 DeepSeek-V4-Pro 分配的缓存布局)。' + - '分页行按随机块表以逐层描述符列表搬运每个请求;bulk 为单描述符线速上限。' + - 'GB/s 为最大 ISL 处按突发聚合的 pull 带宽;b1/bmax 表示每次突发提交的请求数。', + 'paged 测试基于随机 block table,按请求传输以层为主序的描述符列表;bulk 测试以单描述符测量链路线速上限。' + + 'GB/s 表示最大 ISL 下各 burst 汇总的 pull 带宽;b1/bmax 表示每个 burst 提交的请求数。', batchCaption: '取最大实测 ISL', islCaption: '取批大小 1', frontierCaption: - '每条线连接最大实测 ISL 下的批大小阶梯,越靠右下越优。' + + '每条线连接最大实测 ISL 下的各个批大小;越靠右下越优。' + '串行处理请求的后端会收缩为一个点;悬停可查看各点的帕累托状态。', frontierOption: '帕累托前沿', yControl: '指标', @@ -107,7 +107,7 @@ const STRINGS = { diagnostic: '诊断', pending: '待处理', }, - batch: '批量请求数', + batch: '批大小', caption: (op: string, page: string, suffix: string) => `${op} · 每页 ${page} token · ${suffix}`, }, } as const; diff --git a/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx b/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx index 0fcf1ea4e..7b8112442 100644 --- a/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx +++ b/packages/app/src/components/collectivex/CollectiveXRunsTable.tsx @@ -7,7 +7,7 @@ import { track } from '@/lib/analytics'; import { useLocale } from '@/lib/use-locale'; import { cn } from '@/lib/utils'; -import { collectiveXRunDasharray, collectiveXSkuLabel } from './data'; +import { collectiveXConclusionLabel, collectiveXRunDasharray, collectiveXSkuLabel } from './data'; import type { CollectiveXRunSummary } from './types'; interface CollectiveXRunsTableProps { @@ -32,13 +32,11 @@ const STRINGS = { skus: 'SKUs', published: 'Published (UTC)', actions: 'Actions', - pending: 'pending', showRun: (id: string) => `Show run #${id}`, lineStyle: (id: string) => `Line style for run #${id}`, openRun: (id: string) => `Open GitHub Actions run #${id}`, deleteRun: (id: string) => `Delete run #${id}`, empty: 'No runs match this benchmark version.', - conclusion: { success: 'success', failure: 'failure' }, epSuite: (measured: number, requested: number) => `EP: ${measured}/${requested} measured`, kvSuite: (measured: number, requested: number) => `KV transfer: ${measured}/${requested} measured`, @@ -53,13 +51,11 @@ const STRINGS = { skus: 'SKUs', published: '发布时间(UTC)', actions: '操作', - pending: '待处理', showRun: (id: string) => `显示运行 #${id}`, lineStyle: (id: string) => `运行 #${id} 的线型`, openRun: (id: string) => `打开 GitHub Actions 运行 #${id}`, deleteRun: (id: string) => `删除运行 #${id}`, empty: '该基准测试版本下暂无运行记录。', - conclusion: { success: '成功', failure: '失败' }, epSuite: (measured: number, requested: number) => `EP:已测量 ${measured}/${requested}`, kvSuite: (measured: number, requested: number) => `KV 传输:已测量 ${measured}/${requested}`, }, @@ -128,7 +124,7 @@ export function CollectiveXRunsTable({ const visible = visibleRunIds.has(run.run_id); const loading = loadingRunIds.has(run.run_id); const deleting = deletingRunIds.has(run.run_id); - const conclusion = run.conclusion ?? t.pending; + const conclusion = run.conclusion; const selectedRunIndex = selectedRunIndexById.get(run.run_id); // Summaries stored before the kv suite carry no kv_cases: EP-only. const kvRequested = run.kv_cases?.requested ?? 0; @@ -208,12 +204,10 @@ export function CollectiveXRunsTable({ - {conclusion === 'success' || conclusion === 'failure' - ? t.conclusion[conclusion] - : conclusion} + {collectiveXConclusionLabel(conclusion, locale)} diff --git a/packages/app/src/components/collectivex/data.test.ts b/packages/app/src/components/collectivex/data.test.ts index 5d050c88c..4a9515d77 100644 --- a/packages/app/src/components/collectivex/data.test.ts +++ b/packages/app/src/components/collectivex/data.test.ts @@ -4,6 +4,7 @@ import { chartPoints, collectiveXCaseLabel, collectiveXColorKey, + collectiveXConclusionLabel, collectiveXLegendLabel, collectiveXRunDasharray, collectiveXSeriesForRun, @@ -28,6 +29,33 @@ const dataset = makeCollectiveXDataset(); // series[1]: MoRI EP16 scale-out (xGMI scale-up + RDMA scale-out, two nodes). const [scaleUp, scaleOut] = dataset.series; +describe('collectiveXConclusionLabel', () => { + it.each([ + ['success', '成功'], + ['failure', '失败'], + ['cancelled', '已取消'], + ['skipped', '已跳过'], + ['timed_out', '超时'], + ['startup_failure', '启动失败'], + ['action_required', '需要处理'], + ['neutral', '中立'], + ['stale', '已过期'], + ])('localizes the %s workflow conclusion in Chinese', (conclusion, expected) => { + expect(collectiveXConclusionLabel(conclusion, 'zh')).toBe(expected); + }); + + it('derives pending only from a null workflow conclusion', () => { + expect(collectiveXConclusionLabel(null, 'zh')).toBe('待处理'); + expect(collectiveXConclusionLabel(null, 'en')).toBe('pending'); + }); + + it('preserves known English values and hides unknown values on Chinese pages', () => { + expect(collectiveXConclusionLabel('startup_failure', 'en')).toBe('startup_failure'); + expect(collectiveXConclusionLabel('future_status', 'en')).toBe('future_status'); + expect(collectiveXConclusionLabel('future_status', 'zh')).toBe('未知状态'); + }); +}); + describe('collectiveXTopologyLabel', () => { it('shows only the scale-up transport when there is no scale-out fabric', () => { expect(collectiveXTopologyLabel(scaleUp.system)).toBe( diff --git a/packages/app/src/components/collectivex/data.ts b/packages/app/src/components/collectivex/data.ts index 90f0b93bf..93f95acac 100644 --- a/packages/app/src/components/collectivex/data.ts +++ b/packages/app/src/components/collectivex/data.ts @@ -1,5 +1,7 @@ import { GPU_KEYS } from '@semianalysisai/inferencex-constants'; +import type { Locale } from '@/lib/i18n'; + import type { CollectiveXChartPoint, CollectiveXComponent, @@ -25,6 +27,42 @@ export interface CollectiveXSeriesSelection { const BASE_RUN_DASHARRAYS = ['none', '9 4', '3 3', '10 3 2 3', '2 3', '12 3 2 3'] as const; +const COLLECTIVEX_CONCLUSION_LABELS = { + en: { + action_required: 'action_required', + cancelled: 'cancelled', + failure: 'failure', + neutral: 'neutral', + skipped: 'skipped', + stale: 'stale', + startup_failure: 'startup_failure', + success: 'success', + timed_out: 'timed_out', + }, + zh: { + action_required: '需要处理', + cancelled: '已取消', + failure: '失败', + neutral: '中立', + skipped: '已跳过', + stale: '已过期', + startup_failure: '启动失败', + success: '成功', + timed_out: '超时', + }, +} as const; + +/** Format every conclusion emitted by the GitHub Actions workflow-run API. */ +export function collectiveXConclusionLabel(conclusion: string | null, locale: Locale): string { + if (conclusion === null) return locale === 'zh' ? '待处理' : 'pending'; + const labels = COLLECTIVEX_CONCLUSION_LABELS[locale]; + return conclusion in labels + ? labels[conclusion as keyof typeof labels] + : locale === 'zh' + ? '未知状态' + : conclusion; +} + /** * CollectiveX artifacts identify runner pools in the SKU (for example, * `b200-nscale` or `h100-dgxc`). Collapse known hardware identifiers to the diff --git a/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx b/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx index 58fe3f131..04588e8f8 100644 --- a/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx +++ b/packages/app/src/components/feedback-viewer/FeedbackViewer.tsx @@ -102,13 +102,13 @@ const STRINGS = { keyLabel: '解密密钥(base64,32 字节)', keyPlaceholder: 'base64 编码密钥', decrypt: '解密', - forgetKey: '忘记密钥', + forgetKey: '清除密钥', hideKey: '隐藏密钥', showKey: '显示密钥', allDecryptsFailed: '所有行均解密失败——密钥格式正确但与数据不匹配。', fetchError: '无法加载反馈数据。', retry: '重试', - loadingRows: '加载中……', + loadingRows: '正在加载反馈记录……', noRows: '暂无反馈记录。', enterKey: '请在上方输入密钥进行解密。', encryptedRowsLoaded: (n: number) => `已加载 ${n} 条加密记录。`, diff --git a/packages/app/src/components/submissions/SubmissionsChart.tsx b/packages/app/src/components/submissions/SubmissionsChart.tsx index 24012f48b..c63404189 100644 --- a/packages/app/src/components/submissions/SubmissionsChart.tsx +++ b/packages/app/src/components/submissions/SubmissionsChart.tsx @@ -1,7 +1,7 @@ 'use client'; import * as d3 from 'd3'; -import { type ReactNode, useCallback, useMemo, useState } from 'react'; +import { type ReactNode, useCallback, useMemo, useState, useSyncExternalStore } from 'react'; import { track } from '@/lib/analytics'; import { useLocale } from '@/lib/use-locale'; @@ -31,6 +31,27 @@ const TOTAL_COLOR = '#6b7280'; const CHART_MARGIN = { top: 24, right: 24, bottom: 40, left: 60 }; const CHART_ID = 'submissions-chart'; const NIGHTLY_END_DATE = new Date('2025-12-16').getTime(); +const NARROW_VIEWPORT_QUERY = '(max-width: 39.999rem)'; +const NOOP = () => {}; + +function subscribeToNarrowViewport(onStoreChange: () => void): () => void { + if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return NOOP; + const mediaQuery = window.matchMedia(NARROW_VIEWPORT_QUERY); + mediaQuery.addEventListener('change', onStoreChange); + return () => mediaQuery.removeEventListener('change', onStoreChange); +} + +function getNarrowViewportSnapshot(): boolean { + return ( + typeof window !== 'undefined' && + typeof window.matchMedia === 'function' && + window.matchMedia(NARROW_VIEWPORT_QUERY).matches + ); +} + +function useNarrowViewport(): boolean { + return useSyncExternalStore(subscribeToNarrowViewport, getNarrowViewportSnapshot, () => false); +} interface ChartPoint { date: number; @@ -111,6 +132,7 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC const [enabledLines, setEnabledLines] = useState>(new Set(LINE_KEYS)); const [onChangeOnly, setOnChangeOnly] = useState(true); const locale = useLocale(); + const isNarrowViewport = useNarrowViewport(); const legendT = SUBMISSIONS_STRINGS[locale]; const dateFormatter = useMemo( () => @@ -298,7 +320,7 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC xAxis={ locale === 'zh' ? { - tickCount: 6, + tickCount: isNarrowViewport ? 3 : 6, tickFormat: (value) => dateFormatter.format(new Date(Number(value))), } : { tickCount: 6 } diff --git a/packages/app/src/components/tab-nav.tsx b/packages/app/src/components/tab-nav.tsx index 6a74bf9d6..882406d0d 100644 --- a/packages/app/src/components/tab-nav.tsx +++ b/packages/app/src/components/tab-nav.tsx @@ -138,7 +138,10 @@ export function TabNav() { const featureGateUnlocked = useFeatureGate(); const locale = pathname === '/zh' || pathname.startsWith('/zh/') ? 'zh' : 'en'; const current = dashboardRouteForPathname(pathname)?.key ?? 'inference'; - const selectedTab = getDashboardRoute(current).navGroup === 'footer-only' ? '' : current; + const currentRoute = getDashboardRoute(current); + const selectedTab = currentRoute.navGroup === 'footer-only' ? '' : current; + const lockedCurrentGatedTab = + !featureGateUnlocked && currentRoute.navGroup === 'feature-gated' ? currentRoute : null; const tabLabel = (route: DashboardRoute) => locale === 'zh' ? TAB_LABELS_ZH[route.key] : TAB_LABELS_EN[route.key]; @@ -182,6 +185,14 @@ export function TabNav() { {tabLabel(route)} ))} + {lockedCurrentGatedTab && ( + + {tabLabel(lockedCurrentGatedTab)} + + )} {featureGateUnlocked && ( <> diff --git a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx index 99a420043..415a188a4 100644 --- a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx +++ b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx @@ -69,7 +69,7 @@ const STRINGS = { zh: { heading: '历史趋势', description: '将交互性固定在指定水平后,展示各项性能指标随时间的变化;数据经插值计算。', - targetLabel: '目标交互性 (tok/s/user)', + targetLabel: '目标交互性(tok/s/user)', targetTooltip: '设置插值计算采用的交互性水平。移动滑块可比较不同交互性水平下的芯片性能。', captionTitle: (yTitle: string, target: number) => `${yTitle} 随时间变化(交互性 ${target} tok/s/user)`, @@ -78,7 +78,7 @@ const STRINGS = { logScale: '对数缩放', highContrast: '高对比度', resetFilter: '重置筛选', - noData: '所选模型和序列无可用的交互性图表数据。', + noData: '所选模型和序列暂无交互性图表数据。', loadError: '历史基准测试数据加载失败。', retry: '重新加载页面', trendLoadError: '历史趋势数据加载失败。', From fe1378629ae47dc990d420e37831de4973845abc Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Fri, 28 Aug 2026 21:37:26 -0700 Subject: [PATCH 5/6] =?UTF-8?q?test(zh):=20trim=20redundant=20coverage=20a?= =?UTF-8?q?nd=20fix=20inert=20assertions=20/=20=E7=B2=BE=E7=AE=80=E5=86=97?= =?UTF-8?q?=E4=BD=99=E6=B5=8B=E8=AF=95=E5=B9=B6=E4=BF=AE=E5=A4=8D=E5=A4=B1?= =?UTF-8?q?=E6=95=88=E6=96=AD=E8=A8=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - drop query-state unit tests duplicated end-to-end by the retry e2e specs - drop 390px viewport arms (no breakpoint between 375 and 390) - drop zh success-label component dup and intercept prompt asserts covered by vitest - fix vacuous locale-leak needle in prompt-templates.test.ts - replace tautological blank-label sweep with concise-override value table 中文:删除与端到端重试用例重复的 query 单测、390px 视口分支及重复断言;修复永真断言。 --- .../cypress/component/feedback-modal.cy.tsx | 39 ++--------- packages/app/cypress/e2e/ai-chart.cy.ts | 28 ++++---- packages/app/cypress/e2e/collectivex.cy.ts | 24 +++---- .../app/cypress/e2e/historical-trends.cy.ts | 18 +++-- .../app/cypress/e2e/reliability-chart.cy.ts | 18 +++-- packages/app/cypress/e2e/zh-pages.cy.ts | 58 ++++++++-------- .../ai-chart/prompt-templates.test.ts | 2 +- .../useInterpolatedTrendData.query.test.tsx | 65 ------------------ .../ReliabilityContext.query.test.tsx | 68 ------------------- .../app/src/hooks/api/ai-chart-data.test.ts | 9 +-- 10 files changed, 79 insertions(+), 250 deletions(-) delete mode 100644 packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx delete mode 100644 packages/app/src/components/reliability/ReliabilityContext.query.test.tsx diff --git a/packages/app/cypress/component/feedback-modal.cy.tsx b/packages/app/cypress/component/feedback-modal.cy.tsx index 3623e5d6b..d6e3af6f8 100644 --- a/packages/app/cypress/component/feedback-modal.cy.tsx +++ b/packages/app/cypress/component/feedback-modal.cy.tsx @@ -93,29 +93,6 @@ describe('FeedbackForm', () => { cy.contains('感谢您的反馈!').should('be.visible'); }); - it('keeps engine-provided Chinese accessible labels valid after success', () => { - cy.intercept('POST', '/api/v1/feedback', { statusCode: 204 }).as('postAccessibleZh'); - cy.mount( - , - ); - - cy.get('#feedback-modal-title').should('have.text', '帮助我们改进 InferenceX'); - cy.get('#feedback-modal-description').should( - 'have.text', - '欢迎告诉我们哪些体验不错,以及哪些地方需要改进。', - ); - cy.get('[data-testid="feedback-doing-well"]').type('图表很清晰'); - cy.get('[data-testid="feedback-modal-submit"]').click(); - cy.wait('@postAccessibleZh'); - cy.get('#feedback-modal-title').should('have.text', '感谢您的反馈!'); - cy.get('#feedback-modal-description').should('have.text', '我们会认真阅读每一条反馈。'); - }); - it('localizes rate-limit and server errors on Chinese routes', () => { cy.intercept('POST', '/api/v1/feedback', { statusCode: 429 }).as('rateLimited'); cy.mount(); @@ -135,15 +112,13 @@ describe('FeedbackForm', () => { cy.contains('Failed to fetch').should('not.exist'); }); - for (const width of [375, 390]) { - it(`keeps the Chinese form inside a ${width}px viewport`, () => { - cy.viewport(width, 667); - cy.mount(); + it('keeps the Chinese form inside a 375px viewport', () => { + cy.viewport(375, 667); + cy.mount(); - cy.get('[data-testid="feedback-modal-submit"]').should('be.visible'); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + cy.get('[data-testid="feedback-modal-submit"]').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); diff --git a/packages/app/cypress/e2e/ai-chart.cy.ts b/packages/app/cypress/e2e/ai-chart.cy.ts index 7769d34d9..d33d26cfc 100644 --- a/packages/app/cypress/e2e/ai-chart.cy.ts +++ b/packages/app/cypress/e2e/ai-chart.cy.ts @@ -188,12 +188,10 @@ describe('AI chart Chinese workflow', () => { cy.intercept('POST', 'https://api.openai.com/v1/chat/completions', (request) => { const systemPrompt = request.body.messages?.[0]?.content ?? ''; if (systemPrompt.includes('chart generation assistant')) { - expect(systemPrompt).to.contain('natural Simplified Chinese'); request.reply({ choices: [{ message: { content: JSON.stringify(spec) } }] }); return; } - expect(systemPrompt).to.contain('用自然、准确的简体中文回答'); request.reply({ choices: [{ message: { content: 'B200 在该配置下吞吐量更高。' } }] }); }).as('zhOpenAi'); @@ -323,19 +321,17 @@ describe('AI chart Chinese workflow', () => { cy.get('textarea').should('have.value', '对比吞吐量'); }); - for (const width of [375, 390]) { - it(`keeps Chinese provider controls and examples within ${width}px`, () => { - cy.viewport(width, 844); - cy.visit('/zh/ai-chart'); - cy.get('input[placeholder="OpenAI API Key"]').should('be.visible'); - cy.get('textarea[placeholder="描述想查看的图表……"]').should('be.visible'); - cy.contains('提示词示例').should('be.visible'); - cy.contains(`${Cypress.platform === 'darwin' ? '⌘' : 'Ctrl'}+Enter 生成图表`).should( - 'be.visible', - ); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + it('keeps Chinese provider controls and examples within 375px', () => { + cy.viewport(375, 844); + cy.visit('/zh/ai-chart'); + cy.get('input[placeholder="OpenAI API Key"]').should('be.visible'); + cy.get('textarea[placeholder="描述想查看的图表……"]').should('be.visible'); + cy.contains('提示词示例').should('be.visible'); + cy.contains(`${Cypress.platform === 'darwin' ? '⌘' : 'Ctrl'}+Enter 生成图表`).should( + 'be.visible', + ); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); diff --git a/packages/app/cypress/e2e/collectivex.cy.ts b/packages/app/cypress/e2e/collectivex.cy.ts index df2c552d1..4e70a5f2d 100644 --- a/packages/app/cypress/e2e/collectivex.cy.ts +++ b/packages/app/cypress/e2e/collectivex.cy.ts @@ -501,20 +501,18 @@ describe('CollectiveX neutral run view', () => { .and('not.contain.text', 'cancelled'); }); - for (const width of [375, 390]) { - it(`keeps the Chinese explorer and runs table reachable at ${width}px`, () => { - cy.viewport(width, 844); - cy.visit('/zh/collectivex'); - cy.wait('@runs'); - cy.wait('@run'); - - cy.get('[data-testid="collectivex-main-chart"] svg').should('exist'); - cy.get('[data-testid="collectivex-runs-table"]').scrollTo('right').should('be.visible'); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + it('keeps the Chinese explorer and runs table reachable at 375px', () => { + cy.viewport(375, 844); + cy.visit('/zh/collectivex'); + cy.wait('@runs'); + cy.wait('@run'); + + cy.get('[data-testid="collectivex-main-chart"] svg').should('exist'); + cy.get('[data-testid="collectivex-runs-table"]').scrollTo('right').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); describe('CollectiveX run deletion', () => { diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index 2445ca8c9..86cb68bcb 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -265,15 +265,13 @@ describe('Historical Trends — Chinese route', () => { }); }); - for (const width of [375, 390]) { - it(`keeps the target controls and chart reachable at ${width}px`, () => { - cy.viewport(width, 844); - cy.get('[data-testid="historical-trends-display"] input[type="range"]').should('be.visible'); - cy.get('[data-testid="historical-trends-display"] input[type="number"]').should('be.visible'); - cy.get('[data-testid="historical-trend-figure"] svg').should('exist'); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + it('keeps the target controls and chart reachable at 375px', () => { + cy.viewport(375, 844); + cy.get('[data-testid="historical-trends-display"] input[type="range"]').should('be.visible'); + cy.get('[data-testid="historical-trends-display"] input[type="number"]').should('be.visible'); + cy.get('[data-testid="historical-trend-figure"] svg').should('exist'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); diff --git a/packages/app/cypress/e2e/reliability-chart.cy.ts b/packages/app/cypress/e2e/reliability-chart.cy.ts index c2f3d288b..f31723b81 100644 --- a/packages/app/cypress/e2e/reliability-chart.cy.ts +++ b/packages/app/cypress/e2e/reliability-chart.cy.ts @@ -200,15 +200,13 @@ describe('Reliability Chart — Chinese route and settled states', () => { cy.get('#reliability-chart svg rect.bar').should('have.length.greaterThan', 0); }); - for (const width of [375, 390]) { - it(`keeps controls reachable without body overflow at ${width}px`, () => { - cy.viewport(width, 844); - cy.visit('/zh/reliability'); - cy.get('[data-testid="reliability-date-range"]').should('be.visible').click(); - cy.contains('[role="option"]', '全部时间').should('be.visible'); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + it('keeps controls reachable without body overflow at 375px', () => { + cy.viewport(375, 844); + cy.visit('/zh/reliability'); + cy.get('[data-testid="reliability-date-range"]').should('be.visible').click(); + cy.contains('[role="option"]', '全部时间').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); diff --git a/packages/app/cypress/e2e/zh-pages.cy.ts b/packages/app/cypress/e2e/zh-pages.cy.ts index febf0b887..7a4d18d1b 100644 --- a/packages/app/cypress/e2e/zh-pages.cy.ts +++ b/packages/app/cypress/e2e/zh-pages.cy.ts @@ -218,29 +218,27 @@ describe('Chinese (/zh) pages', () => { cy.contains('暂无提交记录。').should('be.visible'); }); - for (const width of [375, 390]) { - it(`keeps the chart labels readable and the table scrollable at ${width}px`, () => { - cy.viewport(width, 844); - cy.get('[data-testid="submissions-chart-svg"] .x-axis .tick text').then(($ticks) => { - expect($ticks.length, 'mobile date tick count').to.be.at.most(3); - const boxes = [...$ticks] - .map((tick) => tick.getBoundingClientRect()) - .sort((left, right) => left.left - right.left); - for (let index = 1; index < boxes.length; index += 1) { - expect(boxes[index - 1].right, 'adjacent mobile date ticks').to.be.at.most( - boxes[index].left, - ); - } - }); - cy.get('[data-testid="submissions-display"] table').should('be.visible'); - cy.get('[data-testid="submissions-display"] .overflow-x-auto') - .scrollTo('right') - .should('be.visible'); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + it('keeps the chart labels readable and the table scrollable at 375px', () => { + cy.viewport(375, 844); + cy.get('[data-testid="submissions-chart-svg"] .x-axis .tick text').then(($ticks) => { + expect($ticks.length, 'mobile date tick count').to.be.at.most(3); + const boxes = [...$ticks] + .map((tick) => tick.getBoundingClientRect()) + .sort((left, right) => left.left - right.left); + for (let index = 1; index < boxes.length; index += 1) { + expect(boxes[index - 1].right, 'adjacent mobile date ticks').to.be.at.most( + boxes[index].left, + ); + } + }); + cy.get('[data-testid="submissions-display"] table').should('be.visible'); + cy.get('[data-testid="submissions-display"] .overflow-x-auto') + .scrollTo('right') + .should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); describe('zh feedback viewer workflow', () => { @@ -304,16 +302,14 @@ describe('Chinese (/zh) pages', () => { cy.contains('暂无反馈记录。').should('be.visible'); }); - for (const width of [375, 390]) { - it(`keeps the key controls and content within ${width}px`, () => { - cy.viewport(width, 844); - cy.get('[data-testid="feedback-key-input"]').should('be.visible'); - cy.get('[data-testid="feedback-key-submit"]').should('be.visible'); - cy.document().then((doc) => { - expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); - }); + it('keeps the key controls and content within 375px', () => { + cy.viewport(375, 844); + cy.get('[data-testid="feedback-key-input"]').should('be.visible'); + cy.get('[data-testid="feedback-key-submit"]').should('be.visible'); + cy.document().then((doc) => { + expect(doc.documentElement.scrollWidth).to.be.lte(doc.documentElement.clientWidth); }); - } + }); }); it('uses the route locale in the global feedback modal and dismisses it through the UI', () => { diff --git a/packages/app/src/components/ai-chart/prompt-templates.test.ts b/packages/app/src/components/ai-chart/prompt-templates.test.ts index 70bbac40c..277810312 100644 --- a/packages/app/src/components/ai-chart/prompt-templates.test.ts +++ b/packages/app/src/components/ai-chart/prompt-templates.test.ts @@ -18,7 +18,7 @@ describe('AI chart locale instructions', () => { ); expect(prompt).toContain('"title": "short chart title"'); - expect(prompt).not.toContain('Write title, description, and yAxisLabel in Simplified Chinese'); + expect(prompt).not.toContain('Simplified Chinese'); expect(summaryPrompt) .toBe(`You are an expert performance analyst. Based on the following benchmark data, provide a concise 2-3 sentence summary highlighting the key takeaway. diff --git a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx b/packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx deleted file mode 100644 index 0696b4922..000000000 --- a/packages/app/src/components/inference/hooks/useInterpolatedTrendData.query.test.tsx +++ /dev/null @@ -1,65 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { act, createElement } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -import { Model, Sequence } from '@/lib/data-mappings'; - -const { mockFetchBenchmarkHistory } = vi.hoisted(() => ({ - mockFetchBenchmarkHistory: vi.fn(), -})); -vi.mock('@/lib/api', () => ({ fetchBenchmarkHistory: mockFetchBenchmarkHistory })); - -import { useInterpolatedTrendData } from './useInterpolatedTrendData'; - -let observed: - | { - error?: Error | null; - refetch?: () => Promise; - } - | undefined; - -function Probe() { - observed = useInterpolatedTrendData({ - selectedModel: Model.DeepSeek_R1, - selectedSequence: Sequence.OneK_OneK, - selectedPrecisions: ['fp8'], - selectedYAxisMetric: 'y_tpPerGpu', - targetInteractivity: 40, - availableDates: [], - enabled: true, - }); - return null; -} - -describe('useInterpolatedTrendData query state', () => { - let root: Root | undefined; - - afterEach(() => { - if (root) act(() => root?.unmount()); - root = undefined; - observed = undefined; - vi.clearAllMocks(); - }); - - it('propagates a history failure and exposes a refetch that can recover', async () => { - mockFetchBenchmarkHistory.mockRejectedValueOnce(new Error('secondary history failed')); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - root = createRoot(document.createElement('div')); - - await act(() => { - root?.render(createElement(QueryClientProvider, { client }, createElement(Probe))); - }); - - await vi.waitFor(() => expect(observed?.error?.message).toBe('secondary history failed')); - expect(observed?.refetch).toBeTypeOf('function'); - - mockFetchBenchmarkHistory.mockResolvedValueOnce([]); - await act(async () => { - await observed?.refetch?.(); - }); - - await vi.waitFor(() => expect(observed?.error).toBeNull()); - }); -}); diff --git a/packages/app/src/components/reliability/ReliabilityContext.query.test.tsx b/packages/app/src/components/reliability/ReliabilityContext.query.test.tsx deleted file mode 100644 index 7b22cb56e..000000000 --- a/packages/app/src/components/reliability/ReliabilityContext.query.test.tsx +++ /dev/null @@ -1,68 +0,0 @@ -// @vitest-environment jsdom -import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; -import { act, createElement } from 'react'; -import { createRoot, type Root } from 'react-dom/client'; -import { afterEach, describe, expect, it, vi } from 'vitest'; - -const { mockFetchReliability } = vi.hoisted(() => ({ - mockFetchReliability: vi.fn(), -})); - -vi.mock('@/lib/api', () => ({ fetchReliability: mockFetchReliability })); -vi.mock('@/hooks/useUrlState', () => ({ - useUrlState: () => ({ - getUrlParam: () => undefined, - setUrlParams: vi.fn(), - }), -})); - -import { ReliabilityProvider, useReliabilityContext } from './ReliabilityContext'; - -let observed: - | { - error?: string | null; - refetch?: () => Promise; - } - | undefined; - -function Probe() { - observed = useReliabilityContext(); - return null; -} - -describe('ReliabilityProvider query state', () => { - let root: Root | undefined; - - afterEach(() => { - if (root) act(() => root?.unmount()); - root = undefined; - observed = undefined; - vi.clearAllMocks(); - }); - - it('exposes a refetch that clears a reliability error after recovery', async () => { - mockFetchReliability.mockRejectedValueOnce(new Error('reliability failed')); - const client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); - root = createRoot(document.createElement('div')); - - await act(() => { - root?.render( - createElement( - QueryClientProvider, - { client }, - createElement(ReliabilityProvider, null, createElement(Probe)), - ), - ); - }); - - await vi.waitFor(() => expect(observed?.error).toBe('reliability failed')); - expect(observed?.refetch).toBeTypeOf('function'); - - mockFetchReliability.mockResolvedValueOnce([]); - await act(async () => { - await observed?.refetch?.(); - }); - - await vi.waitFor(() => expect(observed?.error).toBeNull()); - }); -}); diff --git a/packages/app/src/hooks/api/ai-chart-data.test.ts b/packages/app/src/hooks/api/ai-chart-data.test.ts index fbac750d0..bfc9702d1 100644 --- a/packages/app/src/hooks/api/ai-chart-data.test.ts +++ b/packages/app/src/hooks/api/ai-chart-data.test.ts @@ -1,7 +1,6 @@ import { describe, expect, it } from 'vitest'; import chartDefinitions from '@/components/inference/metric-registry'; -import { Y_AXIS_METRICS } from '@/lib/chart-utils'; import { buildAiLineData, @@ -21,9 +20,11 @@ describe('getAiRadarMetricLabel', () => { ); }); - it.each(Y_AXIS_METRICS)('never returns a blank axis label for allowed metric %s', (metric) => { - expect(getAiRadarMetricLabel(metric, chartDefinitions[0], 'en')).not.toBe(''); - expect(getAiRadarMetricLabel(metric, chartDefinitions[0], 'zh')).not.toBe(''); + it.each([ + ['en' as const, 'Throughput/Chip'], + ['zh' as const, '每芯片吞吐量'], + ])('prefers the concise radar label over the registry label for %s', (locale, expected) => { + expect(getAiRadarMetricLabel('y_tpPerGpu', chartDefinitions[0], locale)).toBe(expected); }); }); From 5b9342b1c00ad54bf99a26cf1cb34d8401ff0b8a Mon Sep 17 00:00:00 2001 From: Wenyao Gao Date: Mon, 31 Aug 2026 12:27:32 -0700 Subject: [PATCH 6/6] fix(zh): complete data-tool copy and trim redundant tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the remaining native Chinese presentation copy, preserve technical identifiers, align Submissions timestamps to UTC, and consolidate duplicated end-to-end coverage. 中文:补全数据工具中剩余的自然中文展示文案,保留技术标识符,统一 Submissions 时间戳为 UTC,并精简重复的端到端测试。 --- .../cypress/component/feedback-modal.cy.tsx | 59 +++++++++---------- .../component/reliability-bar-chart.cy.tsx | 3 +- packages/app/cypress/e2e/ai-chart.cy.ts | 2 + packages/app/cypress/e2e/collectivex.cy.ts | 52 ++++------------ .../app/cypress/e2e/historical-trends.cy.ts | 28 +++++++++ packages/app/cypress/e2e/nudge-system.cy.ts | 34 ----------- .../app/cypress/e2e/reliability-chart.cy.ts | 20 +++++-- packages/app/cypress/e2e/zh-pages.cy.ts | 10 ++++ .../ai-chart/prompt-templates.test.ts | 17 ++---- .../app/src/components/ai-chart/types.test.ts | 15 +++++ packages/app/src/components/ai-chart/types.ts | 21 ++++++- .../collectivex/CollectiveXChart.tsx | 4 +- .../collectivex/CollectiveXDisplay.tsx | 8 +-- .../CollectiveXSupportMatrices.tsx | 10 +++- .../src/components/collectivex/data.test.ts | 15 +++++ .../app/src/components/collectivex/data.ts | 31 ++++++++-- .../submissions/SubmissionsChart.test.ts | 6 +- .../submissions/SubmissionsChart.tsx | 44 +++++++------- .../trends/HistoricalTrendsDisplay.tsx | 12 +++- 19 files changed, 223 insertions(+), 168 deletions(-) diff --git a/packages/app/cypress/component/feedback-modal.cy.tsx b/packages/app/cypress/component/feedback-modal.cy.tsx index d6e3af6f8..96ea67c99 100644 --- a/packages/app/cypress/component/feedback-modal.cy.tsx +++ b/packages/app/cypress/component/feedback-modal.cy.tsx @@ -28,29 +28,9 @@ describe('FeedbackForm', () => { it('POSTs to /api/v1/feedback, dispatches FEEDBACK_SUBMITTED_EVENT, then dismisses', () => { cy.intercept('POST', '/api/v1/feedback', { statusCode: 204 }).as('post'); const onDismiss = cy.stub().as('onDismiss'); - cy.mount(); - - let submittedFired = false; - cy.window().then((win) => { - win.addEventListener(FEEDBACK_SUBMITTED_EVENT, () => { - submittedFired = true; - }); - }); - - cy.get('[data-testid="feedback-doing-well"]').type('useful chart!'); - cy.get('[data-testid="feedback-modal-submit"]').click(); - cy.wait('@post'); - cy.contains('Thanks for your feedback!').should('be.visible'); - cy.then(() => expect(submittedFired).to.be.true); - // Success-hold is 2s; onDismiss fires after. - cy.get('@onDismiss').should('have.been.calledOnce'); - }); - - it('keeps engine-provided English accessible labels valid after success', () => { - cy.intercept('POST', '/api/v1/feedback', { statusCode: 204 }).as('postAccessibleEn'); cy.mount( , @@ -61,11 +41,22 @@ describe('FeedbackForm', () => { 'have.text', "We'd love to hear what's working and what isn't.", ); - cy.get('[data-testid="feedback-doing-well"]').type('Clear charts'); + + let submittedFired = false; + cy.window().then((win) => { + win.addEventListener(FEEDBACK_SUBMITTED_EVENT, () => { + submittedFired = true; + }); + }); + + cy.get('[data-testid="feedback-doing-well"]').type('useful chart!'); cy.get('[data-testid="feedback-modal-submit"]').click(); - cy.wait('@postAccessibleEn'); + cy.wait('@post'); cy.get('#feedback-modal-title').should('have.text', 'Thanks for your feedback!'); cy.get('#feedback-modal-description').should('have.text', 'We read every response.'); + cy.then(() => expect(submittedFired).to.be.true); + // Success-hold is 2s; onDismiss fires after. + cy.get('@onDismiss').should('have.been.calledOnce'); }); it('surfaces a 429 as a user-readable error', () => { @@ -93,14 +84,20 @@ describe('FeedbackForm', () => { cy.contains('感谢您的反馈!').should('be.visible'); }); - it('localizes rate-limit and server errors on Chinese routes', () => { - cy.intercept('POST', '/api/v1/feedback', { statusCode: 429 }).as('rateLimited'); - cy.mount(); - cy.get('[data-testid="feedback-doing-well"]').type('反馈'); - cy.get('[data-testid="feedback-modal-submit"]').click(); - cy.wait('@rateLimited'); - cy.contains('[role="alert"]', '提交次数过多,请稍后再试。').should('be.visible'); - }); + for (const [statusCode, expected] of [ + [400, '提交未通过校验,请检查填写内容后重试。'], + [429, '提交次数过多,请稍后再试。'], + [500, '反馈保存失败,请重试。'], + ] as const) { + it(`localizes a ${statusCode} response on Chinese routes`, () => { + cy.intercept('POST', '/api/v1/feedback', { statusCode }).as('failedSubmission'); + cy.mount(); + cy.get('[data-testid="feedback-doing-well"]').type('反馈'); + cy.get('[data-testid="feedback-modal-submit"]').click(); + cy.wait('@failedSubmission'); + cy.contains('[role="alert"]', expected).should('be.visible'); + }); + } it('does not expose a raw network error on Chinese routes', () => { cy.intercept('POST', '/api/v1/feedback', { forceNetworkError: true }).as('networkFailure'); diff --git a/packages/app/cypress/component/reliability-bar-chart.cy.tsx b/packages/app/cypress/component/reliability-bar-chart.cy.tsx index b20c9c9a1..3d3cb7993 100644 --- a/packages/app/cypress/component/reliability-bar-chart.cy.tsx +++ b/packages/app/cypress/component/reliability-bar-chart.cy.tsx @@ -5,7 +5,7 @@ import { Model } from '@/lib/data-mappings'; import { registerAnalyticsClient } from '@/lib/analytics'; describe('ReliabilityBarChartD3', () => { - it('tracks retry before requesting reliability data again', () => { + it('tracks retry and requests reliability data again', () => { const capture = cy.stub(); const refetch = cy.stub().resolves(); registerAnalyticsClient({ capture }); @@ -21,7 +21,6 @@ describe('ReliabilityBarChartD3', () => { cy.then(() => { expect(capture).to.have.been.calledWith('reliability_retry_clicked'); expect(refetch.callCount).to.eq(1); - expect(capture).to.have.been.calledBefore(refetch); }); }); diff --git a/packages/app/cypress/e2e/ai-chart.cy.ts b/packages/app/cypress/e2e/ai-chart.cy.ts index d33d26cfc..77cfc1ee0 100644 --- a/packages/app/cypress/e2e/ai-chart.cy.ts +++ b/packages/app/cypress/e2e/ai-chart.cy.ts @@ -188,10 +188,12 @@ describe('AI chart Chinese workflow', () => { cy.intercept('POST', 'https://api.openai.com/v1/chat/completions', (request) => { const systemPrompt = request.body.messages?.[0]?.content ?? ''; if (systemPrompt.includes('chart generation assistant')) { + expect(systemPrompt).to.contain('natural Simplified Chinese'); request.reply({ choices: [{ message: { content: JSON.stringify(spec) } }] }); return; } + expect(systemPrompt).to.contain('用自然、准确的简体中文回答'); request.reply({ choices: [{ message: { content: 'B200 在该配置下吞吐量更高。' } }] }); }).as('zhOpenAi'); diff --git a/packages/app/cypress/e2e/collectivex.cy.ts b/packages/app/cypress/e2e/collectivex.cy.ts index 2e65893fe..e4d0b0677 100644 --- a/packages/app/cypress/e2e/collectivex.cy.ts +++ b/packages/app/cypress/e2e/collectivex.cy.ts @@ -474,11 +474,18 @@ describe('CollectiveX neutral run view', () => { .and('contain.text', 'KV'); cy.get('[data-testid="collectivex-support-matrices"]') .should('contain.text', '已知 Kernel 支持情况') + .and('contain.text', '下表展示完整的 SKU × 集合通信库支持情况') .and('contain.text', '吞吐量 Kernel') .and('contain.text', '低延迟 Kernel') .and('contain.text', '可用') .and('contain.text', '已知不可用') .and('contain.text', '不适用'); + cy.get( + '[data-testid="collectivex-known-cell"][data-mode="normal"][data-sku="mi355x"][data-library="mori"] [data-testid="collectivex-known-ep"][data-degree="16"]', + ) + .invoke('attr', 'aria-label') + .should('match', /(注 \d+)/u) + .and('not.include', '(note '); }); it('localizes the complete chart and run-table click path on the Chinese route', () => { @@ -496,6 +503,7 @@ describe('CollectiveX neutral run view', () => { cy.get('[data-testid="collectivex-display"]').should('contain.text', '终态用例'); cy.get('[data-testid="collectivex-main-chart"]') .should('contain.text', '往返(实测)') + .and('contain.text', '常规') .and('contain.text', '解码') .and('contain.text', '延迟(µs)'); @@ -503,50 +511,12 @@ describe('CollectiveX neutral run view', () => { cy.get('[data-chart-tooltip]:visible') .should('contain.text', '点击其他区域关闭') .and('contain.text', '往返') + .and('contain.text', '常规') + .and('contain.text', '解码') .and('contain.text', '延迟 p50 / p90 / p95 / p99'); }); - it('localizes every real GitHub workflow conclusion and derives pending only from null', () => { - const cases = [ - ['success', '成功'], - ['failure', '失败'], - ['cancelled', '已取消'], - ['neutral', '中立'], - ['skipped', '已跳过'], - ['stale', '已过期'], - ['timed_out', '超时'], - ['startup_failure', '启动失败'], - ['action_required', '需要处理'], - [null, '待处理'], - ] as const; - const runs = cases.map(([conclusion], index) => - buildDataset({ - shards: [makeRawShard()], - meta: { - run_id: String(170 + index), - generated_at: `2026-08-${String(20 - index).padStart(2, '0')}T12:20:00Z`, - conclusion, - }, - }), - ); - - installRuns(runs); - cy.intercept('GET', '/api/v1/collectivex/runs/*', (request) => { - const runIdFromUrl = request.url.split('/').at(-1)?.split('?')[0]; - request.reply({ body: runs.find((run) => run.run.run_id === runIdFromUrl) ?? runs[0] }); - }).as('conclusionRun'); - cy.visit('/zh/collectivex'); - cy.wait('@runs'); - cy.wait('@conclusionRun'); - - cases.forEach(([conclusion, expected], index) => { - cy.get(`[data-testid="collectivex-run-row-${170 + index}"]`) - .should('contain.text', expected) - .and('not.contain.text', conclusion ?? 'pending'); - }); - }); - - it('shows a cancelled selected run as cancelled instead of pending', () => { + it('keeps a selected cancelled run localized instead of showing it as pending', () => { const cancelled = buildDataset({ shards: [makeRawShard()], meta: { run_id: '179', generated_at: '2026-08-29T12:20:00Z', conclusion: 'cancelled' }, diff --git a/packages/app/cypress/e2e/historical-trends.cy.ts b/packages/app/cypress/e2e/historical-trends.cy.ts index 86cb68bcb..f9450dc62 100644 --- a/packages/app/cypress/e2e/historical-trends.cy.ts +++ b/packages/app/cypress/e2e/historical-trends.cy.ts @@ -11,6 +11,9 @@ const visitHistoricalWithSetup = () => { cy.get('[data-testid="historical-trends-display"]').should('be.visible'); }; +const asAgenticRowsOn = (rows: Record[], date: string) => + rows.map((row) => ({ ...row, benchmark_type: 'agentic_traces', date })); + describe('Historical Trends Tab', () => { beforeEach(() => { visitHistoricalWithSetup(); @@ -207,6 +210,31 @@ describe('Historical Trends — Chinese route', () => { .should('match', /\d{4}年/u); }); + it('localizes the Agentic sequence and run date in the chart caption', () => { + const runDate = '2025-03-01'; + cy.fixture('api/availability.json').then((rows) => { + cy.intercept('GET', '**/api/v1/availability', { + body: asAgenticRowsOn(rows, runDate), + }).as('agenticAvailability'); + }); + cy.fixture('api/benchmarks.json').then((rows) => { + cy.intercept('GET', '**/api/v1/benchmarks?*', { + body: asAgenticRowsOn(rows, runDate), + }).as('agenticBenchmarks'); + }); + + cy.visit( + `/zh/historical?g_model=DeepSeek-R1-0528&i_seq=agentic-traces&i_prec=fp4&g_rundate=${runDate}`, + ); + cy.wait('@agenticAvailability'); + cy.wait('@agenticBenchmarks'); + cy.get('[data-testid="historical-trend-figure"] figcaption p') + .first() + .should('contain.text', '智能体') + .and('contain.text', '2025年3月1日') + .and('not.contain.text', runDate); + }); + it('shows a settled Chinese empty state instead of leaving the skeleton mounted', () => { cy.intercept('GET', '**/api/v1/benchmarks?*', []).as('emptyBenchmarks'); cy.reload(); diff --git a/packages/app/cypress/e2e/nudge-system.cy.ts b/packages/app/cypress/e2e/nudge-system.cy.ts index a14fc3242..530d8a775 100644 --- a/packages/app/cypress/e2e/nudge-system.cy.ts +++ b/packages/app/cypress/e2e/nudge-system.cy.ts @@ -28,40 +28,6 @@ function clearAllNudgeStorage(win: Cypress.AUTWindow) { } } -// The support file seeds `inferencex-feedback-modal-snoozed` on every page -// load so the modal's backdrop stays out of other specs' way. This spec's -// accessibility test is the one place that wants the real modal, so it clears -// the seed (spec `onBeforeLoad` runs after the support hook). Keep the -// feedback keys OUT of `clearAllNudgeStorage`: the immediate feedback modal -// claims the shared overlay slot and would suppress the delayed -// reproducibility / filter-hint toasts every other test asserts on. -function clearNudgeStorageAndUnsnoozeFeedbackModal(win: Cypress.AUTWindow) { - clearAllNudgeStorage(win); - for (const key of ['inferencex-feedback-modal-snoozed', 'inferencex-feedback-modal-submitted']) { - win.localStorage.removeItem(key); - win.sessionStorage.removeItem(key); - } -} - -describe('Dashboard feedback modal accessibility', () => { - it('has a valid English accessible name and description', () => { - cy.visit('/inference', { - onBeforeLoad: clearNudgeStorageAndUnsnoozeFeedbackModal, - }); - - cy.get('[data-testid="feedback-modal"]') - .should('be.visible') - .and('have.attr', 'role', 'dialog') - .and('have.attr', 'aria-labelledby', 'feedback-modal-title') - .and('have.attr', 'aria-describedby', 'feedback-modal-description'); - cy.get('#feedback-modal-title').should('have.text', 'Help us improve InferenceX'); - cy.get('#feedback-modal-description').should( - 'have.text', - "We'd love to hear what's working and what isn't.", - ); - }); -}); - // `cypress.config.ts` runs with `testIsolation: false` — the browser context // (incl. localStorage / sessionStorage) survives across tests in this spec. // Defensively clear before each test so a missed `onBeforeLoad` in any test diff --git a/packages/app/cypress/e2e/reliability-chart.cy.ts b/packages/app/cypress/e2e/reliability-chart.cy.ts index f31723b81..286a2e77f 100644 --- a/packages/app/cypress/e2e/reliability-chart.cy.ts +++ b/packages/app/cypress/e2e/reliability-chart.cy.ts @@ -166,6 +166,12 @@ describe('Reliability Chart — Chinese route and settled states', () => { cy.get('#reliability-chart svg').should('contain.text', '成功率(%)'); cy.get('#reliability-chart svg .overlay-label').first().should('contain.text', '次运行'); cy.get('#reliability-chart').closest('figure').should('contain.text', 'Shift+滚轮横向缩放'); + cy.get('#reliability-chart svg rect.bar').first().click({ force: true }); + cy.get('[data-chart-tooltip]:visible') + .should('contain.text', '点击其他区域关闭') + .and('contain.text', '成功率:') + .and('contain.text', '成功次数:') + .and('contain.text', '总运行次数:'); }); it('shows a Chinese empty state only after an empty response settles', () => { @@ -177,12 +183,14 @@ describe('Reliability Chart — Chinese route and settled states', () => { }); it('shows a safe Chinese error and recovers through the tracked retry control', () => { - let attempts = 0; + // Keep every automatic query attempt failing until the retry button is + // clicked. Counting attempts is race-prone because a remount can consume + // the first healthy response before the error UI is asserted. + let failRequests = true; cy.fixture('api/reliability.json').then((fixture) => { cy.intercept('GET', '**/api/v1/reliability', (req) => { - attempts += 1; req.reply( - attempts <= 2 + failRequests ? { statusCode: 500, body: { error: 'database-internal-detail' } } : { statusCode: 200, body: fixture }, ); @@ -195,7 +203,11 @@ describe('Reliability Chart — Chinese route and settled states', () => { .should('be.visible') .and('contain.text', '可靠性数据加载失败。'); cy.contains('database-internal-detail').should('not.exist'); - cy.contains('[data-testid="reliability-error"] button', '重试').click(); + cy.contains('[data-testid="reliability-error"] button', '重试') + .then(() => { + failRequests = false; + }) + .click(); cy.wait('@retryReliability'); cy.get('#reliability-chart svg rect.bar').should('have.length.greaterThan', 0); }); diff --git a/packages/app/cypress/e2e/zh-pages.cy.ts b/packages/app/cypress/e2e/zh-pages.cy.ts index 7a4d18d1b..5c6f8ac86 100644 --- a/packages/app/cypress/e2e/zh-pages.cy.ts +++ b/packages/app/cypress/e2e/zh-pages.cy.ts @@ -166,6 +166,16 @@ describe('Chinese (/zh) pages', () => { .should('have.attr', 'aria-label', '图表模式') .and('contain.text', '按周') .and('contain.text', '累计'); + cy.get('[role="group"][aria-label="基准测试提交活动图表"]') + .should('contain.text', 'Shift+滚轮横向缩放') + .find('[data-testid="submissions-chart-svg"]') + .should('contain.text', '数据点数量'); + cy.get('[data-testid="submissions-chart-svg"] .proximity-overlay').click('center', { + force: true, + }); + cy.get('[data-chart-tooltip]:visible') + .should('contain.text', '点击其他区域关闭') + .and('contain.text', '合计'); cy.contains('th button', '投机解码').should('be.visible'); cy.contains('th button', '数据点').click().parent('th').should('have.attr', 'aria-sort'); cy.get('button[aria-label="展开配置详情"]').first().click(); diff --git a/packages/app/src/components/ai-chart/prompt-templates.test.ts b/packages/app/src/components/ai-chart/prompt-templates.test.ts index 277810312..b5b5e69d7 100644 --- a/packages/app/src/components/ai-chart/prompt-templates.test.ts +++ b/packages/app/src/components/ai-chart/prompt-templates.test.ts @@ -19,18 +19,11 @@ describe('AI chart locale instructions', () => { expect(prompt).toContain('"title": "short chart title"'); expect(prompt).not.toContain('Simplified Chinese'); - expect(summaryPrompt) - .toBe(`You are an expert performance analyst. Based on the following benchmark data, provide a concise 2-3 sentence summary highlighting the key takeaway. - -Chart: B200 throughput | Metric: Throughput/Chip | Model: DeepSeek-R1-0528, Seq: 8k/1k - -Data: -B200: 12345.67 tok/s - -Rules: -- Be technical and precise. Mention specific values and percentage differences. -- Focus on the most interesting comparison or finding. -- No markdown formatting, just plain text.`); + expect(summaryPrompt).toContain( + 'Chart: B200 throughput | Metric: Throughput/Chip | Model: DeepSeek-R1-0528, Seq: 8k/1k', + ); + expect(summaryPrompt).toContain('B200: 12345.67 tok/s'); + expect(summaryPrompt).toContain('Be technical and precise'); }); it('asks for Simplified Chinese presentation fields on Chinese routes', () => { diff --git a/packages/app/src/components/ai-chart/types.test.ts b/packages/app/src/components/ai-chart/types.test.ts index 280ca0c77..b28bd6e98 100644 --- a/packages/app/src/components/ai-chart/types.test.ts +++ b/packages/app/src/components/ai-chart/types.test.ts @@ -18,4 +18,19 @@ describe('validateSpec locale fallbacks', () => { it('uses a Chinese fallback title for incomplete Chinese chart specs', () => { expect(validateSpec({}, 'zh').title).toBe('AI 生成的图表'); }); + + it('uses Chinese display labels when an incomplete spec omits its Y-axis label', () => { + expect(validateSpec({ yAxisMetric: 'y_tpPerGpu' }, 'zh').yAxisLabel).toBe( + '每芯片 token 吞吐量(tok/s/chip)', + ); + expect(validateSpec({ yAxisMetric: 'eval_score' }, 'zh').yAxisLabel).toBe('评估得分'); + expect(validateSpec({ yAxisMetric: 'reliability_rate' }, 'zh').yAxisLabel).toBe('运行成功率'); + expect(validateSpec({ yAxisMetric: 'y_tpPerGpu' }).yAxisLabel).toBe('y_tpPerGpu'); + }); + + it('keeps a provider-supplied Chinese Y-axis label unchanged', () => { + expect( + validateSpec({ yAxisMetric: 'y_tpPerGpu', yAxisLabel: '每芯片吞吐量' }, 'zh').yAxisLabel, + ).toBe('每芯片吞吐量'); + }); }); diff --git a/packages/app/src/components/ai-chart/types.ts b/packages/app/src/components/ai-chart/types.ts index 334b54ab8..29f255f9b 100644 --- a/packages/app/src/components/ai-chart/types.ts +++ b/packages/app/src/components/ai-chart/types.ts @@ -1,6 +1,7 @@ import { Model, Sequence, Precision } from '@/lib/data-mappings'; import { Y_AXIS_METRICS } from '@/lib/chart-utils'; import type { Locale } from '@/lib/i18n'; +import { isMetricKey, METRIC_REGISTRY } from '@/components/inference/metric-registry'; import { HW_REGISTRY, FRAMEWORK_KEYS } from '@semianalysisai/inferencex-constants'; export type AiProvider = 'openai' | 'anthropic' | 'xai' | 'google'; @@ -80,6 +81,21 @@ const VALID_FRAMEWORKS = new Set(FRAMEWORK_KEYS); const VALID_Y_METRICS = new Set([...Y_AXIS_METRICS, 'eval_score', 'reliability_rate']); const VALID_SORT_ORDERS = new Set(['desc', 'asc', 'registry']); +const AI_Y_AXIS_LABELS_ZH = { + eval_score: '评估得分', + reliability_rate: '运行成功率', +} as const; + +function fallbackYAxisLabel(metric: string, locale: Locale): string { + if (locale !== 'zh') return metric; + if (metric in AI_Y_AXIS_LABELS_ZH) { + return AI_Y_AXIS_LABELS_ZH[metric as keyof typeof AI_Y_AXIS_LABELS_ZH]; + } + + const metricKey = metric === 'y' ? 'tpPerGpu' : metric.startsWith('y_') ? metric.slice(2) : ''; + return isMetricKey(metricKey) ? METRIC_REGISTRY[metricKey].labelZh : metric; +} + /** Validate and clamp an LLM-generated spec to known values. Throws on unrecoverable input. */ export function validateSpec(raw: Record, locale: Locale = 'en'): AiChartSpec { const chartType = VALID_CHART_TYPES.has(raw.chartType as string) @@ -141,7 +157,10 @@ export function validateSpec(raw: Record, locale: Locale = 'en' frameworks, disagg, yAxisMetric, - yAxisLabel: typeof raw.yAxisLabel === 'string' ? raw.yAxisLabel.slice(0, 100) : yAxisMetric, + yAxisLabel: + typeof raw.yAxisLabel === 'string' + ? raw.yAxisLabel.slice(0, 100) + : fallbackYAxisLabel(yAxisMetric, locale), targetInteractivity, sortOrder, radarMetrics: radarMetrics.length > 0 ? radarMetrics : undefined, diff --git a/packages/app/src/components/collectivex/CollectiveXChart.tsx b/packages/app/src/components/collectivex/CollectiveXChart.tsx index 7338fb800..8a4c700e9 100644 --- a/packages/app/src/components/collectivex/CollectiveXChart.tsx +++ b/packages/app/src/components/collectivex/CollectiveXChart.tsx @@ -147,8 +147,8 @@ export function CollectiveXChart({ const locale = useLocale(); const t = STRINGS[locale]; const points = useMemo( - () => chartPoints(series, operation, percentile, yAxis), - [series, operation, percentile, yAxis], + () => chartPoints(series, operation, percentile, yAxis, locale), + [series, operation, percentile, yAxis, locale], ); const seriesById = useMemo(() => new Map(series.map((item) => [item.series_id, item])), [series]); // Per-series α/β fit for the current operation (p50). β is the per-GPU diff --git a/packages/app/src/components/collectivex/CollectiveXDisplay.tsx b/packages/app/src/components/collectivex/CollectiveXDisplay.tsx index d6f40f2a1..d13276dc6 100644 --- a/packages/app/src/components/collectivex/CollectiveXDisplay.tsx +++ b/packages/app/src/components/collectivex/CollectiveXDisplay.tsx @@ -226,7 +226,7 @@ const STRINGS = { allSuites: '全部', noSuiteRuns: '没有包含所选测试套件的运行。', selectRuns: '请从表格中选择至少一个运行以显示数据。', - selectedRunsFailed: '部分所选运行记录加载失败。', + selectedRunsFailed: '至少一个所选运行加载失败。', runControl: '运行', loadRuns: '加载运行', loadingRuns: '正在加载运行……', @@ -597,11 +597,11 @@ export default function CollectiveXDisplay() { () => phaseSeries.map((item) => ({ name: item.series_id, - label: collectiveXLegendLabel(item), + label: collectiveXLegendLabel(item, locale), color: colors[collectiveXColorKey(item)] ?? 'var(--muted-foreground)', lineDasharray: collectiveXRunDasharray(item.run_index), isActive: activeSeriesIds.has(item.series_id), - title: `#${item.run_id} · EP${item.system.ep_size} · ${collectiveXTopologyLabel(item.system)}`, + title: `#${item.run_id} · EP${item.system.ep_size} · ${collectiveXTopologyLabel(item.system, locale)}`, onClick: () => { setActiveSeriesIds((previous) => { const next = new Set(previous); @@ -612,7 +612,7 @@ export default function CollectiveXDisplay() { track('collectivex_series_toggled', { series: item.series_id }); }, })), - [activeSeriesIds, colors, phaseSeries], + [activeSeriesIds, colors, locale, phaseSeries], ); const handleRefresh = useCallback(() => { track('collectivex_data_refreshed'); diff --git a/packages/app/src/components/collectivex/CollectiveXSupportMatrices.tsx b/packages/app/src/components/collectivex/CollectiveXSupportMatrices.tsx index 6353d44a1..e669e197b 100644 --- a/packages/app/src/components/collectivex/CollectiveXSupportMatrices.tsx +++ b/packages/app/src/components/collectivex/CollectiveXSupportMatrices.tsx @@ -28,11 +28,12 @@ const STRINGS = { broken: 'Known not to work', na: 'Not applicable', notes: 'Notes', + note: (number: number) => ` (note ${number})`, }, zh: { title: '已知 Kernel 支持情况', description: - '与上方勾选的运行无关的完整 SKU × 集合通信库支持图景:绿色组合在集群上可用,红色组合已知不可用(编号注释说明原因),灰色组合对该配对不存在。', + '下表展示完整的 SKU × 集合通信库支持情况,不受上方已选运行影响:绿色表示该组合可在集群上运行,红色表示已知不可用(原因见编号注释),灰色表示该配对不存在。', modes: { normal: '吞吐量 Kernel', 'low-latency': '低延迟 Kernel', @@ -42,6 +43,7 @@ const STRINGS = { broken: '已知不可用', na: '不适用', notes: '注释', + note: (number: number) => `(注 ${number})`, }, } as const; @@ -56,14 +58,16 @@ function EpChip({ degree, noteNumber, noteText, + formatNote, }: { ep: CollectiveXKnownEp; degree: 8 | 16; noteNumber: number | null; noteText: string | null; + formatNote: (number: number) => string; }) { const glyph = ep.status === 'works' ? '✓' : ep.status === 'broken' ? '✕' : '—'; - const label = `EP${degree} ${glyph}${noteNumber === null ? '' : ` (note ${noteNumber})`}`; + const label = `EP${degree} ${glyph}${noteNumber === null ? '' : formatNote(noteNumber)}`; return ( )} diff --git a/packages/app/src/components/collectivex/data.test.ts b/packages/app/src/components/collectivex/data.test.ts index e5b982454..693d62f9d 100644 --- a/packages/app/src/components/collectivex/data.test.ts +++ b/packages/app/src/components/collectivex/data.test.ts @@ -65,6 +65,9 @@ describe('collectiveXTopologyLabel', () => { expect(collectiveXTopologyLabel(scaleUp.system)).toBe( '1x8 · domain 8 · nvlink · h200-nvlink-island', ); + expect(collectiveXTopologyLabel(scaleUp.system, 'zh')).toBe( + '1x8 · 域内芯片数 8 · nvlink · h200-nvlink-island', + ); }); it('joins scale-up and scale-out transports when a scale-out fabric is present', () => { @@ -109,6 +112,15 @@ describe('collectiveXSeriesLabel', () => { expect(collectiveXLegendLabel(runSeries)).toBe( 'H200 · deepep-v2 · EP8 · normal · decode · bf16', ); + expect(collectiveXLegendLabel(runSeries, 'zh')).toBe( + 'H200 · deepep-v2 · EP8 · 常规 · 解码 · bf16', + ); + expect( + collectiveXLegendLabel( + makeCollectiveXSeries({ mode: 'low-latency', phase: 'prefill' }), + 'zh', + ), + ).toBe('H200 · deepep-v2 · EP8 · 低延迟 · 预填充 · bf16'); }); }); @@ -327,6 +339,9 @@ describe('chartPoints', () => { expect(point.x).toBeGreaterThan(0); expect(point.y).toBeGreaterThan(0); } + expect(chartPoints([scaleUp], 'dispatch', 'p50', 'latency', 'zh')[0].seriesLabel).toBe( + 'H200 · deepep-v2 · EP8 · 常规 · 解码 · bf16', + ); }); it('drops points whose metric is unavailable', () => { diff --git a/packages/app/src/components/collectivex/data.ts b/packages/app/src/components/collectivex/data.ts index 34187b397..d9d7883e9 100644 --- a/packages/app/src/components/collectivex/data.ts +++ b/packages/app/src/components/collectivex/data.ts @@ -52,6 +52,16 @@ const COLLECTIVEX_CONCLUSION_LABELS = { }, } as const; +const COLLECTIVEX_MODE_LABELS_ZH: Record = { + normal: '常规', + 'low-latency': '低延迟', +}; + +const COLLECTIVEX_PHASE_LABELS_ZH: Record = { + decode: '解码', + prefill: '预填充', +}; + /** Format every conclusion emitted by the GitHub Actions workflow-run API. */ export function collectiveXConclusionLabel(conclusion: string | null, locale: Locale): string { if (conclusion === null) return locale === 'zh' ? '待处理' : 'pending'; @@ -108,20 +118,28 @@ export function collectiveXTopologyLabel( | 'scale_out_transport' | 'topology_class' >, + locale: Locale = 'en', ): string { const transports = system.scale_out_transport ? `${system.scale_up_transport}+${system.scale_out_transport}` : system.scale_up_transport; - return `${system.nodes}x${system.gpus_per_node} · domain ${system.scale_up_domain} · ${transports} · ${system.topology_class}`; + const domain = + locale === 'zh' ? `域内芯片数 ${system.scale_up_domain}` : `domain ${system.scale_up_domain}`; + return `${system.nodes}x${system.gpus_per_node} · ${domain} · ${transports} · ${system.topology_class}`; } -export function collectiveXLegendLabel(series: CollectiveXSeries): string { - return `${collectiveXSkuLabel(series.system.sku)} · ${series.backend} · EP${series.system.ep_size} · ${series.mode} · ${series.phase} · ${series.precision}`; +export function collectiveXLegendLabel(series: CollectiveXSeries, locale: Locale = 'en'): string { + const mode = locale === 'zh' ? COLLECTIVEX_MODE_LABELS_ZH[series.mode] : series.mode; + const phase = locale === 'zh' ? COLLECTIVEX_PHASE_LABELS_ZH[series.phase] : series.phase; + return `${collectiveXSkuLabel(series.system.sku)} · ${series.backend} · EP${series.system.ep_size} · ${mode} · ${phase} · ${series.precision}`; } -export function collectiveXSeriesLabel(series: CollectiveXSeries | CollectiveXRunSeries): string { +export function collectiveXSeriesLabel( + series: CollectiveXSeries | CollectiveXRunSeries, + locale: Locale = 'en', +): string { const runPrefix = 'run_id' in series ? `#${series.run_id} · ` : ''; - return `${runPrefix}${collectiveXLegendLabel(series)}`; + return `${runPrefix}${collectiveXLegendLabel(series, locale)}`; } export function collectiveXColorKey(series: CollectiveXSeries | CollectiveXRunSeries): string { @@ -240,6 +258,7 @@ export function chartPoints( operation: CollectiveXOperation, percentile: CollectiveXPercentile, yAxis: CollectiveXYAxis, + locale: Locale = 'en', ): CollectiveXChartPoint[] { return series.flatMap((item) => item.points.flatMap((point) => { @@ -249,7 +268,7 @@ export function chartPoints( return [ { seriesId: item.series_id, - seriesLabel: collectiveXSeriesLabel(item), + seriesLabel: collectiveXSeriesLabel(item, locale), colorKey: collectiveXColorKey(item), x, y, diff --git a/packages/app/src/components/submissions/SubmissionsChart.test.ts b/packages/app/src/components/submissions/SubmissionsChart.test.ts index 3913da3fa..d52eab9c4 100644 --- a/packages/app/src/components/submissions/SubmissionsChart.test.ts +++ b/packages/app/src/components/submissions/SubmissionsChart.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest'; -import { formatSubmissionTooltipDate } from './SubmissionsChart'; +import { formatSubmissionDate } from './SubmissionsChart'; const originalTimeZone = process.env.TZ; @@ -9,7 +9,7 @@ afterEach(() => { else process.env.TZ = originalTimeZone; }); -describe('formatSubmissionTooltipDate', () => { +describe('formatSubmissionDate', () => { it.each([ ['en' as const, 'Jan 1, 2025'], ['zh' as const, '2025年1月1日'], @@ -18,6 +18,6 @@ describe('formatSubmissionTooltipDate', () => { const midnightUtc = Date.parse('2025-01-01T00:00:00Z'); expect(new Date(midnightUtc).getDate()).toBe(31); - expect(formatSubmissionTooltipDate(midnightUtc, locale)).toBe(expected); + expect(formatSubmissionDate(midnightUtc, locale)).toBe(expected); }); }); diff --git a/packages/app/src/components/submissions/SubmissionsChart.tsx b/packages/app/src/components/submissions/SubmissionsChart.tsx index c63404189..67529364b 100644 --- a/packages/app/src/components/submissions/SubmissionsChart.tsx +++ b/packages/app/src/components/submissions/SubmissionsChart.tsx @@ -69,19 +69,29 @@ function lineColor(key: string): string { const LINE_KEYS = ['nvidia', 'amd', 'total'] as const; type LineKey = (typeof LINE_KEYS)[number]; -export function formatSubmissionTooltipDate(date: number, locale: Locale): string { - return new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { +const SUBMISSION_DATE_FORMATTERS: Record = { + en: new Intl.DateTimeFormat('en-US', { year: 'numeric', month: 'short', day: 'numeric', timeZone: 'UTC', - }).format(new Date(date)); + }), + zh: new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + timeZone: 'UTC', + }), +}; + +export function formatSubmissionDate(date: number | Date, locale: Locale): string { + return SUBMISSION_DATE_FORMATTERS[locale].format(new Date(date)); } function generateTooltipContent(d: ChartPoint, isPinned: boolean, locale: Locale): string { const t = SUBMISSIONS_STRINGS[locale]; const numberLocale = locale === 'zh' ? 'zh-CN' : 'en-US'; - const dateStr = formatSubmissionTooltipDate(d.date, locale); + const dateStr = formatSubmissionDate(d.date, locale); return `
${isPinned ? `
${t.dismiss}
` : ''} @@ -134,16 +144,6 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC const locale = useLocale(); const isNarrowViewport = useNarrowViewport(); const legendT = SUBMISSIONS_STRINGS[locale]; - const dateFormatter = useMemo( - () => - new Intl.DateTimeFormat(locale === 'zh' ? 'zh-CN' : 'en-US', { - year: 'numeric', - month: 'short', - day: 'numeric', - timeZone: 'UTC', - }), - [locale], - ); const toggleLine = useCallback((name: string) => { setEnabledLines((prev) => { @@ -260,7 +260,7 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC .attr('font-size', '9px') .attr('font-weight', '400') .attr('fill', 'var(--muted-foreground)') - .text(dateFormatter.format(new Date(NIGHTLY_END_DATE))); + .text(formatSubmissionDate(NIGHTLY_END_DATE, locale)); const bbox = (text.node() as SVGTextElement).getBBox(); label .insert('rect', 'text') @@ -293,7 +293,7 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC }, }, ], - [dateFormatter, legendT.markerLine1, legendT.markerLine2, lineData], + [legendT.markerLine1, legendT.markerLine2, lineData, locale], ); if (chartPoints.length === 0) { @@ -317,14 +317,10 @@ export default function SubmissionsChart({ volume, mode, caption }: SubmissionsC instructions={legendT.instructions} xScale={{ type: 'time', domain: [new Date(xDomain[0]), new Date(xDomain[1])], nice: false }} yScale={{ type: 'linear', domain: yDomain, nice: true }} - xAxis={ - locale === 'zh' - ? { - tickCount: isNarrowViewport ? 3 : 6, - tickFormat: (value) => dateFormatter.format(new Date(Number(value))), - } - : { tickCount: 6 } - } + xAxis={{ + tickCount: isNarrowViewport ? 3 : 6, + tickFormat: (value) => formatSubmissionDate(Number(value), locale), + }} yAxis={{ label: locale === 'zh' ? legendT.yAxis : undefined, tickCount: 5, diff --git a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx index 415a188a4..c96188ae6 100644 --- a/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx +++ b/packages/app/src/components/trends/HistoricalTrendsDisplay.tsx @@ -86,6 +86,14 @@ const STRINGS = { }, }; +function historicalRunDate(date: string, locale: 'en' | 'zh'): string { + if (locale !== 'zh') return date; + const [year, month, day] = date.split('-').map(Number); + return Number.isInteger(year) && Number.isInteger(month) && Number.isInteger(day) + ? `${year}年${month}月${day}日` + : date; +} + export default function HistoricalTrendsDisplay() { const locale = useLocale(); const t = STRINGS[locale]; @@ -398,11 +406,11 @@ export default function HistoricalTrendsDisplay() { {selectedPrecisions .map((prec: string) => getPrecisionLabel(prec as Precision)) .join(', ')}{' '} - • {getSequenceLabel(selectedSequence as Sequence)} • {t.source} + • {getSequenceLabel(selectedSequence as Sequence, locale)} • {t.source} {selectedRunDate && ( <> {' '} - • {t.updated} {selectedRunDate} + • {t.updated} {historicalRunDate(selectedRunDate, locale)} )}