feat: localize usage page

This commit is contained in:
a.tolmachev
2026-04-02 23:15:02 +03:00
parent 5e20b58766
commit 2f79eb8f48
3 changed files with 159 additions and 57 deletions
+24 -24
View File
@@ -34,12 +34,12 @@
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg> <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button> </button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none"> <div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label">Your workspaces</div> <div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
<div id="ws-dropdown-list"></div> <div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer"> <div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'"> <button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg> <svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
Create workspace <span data-i18n="nav.create_workspace">Create workspace</span>
</button> </button>
</div> </div>
</div> </div>
@@ -58,7 +58,7 @@
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg> <svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span> <span>⌘K</span>
</div> </div>
<button class="nav-icon-btn" title="Notifications"> <button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications">
<svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg> <svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg>
</button> </button>
<div class="nav-divider"></div> <div class="nav-divider"></div>
@@ -96,18 +96,18 @@
<div class="page-header"> <div class="page-header">
<div class="page-header-text"> <div class="page-header-text">
<h1 class="page-title" data-i18n="usage.title">Usage</h1> <h1 class="page-title" data-i18n="usage.title">Usage</h1>
<p class="page-subtitle">Invocation metrics for all operations in your workspace.</p> <p class="page-subtitle" data-i18n="usage.subtitle">Invocation metrics for all operations in your workspace.</p>
</div> </div>
<div class="page-header-actions"> <div class="page-header-actions">
<select class="period-select" id="period"> <select class="period-select" id="period">
<option value="7d" selected>Last 7 days</option> <option value="7d" selected data-i18n="usage.period.7d">Last 7 days</option>
<option value="30d">Last 30 days</option> <option value="30d" data-i18n="usage.period.30d">Last 30 days</option>
<option value="90d">Last 90 days</option> <option value="90d" data-i18n="usage.period.90d">Last 90 days</option>
<option value="this_month">This month</option> <option value="this_month" data-i18n="usage.period.this_month">This month</option>
</select> </select>
<button class="btn-secondary" type="button"> <button class="btn-secondary" type="button">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 1.72a.75.75 0 011.06 0l4.5 4.5a.75.75 0 01-1.06 1.06L8.75 4.06v8.94a.75.75 0 01-1.5 0V4.06L4.53 7.28a.75.75 0 01-1.06-1.06l4.5-4.5zM1.5 13.75a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75z"/></svg> <svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M7.47 1.72a.75.75 0 011.06 0l4.5 4.5a.75.75 0 01-1.06 1.06L8.75 4.06v8.94a.75.75 0 01-1.5 0V4.06L4.53 7.28a.75.75 0 01-1.06-1.06l4.5-4.5zM1.5 13.75a.75.75 0 01.75-.75h11.5a.75.75 0 010 1.5H2.25a.75.75 0 01-.75-.75z"/></svg>
Export CSV <span data-i18n="usage.export">Export CSV</span>
</button> </button>
</div> </div>
</div> </div>
@@ -115,7 +115,7 @@
<!-- Stat cards --> <!-- Stat cards -->
<div class="stats-grid"> <div class="stats-grid">
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Total invocations</div> <div class="stat-label" data-i18n="usage.stats.total">Total invocations</div>
<div class="stat-value">28,341</div> <div class="stat-value">28,341</div>
<div class="stat-delta up"> <div class="stat-delta up">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg> <svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
@@ -123,7 +123,7 @@
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Success rate</div> <div class="stat-label" data-i18n="usage.stats.success">Success rate</div>
<div class="stat-value">98.6%</div> <div class="stat-value">98.6%</div>
<div class="stat-delta up"> <div class="stat-delta up">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg> <svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
@@ -131,7 +131,7 @@
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">Median latency (p50)</div> <div class="stat-label" data-i18n="usage.stats.p50">Median latency (p50)</div>
<div class="stat-value">124ms</div> <div class="stat-value">124ms</div>
<div class="stat-delta up"> <div class="stat-delta up">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg> <svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 2l4 4H7v4H5V6H2l4-4z"/></svg>
@@ -139,7 +139,7 @@
</div> </div>
</div> </div>
<div class="stat-card"> <div class="stat-card">
<div class="stat-label">p99 latency</div> <div class="stat-label" data-i18n="usage.stats.p99">p99 latency</div>
<div class="stat-value">2.1s</div> <div class="stat-value">2.1s</div>
<div class="stat-delta down"> <div class="stat-delta down">
<svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 6h3V2h2v4h3l-4 4z"/></svg> <svg width="11" height="11" viewBox="0 0 12 12" fill="currentColor"><path d="M6 10L2 6h3V2h2v4h3l-4 4z"/></svg>
@@ -151,10 +151,10 @@
<!-- Invocations chart --> <!-- Invocations chart -->
<div class="section-card" style="margin-bottom:20px;"> <div class="section-card" style="margin-bottom:20px;">
<div class="section-card-header"> <div class="section-card-header">
<div class="section-card-title">Invocations over time</div> <div class="section-card-title" data-i18n="usage.chart.title">Invocations over time</div>
<div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-muted);"> <div style="display:flex;align-items:center;gap:12px;font-size:12px;color:var(--text-muted);">
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--accent);display:inline-block;"></span>Success</span> <span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--accent);display:inline-block;"></span><span data-i18n="usage.chart.success">Success</span></span>
<span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--red);display:inline-block;"></span>Error</span> <span style="display:flex;align-items:center;gap:5px;"><span style="width:10px;height:10px;border-radius:2px;background:var(--red);display:inline-block;"></span><span data-i18n="usage.chart.error">Error</span></span>
</div> </div>
</div> </div>
<div class="section-card-body" style="padding-bottom:28px;"> <div class="section-card-body" style="padding-bottom:28px;">
@@ -166,20 +166,20 @@
<div class="section-card"> <div class="section-card">
<div class="section-card-header"> <div class="section-card-header">
<div> <div>
<div class="section-card-title">By operation</div> <div class="section-card-title" data-i18n="usage.table.title">By operation</div>
<div class="section-card-subtitle">Breakdown for last 7 days</div> <div class="section-card-subtitle">Breakdown for last 7 days</div>
</div> </div>
</div> </div>
<table class="data-table"> <table class="data-table">
<thead> <thead>
<tr> <tr>
<th>Operation</th> <th data-i18n="usage.table.th.operation">Operation</th>
<th>Protocol</th> <th data-i18n="usage.table.th.protocol">Protocol</th>
<th style="text-align:right;">Calls</th> <th style="text-align:right;" data-i18n="usage.table.th.calls">Calls</th>
<th style="text-align:right;">Errors</th> <th style="text-align:right;" data-i18n="usage.table.th.errors">Errors</th>
<th style="text-align:right;">Error rate</th> <th style="text-align:right;" data-i18n="usage.table.th.error_rate">Error rate</th>
<th>Latency (p50 / p95 / p99)</th> <th data-i18n="usage.table.th.latency">Latency (p50 / p95 / p99)</th>
<th style="text-align:right;">Traffic share</th> <th style="text-align:right;" data-i18n="usage.table.th.share">Traffic share</th>
</tr> </tr>
</thead> </thead>
<tbody id="usage-tbody"> <tbody id="usage-tbody">
+98 -2
View File
@@ -193,7 +193,55 @@ var TRANSLATIONS = {
// Usage page // Usage page
'usage.title': 'Usage', '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 sidebar
'settings.title': 'Settings', 'settings.title': 'Settings',
@@ -512,7 +560,55 @@ var TRANSLATIONS = {
// Usage page // Usage page
'usage.title': 'Использование', '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 sidebar
'settings.title': 'Настройки', 'settings.title': 'Настройки',
+37 -31
View File
@@ -16,13 +16,25 @@ document.addEventListener('DOMContentLoaded', function () {
var subtitle = document.querySelector('.section-card-subtitle'); var subtitle = document.querySelector('.section-card-subtitle');
var statCards = document.querySelectorAll('.stats-grid .stat-card'); 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() { function currentWorkspaceId() {
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
return workspace ? workspace.id : null; return workspace ? workspace.id : null;
} }
function formatCount(value) { function formatCount(value) {
return Number(value || 0).toLocaleString(); return Number(value || 0).toLocaleString(currentLocale());
} }
function formatMs(value) { function formatMs(value) {
@@ -36,13 +48,7 @@ document.addEventListener('DOMContentLoaded', function () {
} }
function periodLabel(period) { function periodLabel(period) {
var labels = { return tKey('usage.period.label.' + period);
'7d': 'last 7 days',
'30d': 'last 30 days',
'90d': 'last 90 days',
'this_month': 'this month',
};
return labels[period] || period;
} }
function protocolColor(protocol) { function protocolColor(protocol) {
@@ -68,12 +74,12 @@ document.addEventListener('DOMContentLoaded', function () {
function chartBucketLabel(timestamp, period, index) { function chartBucketLabel(timestamp, period, index) {
var date = new Date(timestamp); var date = new Date(timestamp);
if (period === '7d') { if (period === '7d') {
return date.toLocaleDateString('en-US', { weekday: 'short' }); return date.toLocaleDateString(currentLocale(), { weekday: 'short' });
} }
if (period === '90d') { 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) { function toUtcBucketStart(date, period) {
@@ -186,19 +192,19 @@ document.addEventListener('DOMContentLoaded', function () {
var cards = [ var cards = [
{ {
value: formatCount(summary ? summary.rollup.calls_total : 0), 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) + '%', 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), 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), 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) { if (!timeline.length) {
chartWrap.innerHTML = chartWrap.innerHTML =
'<div class="empty-state" style="width:100%;">' + '<div class="empty-state" style="width:100%;">' +
'<div class="empty-state-title">No usage data yet</div>' + '<div class="empty-state-title">' + tKey('usage.chart.empty.title') + '</div>' +
'<div class="empty-state-text">Invocation metrics will appear here after tests or published tool calls in the selected period.</div>' + '<div class="empty-state-text">' + tKey('usage.chart.empty.sub') + '</div>' +
'</div>'; '</div>';
return; return;
} }
@@ -242,9 +248,9 @@ document.addEventListener('DOMContentLoaded', function () {
errorHeight = Math.max(errorHeight, 6); errorHeight = Math.max(errorHeight, 6);
} }
node.querySelector('.chart-bar.success').style.height = okHeight + 'px'; 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').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); node.querySelector('.chart-col-label').textContent = chartBucketLabel(point.bucket_start, state.period, index);
chartWrap.appendChild(node); chartWrap.appendChild(node);
}); });
@@ -258,8 +264,8 @@ document.addEventListener('DOMContentLoaded', function () {
tableBody.innerHTML = tableBody.innerHTML =
'<tr><td colspan="7" style="padding:24px;">' + '<tr><td colspan="7" style="padding:24px;">' +
'<div class="empty-state" style="padding:0;">' + '<div class="empty-state" style="padding:0;">' +
'<div class="empty-state-title">No operation breakdown yet</div>' + '<div class="empty-state-title">' + tKey('usage.table.empty.title') + '</div>' +
'<div class="empty-state-text">The selected period has no invocation samples to break down by operation.</div>' + '<div class="empty-state-text">' + tKey('usage.table.empty.sub') + '</div>' +
'</div>' + '</div>' +
'</td></tr>'; '</td></tr>';
return; 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-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('.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) + '%'; node.querySelector('.col-quota-fill').style.width = share.toFixed(1) + '%';
tableBody.appendChild(node); tableBody.appendChild(node);
@@ -310,12 +316,12 @@ document.addEventListener('DOMContentLoaded', function () {
function renderUsage() { function renderUsage() {
if (state.loading && !state.usage) { 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; return;
} }
if (state.loadError) { if (state.loadError) {
renderEmpty('Unable to load usage', state.loadError); renderEmpty(tKey('usage.error.title'), state.loadError);
return; return;
} }
@@ -323,20 +329,20 @@ document.addEventListener('DOMContentLoaded', function () {
renderChart(); renderChart();
renderTable(); renderTable();
if (subtitle) { if (subtitle) {
subtitle.textContent = 'Breakdown for ' + periodLabel(state.period); subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) });
} }
} }
async function loadUsage() { async function loadUsage() {
if (!window.CrankApi) { if (!window.CrankApi) {
state.loadError = 'Admin API is not available'; state.loadError = tKey('usage.error.api');
renderUsage(); renderUsage();
return; return;
} }
state.workspaceId = currentWorkspaceId(); state.workspaceId = currentWorkspaceId();
if (!state.workspaceId) { if (!state.workspaceId) {
state.loadError = 'Workspace is not selected'; state.loadError = tKey('usage.error.workspace');
renderUsage(); renderUsage();
return; return;
} }
@@ -348,7 +354,7 @@ document.addEventListener('DOMContentLoaded', function () {
try { try {
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period }); state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
} catch (error) { } catch (error) {
state.loadError = error.message || 'Failed to load usage'; state.loadError = error.message || tKey('usage.error.load');
} finally { } finally {
state.loading = false; state.loading = false;
renderUsage(); renderUsage();
@@ -358,12 +364,12 @@ document.addEventListener('DOMContentLoaded', function () {
function exportCsv() { function exportCsv() {
if (!state.usage) { if (!state.usage) {
if (window.CrankUi) { 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; 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) { state.usage.operations.forEach(function (operation) {
var errorRate = operation.calls_total === 0 var errorRate = operation.calls_total === 0
? 0 ? 0
@@ -388,7 +394,7 @@ document.addEventListener('DOMContentLoaded', function () {
link.click(); link.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
if (window.CrankUi) { 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'));
} }
} }