feat: localize operations catalog

This commit is contained in:
a.tolmachev
2026-04-02 22:51:25 +03:00
parent 4113515d0f
commit a491c246e0
3 changed files with 117 additions and 35 deletions
+23 -22
View File
@@ -113,7 +113,7 @@
</div>
<button class="btn-new" @click="handleNewOperation()">
<svg width="13" height="13"><use href="icons/general/plus.svg#icon"/></svg>
New operation
<span data-i18n="ops.new">New operation</span>
</button>
</div>
@@ -122,22 +122,22 @@
<div class="stat-card">
<div class="stat-label" data-i18n="stats.total">Total operations</div>
<div class="stat-value" x-text="formatMetricNumber(stats.total)"></div>
<div class="stat-delta" x-text="stats.total ? 'Catalog synced with backend' : 'No operations yet'"></div>
<div class="stat-delta" x-text="stats.total ? tKey('ops.stats.synced') : tKey('ops.stats.none')"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.active">Active</div>
<div class="stat-value" x-text="formatMetricNumber(stats.active)"></div>
<div class="stat-delta" x-text="stats.total ? Math.round((stats.active / stats.total) * 100) + '% of total' : 'No published tools yet'"></div>
<div class="stat-delta" x-text="stats.total ? tfKey('ops.stats.share', { value: Math.round((stats.active / stats.total) * 100) }) : tKey('ops.stats.no_published')"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
<div class="stat-value" x-text="formatMetricNumber(stats.requestsToday)"></div>
<div class="stat-delta" x-text="stats.requestsToday ? 'Summed from operation usage' : 'No traffic recorded today'"></div>
<div class="stat-delta" x-text="stats.requestsToday ? tKey('ops.stats.requests_rollup') : tKey('ops.stats.no_traffic')"></div>
</div>
<div class="stat-card">
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
<div class="stat-value" x-text="stats.averageLatencyMs ? stats.averageLatencyMs + 'ms' : '—'"></div>
<div class="stat-delta" x-text="stats.averageLatencyMs ? `Weighted by today's calls` : 'Waiting for runtime samples'"></div>
<div class="stat-delta" x-text="stats.averageLatencyMs ? tKey('ops.stats.latency_weighted') : tKey('ops.stats.latency_waiting')"></div>
</div>
</div>
@@ -163,6 +163,7 @@
<svg width="14" height="14"><use href="icons/general/search.svg#icon"/></svg>
<input
type="text"
data-i18n-ph="filter.search"
placeholder="Search operations..."
:value="search"
@input="setSearch($event.target.value)"
@@ -176,7 +177,7 @@
:class="{ 'has-value': filterProtocol, open: openDropdown === 'protocol' }"
@click="toggleDropdown('protocol')"
>
<span x-text="filterProtocol ? filterProtocol.toUpperCase() : 'Protocol'"></span>
<span x-text="filterProtocol ? filterProtocol.toUpperCase() : tKey('filter.protocol')"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'protocol'" x-transition>
@@ -193,7 +194,7 @@
<template x-if="filterProtocol">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearProtocol()">Clear filter</button>
<button class="dropdown-clear" @click="clearProtocol()" data-i18n="filter.clear">Clear filter</button>
</div>
</template>
</div>
@@ -206,7 +207,7 @@
:class="{ 'has-value': filterCategory, open: openDropdown === 'category' }"
@click="toggleDropdown('category')"
>
<span x-text="filterCategory ?? 'Category'"></span>
<span x-text="filterCategory ?? tKey('filter.category')"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'category'" x-transition>
@@ -223,7 +224,7 @@
<template x-if="filterCategory">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearCategory()">Clear filter</button>
<button class="dropdown-clear" @click="clearCategory()" data-i18n="filter.clear">Clear filter</button>
</div>
</template>
</div>
@@ -236,7 +237,7 @@
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
@click="toggleDropdown('agent')"
>
<span x-text="filterAgent ? activeAgents.find(a => a.agent_id === filterAgent)?.display_name : 'Agent'"></span>
<span x-text="filterAgent ? activeAgents.find(a => a.agent_id === filterAgent)?.display_name : tKey('filter.agent')"></span>
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
</button>
<div class="dropdown-menu" x-show="openDropdown === 'agent'" x-transition>
@@ -251,12 +252,12 @@
</button>
</template>
<template x-if="!activeAgents.length">
<div class="dropdown-item" style="color:var(--text-muted);cursor:default;">No active agents</div>
<div class="dropdown-item" style="color:var(--text-muted);cursor:default;" data-i18n="ops.no_active_agents">No active agents</div>
</template>
<template x-if="filterAgent">
<div>
<div class="dropdown-divider"></div>
<button class="dropdown-clear" @click="clearAgent()">Clear filter</button>
<button class="dropdown-clear" @click="clearAgent()" data-i18n="filter.clear">Clear filter</button>
</div>
</template>
</div>
@@ -265,7 +266,7 @@
<!-- Right side: count + sort -->
<div class="filter-right">
<span class="results-count">
Showing <span x-text="totalFiltered"></span> of <span x-text="operations.length"></span>
<span x-text="tfKey('ops.results', { shown: totalFiltered, total: operations.length })"></span>
</span>
<!-- Sort dropdown -->
@@ -300,12 +301,12 @@
<template x-for="chip in activeChips" :key="chip.key">
<div class="active-filter-chip">
<span x-text="chip.label"></span>
<button class="active-filter-chip-remove" @click="clearChip(chip.key)" title="Remove filter">
<button class="active-filter-chip-remove" @click="clearChip(chip.key)" data-i18n-title="filter.remove" title="Remove filter">
<svg width="8" height="8" viewBox="0 0 8 8" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="7" y2="7"/><line x1="7" y1="1" x2="1" y2="7"/></svg>
</button>
</div>
</template>
<button class="active-filter-clear-all" @click="clearAllFilters()" x-show="activeChips.length > 1">
<button class="active-filter-clear-all" @click="clearAllFilters()" x-show="activeChips.length > 1" data-i18n="filter.clear_all">
Clear all
</button>
</div>
@@ -320,12 +321,12 @@
<!-- Empty state -->
<div class="empty-state" x-show="!loading && !loadError && totalFiltered === 0">
<h3 x-text="operations.length === 0 ? 'No operations yet' : 'No operations found'">No operations found</h3>
<p x-text="operations.length === 0 ? 'Create the first operation in this workspace to expose a live MCP tool.' : 'Try adjusting your search or filters.'">Try adjusting your search or filters.</p>
<h3 x-text="operations.length === 0 ? tKey('ops.empty.initial.title') : tKey('ops.empty.title')">No operations found</h3>
<p x-text="operations.length === 0 ? tKey('ops.empty.initial.sub') : tKey('ops.empty.sub')">Try adjusting your search or filters.</p>
</div>
<div class="empty-state" x-show="!loading && !!loadError">
<h3>Backend connection failed</h3>
<h3 data-i18n="ops.error.title">Backend connection failed</h3>
<p x-text="loadError"></p>
</div>
@@ -342,7 +343,7 @@
<th data-i18n="th.protocol">PROTOCOL</th>
<th data-i18n="th.status">STATUS</th>
<th class="th-sortable col-created" :class="{ 'sort-active': sort.startsWith('created') }" @click="setSort(sort === 'created_asc' ? 'created_desc' : 'created_asc')">
CREATED
<span data-i18n="th.created">CREATED</span>
<span class="sort-arrow" :class="{ active: sort.startsWith('created') }">
<span x-text="sort === 'created_asc' ? '↑' : sort === 'created_desc' ? '↓' : '↕'"></span>
</span>
@@ -383,10 +384,10 @@
</td>
<td>
<div class="row-actions">
<button class="row-btn row-btn-edit" title="Edit operation" @click="editOperation(op)">
<button class="row-btn row-btn-edit" :title="tKey('ops.action.edit')" @click="editOperation(op)">
<svg width="14" height="14"><use href="icons/general/edit.svg#icon"/></svg>
</button>
<button class="row-btn row-btn-delete" title="Delete operation" @click="deleteOperation(op.id)">
<button class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
</div>
@@ -399,7 +400,7 @@
<!-- Footer / pagination -->
<div class="table-footer" x-show="!loading && totalFiltered > 0 && !loadError">
<span class="table-footer-info">
Showing <span x-text="pageStart"></span><span x-text="pageEnd"></span> of <span x-text="totalFiltered"></span> operations
<span x-text="tfKey('ops.range', { from: pageStart, to: pageEnd, total: totalFiltered })"></span>
</span>
<div class="pagination">
<button class="page-btn" :disabled="page === 1" @click="goPage(page - 1)"></button>
+30 -13
View File
@@ -28,6 +28,10 @@ function uiStatus(rawStatus) {
return rawStatus || 'draft';
}
function currentLocale() {
return localStorage.getItem('crank_lang') === 'ru' ? 'ru-RU' : 'en-US';
}
function mapOperation(item) {
return {
id: item.id,
@@ -239,11 +243,11 @@ document.addEventListener('alpine:init', function() {
get activeChips() {
var chips = [];
if (this.filterProtocol) chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
if (this.filterCategory) chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
if (this.filterProtocol) chips.push({ key: 'protocol', label: this.tfKey('ops.filter.protocol', { value: this.filterProtocol.toUpperCase() }) });
if (this.filterCategory) chips.push({ key: 'category', label: this.tfKey('ops.filter.category', { value: this.filterCategory }) });
if (this.filterAgent) {
var agent = this.activeAgents.find(function(item) { return item.agent_id === this.filterAgent; }, this);
chips.push({ key: 'agent', label: 'Agent: ' + (agent ? agent.display_name : '') });
chips.push({ key: 'agent', label: this.tfKey('ops.filter.agent', { value: agent ? agent.display_name : '' }) });
}
return chips;
},
@@ -361,7 +365,7 @@ document.addEventListener('alpine:init', function() {
},
async deleteOperation(id) {
if (!confirm('Delete this operation? This cannot be undone.')) return;
if (!confirm(this.tKey('ops.delete.confirm'))) return;
try {
var operation = this.operations.find(function(item) { return item.id === id; });
await window.CrankApi.deleteOperation(this.workspaceId, id);
@@ -372,13 +376,18 @@ document.addEventListener('alpine:init', function() {
this.stats = computeStats(this.operations);
if (window.CrankUi) {
window.CrankUi.success(
(operation ? (operation.display_name || operation.name) : 'Operation') + ' was deleted.',
'Operation deleted'
this.tfKey('ops.delete.success.message', {
name: operation ? (operation.display_name || operation.name) : ''
}),
this.tKey('ops.delete.success.title')
);
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to delete operation', 'Delete failed');
window.CrankUi.error(
error.message || this.tKey('ops.delete.error.message'),
this.tKey('ops.delete.error.title')
);
}
}
},
@@ -400,15 +409,15 @@ document.addEventListener('alpine:init', function() {
},
statusLabel(operation) {
if (operation.status === 'active') return 'Active';
if (operation.status === 'inactive') return 'Inactive';
if (operation.status === 'error') return 'Error';
return operation.status.charAt(0).toUpperCase() + operation.status.slice(1);
if (operation.status === 'active') return this.tKey('tab.active');
if (operation.status === 'inactive') return this.tKey('tab.inactive');
if (operation.status === 'error') return this.tKey('tab.error');
return this.tKey('tab.draft');
},
formatDate(dateStr) {
if (!dateStr) return '—';
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
return new Date(dateStr).toLocaleDateString(currentLocale(), { month: 'short', day: 'numeric', year: 'numeric' });
},
truncateUrl(url) {
@@ -417,7 +426,15 @@ document.addEventListener('alpine:init', function() {
},
formatMetricNumber(value) {
return new Intl.NumberFormat('en-US').format(value || 0);
return new Intl.NumberFormat(currentLocale()).format(value || 0);
},
tKey(key) {
return window.t ? t(key) : key;
},
tfKey(key, vars) {
return window.tf ? tf(key, vars) : this.tKey(key);
},
get sortOptions() {
+64
View File
@@ -21,6 +21,30 @@ var TRANSLATIONS = {
'ops.loading': 'Loading operations…',
'ops.empty.title': 'No operations found',
'ops.empty.sub': 'Try adjusting your search or filters.',
'ops.empty.initial.title': 'No operations yet',
'ops.empty.initial.sub': 'Create the first operation in this workspace to expose a live MCP tool.',
'ops.error.title': 'Backend connection failed',
'ops.delete.confirm': 'Delete this operation? This cannot be undone.',
'ops.delete.success.title': 'Operation deleted',
'ops.delete.success.message': '{name} was deleted.',
'ops.delete.error.title': 'Delete failed',
'ops.delete.error.message': 'Failed to delete operation',
'ops.action.edit': 'Edit operation',
'ops.action.delete':'Delete operation',
'ops.stats.synced': 'Catalog synced with backend',
'ops.stats.none': 'No operations yet',
'ops.stats.share': '{value}% of total',
'ops.stats.no_published': 'No published tools yet',
'ops.stats.requests_rollup': 'Summed from operation usage',
'ops.stats.no_traffic': 'No traffic recorded today',
'ops.stats.latency_weighted': "Weighted by today's calls",
'ops.stats.latency_waiting': 'Waiting for runtime samples',
'ops.results': 'Showing {shown} of {total}',
'ops.range': 'Showing {from}{to} of {total} operations',
'ops.filter.protocol': 'Protocol: {value}',
'ops.filter.category': 'Category: {value}',
'ops.filter.agent': 'Agent: {value}',
'ops.no_active_agents': 'No active agents',
// Stats
'stats.total': 'Total operations',
@@ -40,9 +64,12 @@ var TRANSLATIONS = {
'filter.protocol': 'Protocol',
'filter.status': 'Status',
'filter.category': 'Category',
'filter.agent': 'Agent',
'filter.showing': 'Showing',
'filter.of': 'of',
'filter.clear': 'Clear filter',
'filter.clear_all': 'Clear all',
'filter.remove': 'Remove filter',
'sort.created_desc':'Sort: Last created',
'sort.created_asc': 'Sort: Oldest first',
'sort.name_asc': 'Sort: Name AZ',
@@ -147,6 +174,30 @@ var TRANSLATIONS = {
'ops.loading': 'Загрузка операций…',
'ops.empty.title': 'Операции не найдены',
'ops.empty.sub': 'Попробуйте изменить поисковый запрос или фильтры.',
'ops.empty.initial.title': 'Операций пока нет',
'ops.empty.initial.sub': 'Создайте первую операцию в этом воркспейсе, чтобы открыть живой MCP-инструмент.',
'ops.error.title': 'Не удалось подключиться к backend',
'ops.delete.confirm': 'Удалить эту операцию? Действие нельзя отменить.',
'ops.delete.success.title': 'Операция удалена',
'ops.delete.success.message': 'Операция {name} удалена.',
'ops.delete.error.title': 'Не удалось удалить',
'ops.delete.error.message': 'Не удалось удалить операцию',
'ops.action.edit': 'Редактировать операцию',
'ops.action.delete':'Удалить операцию',
'ops.stats.synced': 'Каталог синхронизирован с backend',
'ops.stats.none': 'Операций пока нет',
'ops.stats.share': '{value}% от общего числа',
'ops.stats.no_published': 'Опубликованных инструментов пока нет',
'ops.stats.requests_rollup': 'Сумма по статистике операций',
'ops.stats.no_traffic': 'Трафик сегодня не зафиксирован',
'ops.stats.latency_weighted': 'Взвешено по сегодняшним вызовам',
'ops.stats.latency_waiting': 'Ожидание runtime-метрик',
'ops.results': 'Показано {shown} из {total}',
'ops.range': 'Показано {from}{to} из {total} операций',
'ops.filter.protocol': 'Протокол: {value}',
'ops.filter.category': 'Категория: {value}',
'ops.filter.agent': 'Агент: {value}',
'ops.no_active_agents': 'Нет активных агентов',
// Stats
'stats.total': 'Всего операций',
@@ -166,9 +217,12 @@ var TRANSLATIONS = {
'filter.protocol': 'Протокол',
'filter.status': 'Статус',
'filter.category': 'Категория',
'filter.agent': 'Агент',
'filter.showing': 'Показано',
'filter.of': 'из',
'filter.clear': 'Сбросить',
'filter.clear_all': 'Сбросить все',
'filter.remove': 'Убрать фильтр',
'sort.created_desc':'Сначала новые',
'sort.created_asc': 'Сначала старые',
'sort.name_asc': 'Название А–Я',
@@ -263,6 +317,12 @@ function t(key) {
return tr[key] !== undefined ? tr[key] : (TRANSLATIONS.en[key] !== undefined ? TRANSLATIONS.en[key] : key);
}
function tf(key, vars) {
return Object.keys(vars || {}).reduce(function(result, name) {
return result.replaceAll('{' + name + '}', vars[name]);
}, t(key));
}
function applyLang() {
// text content
document.querySelectorAll('[data-i18n]').forEach(function(el) {
@@ -272,6 +332,10 @@ function applyLang() {
document.querySelectorAll('[data-i18n-ph]').forEach(function(el) {
el.placeholder = t(el.getAttribute('data-i18n-ph'));
});
// title
document.querySelectorAll('[data-i18n-title]').forEach(function(el) {
el.title = t(el.getAttribute('data-i18n-title'));
});
// highlight active language button in settings
var lang = localStorage.getItem('crank_lang') || 'en';
document.querySelectorAll('.lang-btn').forEach(function(btn) {