From 2f79eb8f48c5c878ea680249adcf3cd2f2d7d15b Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Thu, 2 Apr 2026 23:15:02 +0300 Subject: [PATCH] feat: localize usage page --- apps/ui/html/usage.html | 48 +++++++++---------- apps/ui/js/i18n.js | 100 +++++++++++++++++++++++++++++++++++++++- apps/ui/js/usage.js | 68 ++++++++++++++------------- 3 files changed, 159 insertions(+), 57 deletions(-) diff --git a/apps/ui/html/usage.html b/apps/ui/html/usage.html index 0d35d67..57f20b8 100644 --- a/apps/ui/html/usage.html +++ b/apps/ui/html/usage.html @@ -34,12 +34,12 @@ @@ -58,7 +58,7 @@ ⌘K - @@ -96,18 +96,18 @@ @@ -115,7 +115,7 @@
-
Total invocations
+
Total invocations
28,341
@@ -123,7 +123,7 @@
-
Success rate
+
Success rate
98.6%
@@ -131,7 +131,7 @@
-
Median latency (p50)
+
Median latency (p50)
124ms
@@ -139,7 +139,7 @@
-
p99 latency
+
p99 latency
2.1s
@@ -151,10 +151,10 @@
-
Invocations over time
+
Invocations over time
- Success - Error + Success + Error
@@ -166,20 +166,20 @@
-
By operation
+
By operation
Breakdown for last 7 days
- - - - - - - + + + + + + + diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index e1b54c2..d699bc5 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -193,7 +193,55 @@ var TRANSLATIONS = { // Usage page 'usage.title': 'Usage', - 'usage.subtitle': 'Request volume and performance metrics', + 'usage.subtitle': 'Invocation metrics for all operations in your workspace.', + 'usage.export': 'Export CSV', + 'usage.period.7d': 'Last 7 days', + 'usage.period.30d': 'Last 30 days', + 'usage.period.90d': 'Last 90 days', + 'usage.period.this_month': 'This month', + 'usage.period.label.7d': 'last 7 days', + 'usage.period.label.30d': 'last 30 days', + 'usage.period.label.90d': 'last 90 days', + 'usage.period.label.this_month': 'this month', + 'usage.stats.total': 'Total invocations', + 'usage.stats.success': 'Success rate', + 'usage.stats.p50': 'Median latency (p50)', + 'usage.stats.p99': 'p99 latency', + 'usage.stats.across': 'Across {period}', + 'usage.stats.success_calls': '{count} successful calls', + 'usage.stats.median': 'Median latency for selected period', + 'usage.stats.error_calls': '{count} error calls', + 'usage.chart.title': 'Invocations over time', + 'usage.chart.success': 'Success', + 'usage.chart.error': 'Error', + 'usage.chart.empty.title': 'No usage data yet', + 'usage.chart.empty.sub': 'Invocation metrics will appear here after tests or published tool calls in the selected period.', + 'usage.table.title': 'By operation', + 'usage.table.subtitle': 'Breakdown for {period}', + 'usage.table.th.operation': 'Operation', + 'usage.table.th.protocol': 'Protocol', + 'usage.table.th.calls': 'Calls', + 'usage.table.th.errors': 'Errors', + 'usage.table.th.error_rate': 'Error rate', + 'usage.table.th.latency': 'Latency (p50 / p95 / p99)', + 'usage.table.th.share': 'Traffic share', + 'usage.table.empty.title': 'No operation breakdown yet', + 'usage.table.empty.sub': 'The selected period has no invocation samples to break down by operation.', + 'usage.table.share': '{value}% of workspace traffic', + 'usage.loading.title': 'Loading usage…', + 'usage.loading.sub': 'Aggregating invocation metrics for the selected workspace.', + 'usage.error.title': 'Unable to load usage', + 'usage.error.api': 'Admin API is not available', + 'usage.error.workspace': 'Workspace is not selected', + 'usage.error.load': 'Failed to load usage', + 'usage.export.empty.title': 'No usage data loaded', + 'usage.export.empty.body': 'Load usage data before exporting the CSV snapshot.', + 'usage.export.done.title': 'Usage exported', + 'usage.export.done.body': 'The current usage snapshot was exported as CSV.', + 'usage.chart.ok': '{count} ok', + 'usage.chart.errors': '{count} errors', + 'usage.chart.week': 'Wk {index}', + 'usage.csv.header': 'Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)', // Settings sidebar 'settings.title': 'Settings', @@ -512,7 +560,55 @@ var TRANSLATIONS = { // Usage page 'usage.title': 'Использование', - 'usage.subtitle': 'Объём запросов и метрики производительности', + 'usage.subtitle': 'Метрики вызовов по всем операциям этого воркспейса.', + 'usage.export': 'Экспорт CSV', + 'usage.period.7d': 'Последние 7 дней', + 'usage.period.30d': 'Последние 30 дней', + 'usage.period.90d': 'Последние 90 дней', + 'usage.period.this_month': 'Этот месяц', + 'usage.period.label.7d': 'последние 7 дней', + 'usage.period.label.30d': 'последние 30 дней', + 'usage.period.label.90d': 'последние 90 дней', + 'usage.period.label.this_month': 'этот месяц', + 'usage.stats.total': 'Всего вызовов', + 'usage.stats.success': 'Доля успешных', + 'usage.stats.p50': 'Медианная задержка (p50)', + 'usage.stats.p99': 'Задержка p99', + 'usage.stats.across': 'За период: {period}', + 'usage.stats.success_calls': 'Успешных вызовов: {count}', + 'usage.stats.median': 'Медианная задержка за выбранный период', + 'usage.stats.error_calls': 'Ошибочных вызовов: {count}', + 'usage.chart.title': 'Вызовы во времени', + 'usage.chart.success': 'Успех', + 'usage.chart.error': 'Ошибка', + 'usage.chart.empty.title': 'Данных по использованию пока нет', + 'usage.chart.empty.sub': 'Метрики появятся здесь после тестов или вызовов опубликованных инструментов за выбранный период.', + 'usage.table.title': 'По операциям', + 'usage.table.subtitle': 'Разбивка за период: {period}', + 'usage.table.th.operation': 'Операция', + 'usage.table.th.protocol': 'Протокол', + 'usage.table.th.calls': 'Вызовы', + 'usage.table.th.errors': 'Ошибки', + 'usage.table.th.error_rate': 'Доля ошибок', + 'usage.table.th.latency': 'Задержка (p50 / p95 / p99)', + 'usage.table.th.share': 'Доля трафика', + 'usage.table.empty.title': 'Разбивки по операциям пока нет', + 'usage.table.empty.sub': 'За выбранный период нет вызовов, которые можно разложить по операциям.', + 'usage.table.share': '{value}% трафика воркспейса', + 'usage.loading.title': 'Загрузка использования…', + 'usage.loading.sub': 'Собираем метрики вызовов для выбранного воркспейса.', + 'usage.error.title': 'Не удалось загрузить использование', + 'usage.error.api': 'Admin API недоступен', + 'usage.error.workspace': 'Воркспейс не выбран', + 'usage.error.load': 'Не удалось загрузить метрики использования', + 'usage.export.empty.title': 'Данные по использованию не загружены', + 'usage.export.empty.body': 'Сначала загрузите данные использования, а потом экспортируйте снимок CSV.', + 'usage.export.done.title': 'Использование экспортировано', + 'usage.export.done.body': 'Текущий снимок использования экспортирован в CSV.', + 'usage.chart.ok': 'Успешных: {count}', + 'usage.chart.errors': 'Ошибок: {count}', + 'usage.chart.week': 'Нед. {index}', + 'usage.csv.header': 'Операция,Протокол,Вызовы,Ошибки,Доля ошибок (%),p50 (мс),p95 (мс),p99 (мс)', // Settings sidebar 'settings.title': 'Настройки', diff --git a/apps/ui/js/usage.js b/apps/ui/js/usage.js index 03ba063..22c7a6a 100644 --- a/apps/ui/js/usage.js +++ b/apps/ui/js/usage.js @@ -16,13 +16,25 @@ document.addEventListener('DOMContentLoaded', function () { var subtitle = document.querySelector('.section-card-subtitle'); var statCards = document.querySelectorAll('.stats-grid .stat-card'); + function tKey(key) { + return window.t ? t(key) : key; + } + + function tfKey(key, vars) { + return window.tf ? tf(key, vars) : tKey(key); + } + + function currentLocale() { + return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US'; + } + function currentWorkspaceId() { var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; return workspace ? workspace.id : null; } function formatCount(value) { - return Number(value || 0).toLocaleString(); + return Number(value || 0).toLocaleString(currentLocale()); } function formatMs(value) { @@ -36,13 +48,7 @@ document.addEventListener('DOMContentLoaded', function () { } function periodLabel(period) { - var labels = { - '7d': 'last 7 days', - '30d': 'last 30 days', - '90d': 'last 90 days', - 'this_month': 'this month', - }; - return labels[period] || period; + return tKey('usage.period.label.' + period); } function protocolColor(protocol) { @@ -68,12 +74,12 @@ document.addEventListener('DOMContentLoaded', function () { function chartBucketLabel(timestamp, period, index) { var date = new Date(timestamp); if (period === '7d') { - return date.toLocaleDateString('en-US', { weekday: 'short' }); + return date.toLocaleDateString(currentLocale(), { weekday: 'short' }); } if (period === '90d') { - return date.toLocaleDateString('en-US', { month: 'short' }); + return date.toLocaleDateString(currentLocale(), { month: 'short' }); } - return 'Wk ' + (index + 1); + return tfKey('usage.chart.week', { index: index + 1 }); } function toUtcBucketStart(date, period) { @@ -186,19 +192,19 @@ document.addEventListener('DOMContentLoaded', function () { var cards = [ { value: formatCount(summary ? summary.rollup.calls_total : 0), - text: 'Across ' + periodLabel(state.period), + text: tfKey('usage.stats.across', { period: periodLabel(state.period) }), }, { value: ((summary ? summary.success_rate : 0) || 0).toFixed(1) + '%', - text: formatCount(summary ? summary.rollup.calls_ok : 0) + ' successful calls', + text: tfKey('usage.stats.success_calls', { count: formatCount(summary ? summary.rollup.calls_ok : 0) }), }, { value: formatMs(summary ? summary.rollup.p50_ms : 0), - text: 'Median latency for selected period', + text: tKey('usage.stats.median'), }, { value: formatMs(summary ? summary.rollup.p99_ms : 0), - text: formatCount(summary ? summary.rollup.calls_error : 0) + ' error calls', + text: tfKey('usage.stats.error_calls', { count: formatCount(summary ? summary.rollup.calls_error : 0) }), } ]; @@ -221,8 +227,8 @@ document.addEventListener('DOMContentLoaded', function () { if (!timeline.length) { chartWrap.innerHTML = '
' + - '
No usage data yet
' + - '
Invocation metrics will appear here after tests or published tool calls in the selected period.
' + + '
' + tKey('usage.chart.empty.title') + '
' + + '
' + tKey('usage.chart.empty.sub') + '
' + '
'; return; } @@ -242,9 +248,9 @@ document.addEventListener('DOMContentLoaded', function () { errorHeight = Math.max(errorHeight, 6); } node.querySelector('.chart-bar.success').style.height = okHeight + 'px'; - node.querySelector('.chart-bar.success').dataset.tip = formatCount(point.calls_ok) + ' ok'; + node.querySelector('.chart-bar.success').dataset.tip = tfKey('usage.chart.ok', { count: formatCount(point.calls_ok) }); node.querySelector('.chart-bar.error').style.height = errorHeight + 'px'; - node.querySelector('.chart-bar.error').dataset.tip = formatCount(point.calls_error) + ' errors'; + node.querySelector('.chart-bar.error').dataset.tip = tfKey('usage.chart.errors', { count: formatCount(point.calls_error) }); node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index); chartWrap.appendChild(node); }); @@ -258,8 +264,8 @@ document.addEventListener('DOMContentLoaded', function () { tableBody.innerHTML = ''; return; @@ -301,7 +307,7 @@ document.addEventListener('DOMContentLoaded', function () { node.querySelector('.latency-p95').style.width = Math.max(4, Math.round(((operation.p95_ms - operation.p50_ms) / base) * 64)) + 'px'; node.querySelector('.latency-p99').style.width = Math.max(4, Math.round(((operation.p99_ms - operation.p95_ms) / base) * 64)) + 'px'; - node.querySelector('.col-quota-label').textContent = share.toFixed(1) + '% of workspace traffic'; + node.querySelector('.col-quota-label').textContent = tfKey('usage.table.share', { value: share.toFixed(1) }); node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%'; tableBody.appendChild(node); @@ -310,12 +316,12 @@ document.addEventListener('DOMContentLoaded', function () { function renderUsage() { if (state.loading && !state.usage) { - renderEmpty('Loading usage…', 'Aggregating invocation metrics for the selected workspace.'); + renderEmpty(tKey('usage.loading.title'), tKey('usage.loading.sub')); return; } if (state.loadError) { - renderEmpty('Unable to load usage', state.loadError); + renderEmpty(tKey('usage.error.title'), state.loadError); return; } @@ -323,20 +329,20 @@ document.addEventListener('DOMContentLoaded', function () { renderChart(); renderTable(); if (subtitle) { - subtitle.textContent = 'Breakdown for ' + periodLabel(state.period); + subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) }); } } async function loadUsage() { if (!window.CrankApi) { - state.loadError = 'Admin API is not available'; + state.loadError = tKey('usage.error.api'); renderUsage(); return; } state.workspaceId = currentWorkspaceId(); if (!state.workspaceId) { - state.loadError = 'Workspace is not selected'; + state.loadError = tKey('usage.error.workspace'); renderUsage(); return; } @@ -348,7 +354,7 @@ document.addEventListener('DOMContentLoaded', function () { try { state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period }); } catch (error) { - state.loadError = error.message || 'Failed to load usage'; + state.loadError = error.message || tKey('usage.error.load'); } finally { state.loading = false; renderUsage(); @@ -358,12 +364,12 @@ document.addEventListener('DOMContentLoaded', function () { function exportCsv() { if (!state.usage) { if (window.CrankUi) { - window.CrankUi.info('Load usage data before exporting the CSV snapshot.', 'No usage data loaded'); + window.CrankUi.info(tKey('usage.export.empty.body'), tKey('usage.export.empty.title')); } return; } - var rows = ['Operation,Protocol,Calls,Errors,Error Rate (%),p50 (ms),p95 (ms),p99 (ms)']; + var rows = [tKey('usage.csv.header')]; state.usage.operations.forEach(function (operation) { var errorRate = operation.calls_total === 0 ? 0 @@ -388,7 +394,7 @@ document.addEventListener('DOMContentLoaded', function () { link.click(); URL.revokeObjectURL(url); if (window.CrankUi) { - window.CrankUi.success('The current usage snapshot was exported as CSV.', 'Usage exported'); + window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title')); } }
OperationProtocolCallsErrorsError rateLatency (p50 / p95 / p99)Traffic shareOperationProtocolCallsErrorsError rateLatency (p50 / p95 / p99)Traffic share
' + '
' + - '
No operation breakdown yet
' + - '
The selected period has no invocation samples to break down by operation.
' + + '
' + tKey('usage.table.empty.title') + '
' + + '
' + tKey('usage.table.empty.sub') + '
' + '
' + '