merge: frontend i18n remediation

This commit is contained in:
a.tolmachev
2026-04-12 01:57:50 +03:00
6 changed files with 161 additions and 101 deletions
+15 -13
View File
@@ -2,39 +2,41 @@
## Current
### `feat/refactoring-plan`
### `feat/frontend-settings-honesty-pass`
Status: in_progress
DoD:
- `__REFACTORING.md` documents the full remediation plan for registry modularization, sqlx compile-time validation, KDF correction, typed timestamps, ID display support, pool tuning, and error normalization
- `__REFACTORING.md` also documents correlation IDs, rate limiting/backpressure, and a distributed `mcp-server` session store
- `.gitignore` ignores local `__*.md` working notes and refactoring specs
- `TASKS.md` points to the refactoring track as the active work item
- settings page does not present non-functional controls as if they were interactive
- planned notification capabilities are collapsed into an honest informational block
- page subtitle stays user-facing and describes the live settings surface
- EN and RU copy remain aligned after the cleanup
- existing working settings actions keep their current behavior
## Next
- `feat/postgres-registry-modularization`
- `feat/frontend-command-palette-decision`
## Backlog
- `feat/frontend-refactoring-plan`
- `feat/frontend-ux-review-plan`
- `feat/frontend-xss-hardening`
- `feat/frontend-i18n-remediation`
- `feat/frontend-login-simplification`
- `feat/frontend-settings-honesty-pass`
- `feat/frontend-command-palette-decision`
- `feat/frontend-plural-rules`
- `feat/frontend-template-safety-cleanup`
- `feat/frontend-shell-unification`
- `feat/frontend-wizard-modularization`
- `feat/frontend-build-pipeline`
- `feat/frontend-css-state-cleanup`
- `feat/frontend-performance-polish`
- `feat/postgres-registry-modularization`
- `feat/sqlx-compile-time-verification`
- `feat/typed-timestamps`
- `feat/secret-crypto-hkdf`
- `feat/frontend-observability-and-testability`
- `feat/id-display-support`
- `feat/postgres-pool-config`
- `feat/postgres-registry-modularization`
- `feat/sqlx-compile-time-verification`
- `feat/secret-crypto-hkdf`
- `feat/typed-timestamps`
- `feat/error-structure-normalization`
- `feat/correlation-id-propagation`
- `feat/runtime-rate-limiting-and-backpressure`
+1 -32
View File
@@ -26,7 +26,7 @@
<div class="login-box">
<div class="login-heading" data-i18n="login.title">Sign in</div>
<div class="login-sub" data-i18n="login.subtitle">Welcome back to your workspace</div>
<div class="login-note" data-i18n="login.password_only">This build supports password sign-in only. Password reset, Google SSO and self-service access requests are not wired yet.</div>
<div class="login-note" data-i18n="login.coming_soon">Password reset and SSO will be added later.</div>
<div class="login-error" id="login-error" data-i18n="login.error.invalid">
Invalid email or password. Please try again.
@@ -41,42 +41,11 @@
<div class="field">
<label class="field-label" for="password" data-i18n="login.password">Password</label>
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
<div class="field-footer">
<button class="field-link is-disabled" type="button" disabled>
<span data-i18n="login.forgot">Forgot password?</span>
<span class="login-inline-badge" data-i18n="login.planned_badge">Planned</span>
</button>
</div>
</div>
<button class="btn-signin" type="submit" data-i18n="login.submit">Sign in</button>
</form>
<div class="login-divider">
<div class="login-divider-line"></div>
<span class="login-divider-text" data-i18n="login.divider">or continue with</span>
<div class="login-divider-line"></div>
</div>
<button class="btn-sso" type="button" disabled>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none">
<path d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92c-.26 1.37-1.04 2.53-2.21 3.31v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.09z" fill="#4285F4"/>
<path d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z" fill="#34A853"/>
<path d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l3.66-2.84z" fill="#FBBC05"/>
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335"/>
</svg>
<span data-i18n="login.sso_short">Google SSO</span>
<span class="login-inline-badge" data-i18n="login.planned_badge">Planned</span>
</button>
</div>
<div class="login-footer">
<span data-i18n="login.request_prefix">Don't have an account?</span>
<button class="login-footer-action" type="button" disabled>
<span data-i18n="login.request_link">Request access</span>
<span class="login-inline-badge" data-i18n="login.planned_badge">Planned</span>
</button>
</div>
<div class="version-badge">Crank v0.9.0-beta</div>
+107 -40
View File
@@ -104,7 +104,7 @@ var TRANSLATIONS = {
'apikeys.th.created':'CREATED',
'apikeys.th.last': 'LAST USED',
'apikeys.callout.title': 'Keys are only shown once.',
'apikeys.callout.body': 'Copy and store them securely immediately after creation — Crank stores only a hash. `Last used` updates after successful MCP authentication.',
'apikeys.callout.body': 'Copy and store it securely right after creation — Crank stores only a hash. Last used updates after a successful MCP call.',
'apikeys.active.title': 'Active keys',
'apikeys.active.subtitle': '{active} active · {revoked} revoked',
'apikeys.search': 'Filter keys…',
@@ -163,7 +163,7 @@ var TRANSLATIONS = {
'secrets.subtitle': 'Encrypted upstream credentials for bearer tokens, basic auth, and API keys used by auth profiles.',
'secrets.new': 'Create secret',
'secrets.callout.title': 'Secret plaintext is write-only.',
'secrets.callout.body': 'Crank encrypts secret values with the workspace master key. After create or rotate, the API returns only metadata, so this page focuses on lifecycle and usage references.',
'secrets.callout.body': 'Crank encrypts secret values with the workspace master key. After creation or rotation, the API returns only metadata, so this page focuses on secret management and where each secret is used.',
'secrets.active.title': 'Workspace secrets',
'secrets.active.subtitle': '{active} active · {total} total',
'secrets.search': 'Filter secrets…',
@@ -180,7 +180,8 @@ var TRANSLATIONS = {
'secrets.status.active': 'Active',
'secrets.status.disabled': 'Disabled',
'secrets.last_used.never': 'Never',
'secrets.used_by_count': '{count} profiles',
'secrets.used_by_count.one': '{count} profile',
'secrets.used_by_count.other': '{count} profiles',
'secrets.loading': 'Loading…',
'secrets.empty.none': 'No secrets yet',
'secrets.empty.search': 'No secrets match your search',
@@ -222,19 +223,20 @@ var TRANSLATIONS = {
'secrets.hint.token': 'Best for bearer tokens and simple API key values.',
'secrets.hint.username_password': 'Stores username and password as one encrypted payload.',
'secrets.hint.header': 'Stores a raw header value. Header name is configured in the auth profile.',
'secrets.hint.generic': 'Stores arbitrary JSON for advanced future integrations.',
'secrets.hint.generic': 'Stores arbitrary JSON for custom integrations that need a structured secret payload.',
'secrets.validation.name_required': 'Secret name is required.',
'secrets.validation.value_required': 'Secret value is required.',
'secrets.validation.username_password_required': 'Username and password are both required.',
'secrets.validation.json_invalid': 'Generic secret payload must be valid JSON.',
'secrets.profiles.title': 'Auth profile references',
'secrets.profiles.subtitle': 'Profiles resolve secrets at runtime before upstream execution.',
'secrets.profiles.subtitle_count': '{count} auth profiles',
'secrets.profiles.subtitle_count.one': '{count} auth profile',
'secrets.profiles.subtitle_count.other': '{count} auth profiles',
'secrets.profiles.error_title': 'Unable to load auth profile references',
'secrets.profiles.loading_title': 'Loading auth profile references…',
'secrets.profiles.loading_body': 'Fetching auth profiles for the current workspace.',
'secrets.profiles.empty_title': 'No auth profiles yet',
'secrets.profiles.empty_body': 'Create auth profiles in the next slice to bind secrets to upstream authentication.',
'secrets.profiles.empty_body': 'Create auth profiles in the Wizard when configuring an operation.',
'secrets.profiles.references': 'Referenced secrets',
'secrets.profiles.reference_count': '{count} refs',
'secrets.profiles.meta.created': 'Created',
@@ -262,7 +264,7 @@ var TRANSLATIONS = {
'logs.paused': 'Paused',
'logs.refresh': 'Refresh',
'logs.range.30m': 'Last 30 min',
'logs.range.1h': 'Last 1 hour',
'logs.range.1h': 'Last hour',
'logs.range.6h': 'Last 6 hours',
'logs.range.24h': 'Last 24 hours',
'logs.range.7d': 'Last 7 days',
@@ -369,7 +371,7 @@ var TRANSLATIONS = {
'settings.profile.email': 'Email',
'settings.profile.role': 'Role',
'settings.page.title': 'Account settings',
'settings.page.subtitle': 'Manage the live profile, password and workspace settings available in this build. Planned capabilities stay visible, but they do not pretend to be wired yet.',
'settings.page.subtitle': 'Manage your profile, password, and workspace settings.',
'settings.ws.slug_hint': 'Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.',
'settings.ws.description_optional': 'Description (optional)',
'settings.ws.description_placeholder': 'What does this workspace do?',
@@ -405,8 +407,8 @@ var TRANSLATIONS = {
'settings.session.current_workspace': 'current workspace',
'settings.session.summary': '{email} · {role} in {workspace}. Use Log out to revoke the current browser session.',
'settings.notifications.title': 'Notification preferences',
'settings.notifications.not_ready_title': 'Notifications are not wired yet.',
'settings.notifications.not_ready_body': 'Alert routing and per-user preferences are planned, but this build does not persist or deliver notification settings.',
'settings.notifications.not_ready_title': 'Notifications are coming later.',
'settings.notifications.not_ready_body': 'Notification routing and per-user preferences will be added later.',
'settings.planned_badge': 'Planned',
'settings.notifications.spike_title': 'Operation error spike',
'settings.notifications.spike_body': 'Alert when error rate exceeds a rolling threshold for a published tool.',
@@ -446,7 +448,8 @@ var TRANSLATIONS = {
'workspace_setup.members.invite_sent': '✓ Invite sent.',
'workspace_setup.members.you': '(you)',
'workspace_setup.members.today': 'Today',
'workspace_setup.members.days_ago': '{count} days ago',
'workspace_setup.members.days_ago.one': '{count} day ago',
'workspace_setup.members.days_ago.other': '{count} days ago',
'workspace_setup.members.week_ago': '1 week ago',
'workspace_setup.members.never': 'Never',
'workspace_setup.members.pending': 'Pending invites',
@@ -477,8 +480,8 @@ var TRANSLATIONS = {
'workspace_setup.role.admin': 'Admin',
'workspace_setup.role.operator': 'Developer',
'workspace_setup.role.viewer': 'Viewer',
'workspace_setup.member_count': '{count} members',
'workspace_setup.member_count_one': '1 member',
'workspace_setup.member_count.one': '{count} member',
'workspace_setup.member_count.other': '{count} members',
'workspace_setup.saving_create': 'Creating…',
'workspace_setup.saving_update': 'Saving…',
'workspace_setup.saved': 'Saved ✓',
@@ -572,7 +575,7 @@ var TRANSLATIONS = {
'wizard.step2.auth_mode.existing': 'Use existing auth profile',
'wizard.step2.auth_mode.create': 'Create auth profile now',
'wizard.step2.auth_profile': 'Auth profile',
'wizard.step2.auth_profile_hint': 'Selected profile will be resolved at runtime and attached through `execution_config.auth_profile_ref`.',
'wizard.step2.auth_profile_hint': 'The selected auth profile is applied automatically when the tool runs.',
'wizard.step2.auth_profile_empty': 'No auth profiles yet. Create one below or on the Secrets page.',
'wizard.step2.create_profile_title': 'Create auth profile',
'wizard.step2.create_profile_subtitle': 'Create a reusable profile backed by encrypted workspace secrets.',
@@ -645,7 +648,8 @@ var TRANSLATIONS = {
'wizard.step3.grpc.paste_content': 'Paste content',
'wizard.step3.grpc.parse_proto': 'Parse proto',
'wizard.step3.grpc.streaming_excluded': 'Streaming methods excluded',
'wizard.step3.grpc.streaming_hidden': '{count} streaming method(s) found and hidden — not compatible with MCP tool calls.',
'wizard.step3.grpc.streaming_hidden.one': 'Found and hid {count} streaming method — it is not compatible with MCP tool calls.',
'wizard.step3.grpc.streaming_hidden.other': 'Found and hid {count} streaming methods — they are not compatible with MCP tool calls.',
'wizard.step3.grpc.select_method': 'Select method',
'wizard.step3.grpc.discovery_live_title': 'Descriptor-driven discovery',
'wizard.step3.grpc.discovery_live_subtitle': 'Use the saved draft and a descriptor set to discover services through the backend',
@@ -832,9 +836,12 @@ var TRANSLATIONS = {
'wizard.test.completed_body': 'The current draft executed successfully against the upstream.',
'wizard.test.failed_body': 'The draft executed, but the backend returned validation or runtime errors.',
'wizard.test.window_completed': 'Window test completed',
'wizard.test.window_completed_body': 'Window complete: {window_complete}. Truncated: {truncated}. Has more: {has_more}.',
'wizard.test.window_completed_body': 'Collected a bounded result window.',
'wizard.test.window_truncated_note': 'Results were truncated.',
'wizard.test.window_more_note': 'More data is available if you continue from the streaming views.',
'wizard.test.window_incomplete_note': 'The upstream stream ended before the configured window was fully collected.',
'wizard.test.session_started': 'Session test started',
'wizard.test.session_started_body': 'Session {session_id} was created. Poll after {poll_after_ms} ms from the stream sessions view.',
'wizard.test.session_started_body': 'Session {session_id} was created. Check Stream sessions in a few seconds.',
'wizard.test.async_job_started': 'Async job test started',
'wizard.test.async_job_started_body': 'Async job {job_id} was created. Track status and result from the async jobs view.',
'wizard.test.no_response': 'No test response yet',
@@ -867,7 +874,11 @@ var TRANSLATIONS = {
'wizard.grpc.no_methods': 'No unary RPC methods found.',
'wizard.grpc.fields_unresolved': 'Fields not resolved.',
'wizard.grpc.fields_unresolved_body': 'Import or external types are not expanded client-side.',
'wizard.grpc.services_found': '{services} services · {methods} unary methods found',
'wizard.grpc.services_found': 'Found {services_label} and {methods_label}.',
'wizard.grpc.services_label.one': '{count} service',
'wizard.grpc.services_label.other': '{count} services',
'wizard.grpc.methods_label.one': '{count} unary method',
'wizard.grpc.methods_label.other': '{count} unary methods',
'wizard.grpc.services_discovered': '{services} services · {methods} unary methods discovered',
'wizard.grpc.services_none': 'No unary methods discovered yet',
@@ -938,7 +949,7 @@ var TRANSLATIONS = {
'agents.drawer.ops_no_match': 'No operations match "{query}"',
'agents.drawer.ops_selected': '{count} operations selected',
'agents.drawer.clear_all': 'Clear all',
'agents.drawer.recommendation': "You've selected {count} tools. LLMs perform best with <15 tools per agent. Consider splitting into separate agents by use-case.",
'agents.drawer.recommendation': "You've selected {count} tools. LLMs usually work best when an agent has fewer than 15 tools. Consider splitting this into separate agents by use case.",
'agents.drawer.cancel': 'Cancel',
'agents.drawer.create': 'Create agent',
'agents.drawer.save': 'Save changes',
@@ -989,7 +1000,8 @@ var TRANSLATIONS = {
'login.request_prefix': "Don't have an account?",
'login.request_link': 'Request access',
'login.planned_badge': 'Planned',
'login.password_only': 'This build supports password sign-in only. Password reset, Google SSO and self-service access requests are not wired yet.',
'login.password_only': 'Sign in with email and password.',
'login.coming_soon': 'Password reset and SSO will be added later.',
'login.error.required': 'Please enter your email and password.',
'login.error.invalid': 'Invalid email or password. Please try again.',
'login.error.generic': 'Unable to sign in right now. Please try again.',
@@ -1095,7 +1107,7 @@ var TRANSLATIONS = {
'apikeys.th.created':'СОЗДАН',
'apikeys.th.last': 'ПОСЛЕДНЕЕ ИСПОЛЬЗОВАНИЕ',
'apikeys.callout.title': 'Ключ показывается только один раз.',
'apikeys.callout.body': 'Скопируйте и сохраните его сразу после создания — Crank хранит только хэш. `Последнее использование` обновляется после успешной MCP-аутентификации.',
'apikeys.callout.body': 'Скопируйте и сохраните ключ сразу после создания — Crank хранит только хэш. Поле «Последнее использование» обновляется после успешного MCP-вызова.',
'apikeys.active.title': 'Активные ключи',
'apikeys.active.subtitle': '{active} активных · {revoked} отозванных',
'apikeys.search': 'Фильтр ключей…',
@@ -1154,7 +1166,7 @@ var TRANSLATIONS = {
'secrets.subtitle': 'Зашифрованные upstream-учетные данные для bearer token, basic auth и API key, которые используют auth profiles.',
'secrets.new': 'Создать секрет',
'secrets.callout.title': 'Plaintext секрета доступен только на запись.',
'secrets.callout.body': 'Crank шифрует secret values мастер-ключом воркспейса. После создания или ротации API возвращает только metadata, поэтому эта страница сфокусирована на lifecycle и usage references.',
'secrets.callout.body': 'Crank шифрует значения секретов мастер-ключом воркспейса. После создания или ротации API возвращает только метаданные, поэтому на этой странице доступны управление секретами и просмотр их использования.',
'secrets.active.title': 'Секреты воркспейса',
'secrets.active.subtitle': '{active} активных · {total} всего',
'secrets.search': 'Фильтр секретов…',
@@ -1171,7 +1183,10 @@ var TRANSLATIONS = {
'secrets.status.active': 'Активен',
'secrets.status.disabled': 'Отключен',
'secrets.last_used.never': 'Никогда',
'secrets.used_by_count': '{count} профилей',
'secrets.used_by_count.one': '{count} профиль',
'secrets.used_by_count.few': '{count} профиля',
'secrets.used_by_count.many': '{count} профилей',
'secrets.used_by_count.other': '{count} профиля',
'secrets.loading': 'Загрузка…',
'secrets.empty.none': 'Секретов пока нет',
'secrets.empty.search': 'Нет секретов по текущему поиску',
@@ -1213,19 +1228,22 @@ var TRANSLATIONS = {
'secrets.hint.token': 'Лучший вариант для bearer tokens и простых API key values.',
'secrets.hint.username_password': 'Хранит логин и пароль как один зашифрованный payload.',
'secrets.hint.header': 'Хранит сырое значение заголовка. Имя заголовка задается в auth profile.',
'secrets.hint.generic': 'Хранит произвольный JSON для advanced future integrations.',
'secrets.hint.generic': 'Хранит произвольный JSON для нестандартных интеграций, которым нужен структурированный секрет.',
'secrets.validation.name_required': 'Нужно указать имя секрета.',
'secrets.validation.value_required': 'Нужно указать значение секрета.',
'secrets.validation.username_password_required': 'Нужно указать и логин, и пароль.',
'secrets.validation.json_invalid': 'Generic secret payload должен быть валидным JSON.',
'secrets.profiles.title': 'Ссылки auth profiles',
'secrets.profiles.subtitle': 'Профили резолвят секреты в runtime перед upstream execution.',
'secrets.profiles.subtitle_count': '{count} auth profiles',
'secrets.profiles.subtitle_count.one': '{count} профиль авторизации',
'secrets.profiles.subtitle_count.few': '{count} профиля авторизации',
'secrets.profiles.subtitle_count.many': '{count} профилей авторизации',
'secrets.profiles.subtitle_count.other': '{count} профиля авторизации',
'secrets.profiles.error_title': 'Не удалось загрузить ссылки auth profiles',
'secrets.profiles.loading_title': 'Загрузка ссылок auth profiles…',
'secrets.profiles.loading_body': 'Получаем auth profiles для текущего воркспейса.',
'secrets.profiles.empty_title': 'Auth profiles пока нет',
'secrets.profiles.empty_body': 'В следующем срезе появится UI для привязки секретов к upstream authentication через auth selector.',
'secrets.profiles.empty_body': 'Создайте auth profile в мастере при настройке операции.',
'secrets.profiles.references': 'Связанные секреты',
'secrets.profiles.reference_count': '{count} ссылок',
'secrets.profiles.meta.created': 'Создан',
@@ -1253,7 +1271,7 @@ var TRANSLATIONS = {
'logs.paused': 'Пауза',
'logs.refresh': 'Обновить',
'logs.range.30m': 'Последние 30 мин',
'logs.range.1h': 'Последний 1 час',
'logs.range.1h': 'Последний час',
'logs.range.6h': 'Последние 6 часов',
'logs.range.24h': 'Последние 24 часа',
'logs.range.7d': 'Последние 7 дней',
@@ -1360,7 +1378,7 @@ var TRANSLATIONS = {
'settings.profile.email': 'Email',
'settings.profile.role': 'Роль',
'settings.page.title': 'Настройки аккаунта',
'settings.page.subtitle': 'Управляйте живыми настройками профиля, пароля и воркспейса, доступными в этой сборке. Запланированные возможности остаются видимыми, но не притворяются уже подключенными.',
'settings.page.subtitle': 'Управляйте профилем, паролем и настройками воркспейса.',
'settings.ws.slug_hint': 'Используется в API endpoint-ах и по всей консоли. Только строчные буквы, цифры и дефисы.',
'settings.ws.description_optional': 'Описание (необязательно)',
'settings.ws.description_placeholder': 'Чем занимается этот воркспейс?',
@@ -1397,7 +1415,7 @@ var TRANSLATIONS = {
'settings.session.summary': '{email} · {role} в {workspace}. Используйте «Выйти», чтобы отозвать текущую browser session.',
'settings.notifications.title': 'Настройки уведомлений',
'settings.notifications.not_ready_title': 'Уведомления пока не подключены.',
'settings.notifications.not_ready_body': 'Маршрутизация алертов и настройки на пользователя запланированы, но эта сборка не сохраняет и не доставляет notification settings.',
'settings.notifications.not_ready_body': 'Маршрутизация уведомлений и персональные настройки появятся позже.',
'settings.planned_badge': 'Запланировано',
'settings.notifications.spike_title': 'Всплеск ошибок операции',
'settings.notifications.spike_body': 'Алерт, когда доля ошибок превышает скользящий порог для опубликованного инструмента.',
@@ -1437,7 +1455,10 @@ var TRANSLATIONS = {
'workspace_setup.members.invite_sent': '✓ Приглашение отправлено.',
'workspace_setup.members.you': '(вы)',
'workspace_setup.members.today': 'Сегодня',
'workspace_setup.members.days_ago': '{count} дня назад',
'workspace_setup.members.days_ago.one': '{count} день назад',
'workspace_setup.members.days_ago.few': '{count} дня назад',
'workspace_setup.members.days_ago.many': '{count} дней назад',
'workspace_setup.members.days_ago.other': '{count} дня назад',
'workspace_setup.members.week_ago': '1 неделю назад',
'workspace_setup.members.never': 'Никогда',
'workspace_setup.members.pending': 'Ожидающие приглашения',
@@ -1456,7 +1477,7 @@ var TRANSLATIONS = {
'workspace_setup.actions.save': 'Сохранить изменения',
'workspace_setup.create.title': 'Создать новый воркспейс',
'workspace_setup.create.subtitle': 'Воркспейс — это общее окружение для MCP-операций и агентов вашей команды. У каждого воркспейса свой namespace MCP endpoint-ов.',
'workspace_setup.create.footer': 'Вы станете Owner этого воркспейса. После создания можно пригласить дополнительных участников.',
'workspace_setup.create.footer': 'Вы станете владельцем этого воркспейса. После создания можно пригласить дополнительных участников.',
'workspace_setup.danger.title': 'Опасная зона',
'workspace_setup.danger.export_title': 'Экспортировать все данные',
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и platform API keys.',
@@ -1468,8 +1489,10 @@ var TRANSLATIONS = {
'workspace_setup.role.admin': 'Администратор',
'workspace_setup.role.operator': 'Разработчик',
'workspace_setup.role.viewer': 'Наблюдатель',
'workspace_setup.member_count': '{count} участников',
'workspace_setup.member_count_one': '1 участник',
'workspace_setup.member_count.one': '{count} участник',
'workspace_setup.member_count.few': '{count} участника',
'workspace_setup.member_count.many': '{count} участников',
'workspace_setup.member_count.other': '{count} участника',
'workspace_setup.saving_create': 'Создание…',
'workspace_setup.saving_update': 'Сохранение…',
'workspace_setup.saved': 'Сохранено ✓',
@@ -1527,7 +1550,7 @@ var TRANSLATIONS = {
'wizard.step1.rest_name': 'REST / HTTP',
'wizard.step1.rest_tagline': 'Стандартные HTTP-методы поверх REST endpoint-а. Самый универсальный вариант.',
'wizard.step1.graphql_name': 'GraphQL',
'wizard.step1.graphql_tagline': 'Запросы и мутации к GraphQL-схеме. Поддерживаются query и mutation для одной фиксированной операции.',
'wizard.step1.graphql_tagline': 'Операции по GraphQL-схеме. Поддерживаются Query и Mutation для одной фиксированной операции.',
'wizard.step1.grpc_name': 'gRPC',
'wizard.step1.grpc_tagline': 'Высокопроизводительный бинарный RPC поверх HTTP/2 с unary и bounded streaming flow для низколатентных внутренних сервисов.',
'wizard.step1.websocket_name': 'WebSocket',
@@ -1550,6 +1573,7 @@ var TRANSLATIONS = {
'wizard.step2.change': 'Изменить',
'wizard.step2.auth_none': 'Без авторизации',
'wizard.step2.auth_bearer': 'Bearer token',
'wizard.step2.auth_basic': 'Basic auth',
'wizard.step2.auth_apikey': 'API key',
'wizard.step2.register_new': 'Зарегистрировать новый upstream',
'wizard.step2.name': 'Имя',
@@ -1562,7 +1586,7 @@ var TRANSLATIONS = {
'wizard.step2.auth_mode.existing': 'Использовать существующий auth profile',
'wizard.step2.auth_mode.create': 'Создать auth profile сейчас',
'wizard.step2.auth_profile': 'Auth profile',
'wizard.step2.auth_profile_hint': 'Выбранный профиль будет резолвиться в runtime и подключаться через `execution_config.auth_profile_ref`.',
'wizard.step2.auth_profile_hint': 'Выбранный профиль применяется автоматически при каждом вызове инструмента.',
'wizard.step2.auth_profile_empty': 'Auth profiles пока нет. Создайте его ниже или на странице Secrets.',
'wizard.step2.create_profile_title': 'Создать auth profile',
'wizard.step2.create_profile_subtitle': 'Создайте переиспользуемый профиль на базе зашифрованных workspace secrets.',
@@ -1635,7 +1659,10 @@ var TRANSLATIONS = {
'wizard.step3.grpc.paste_content': 'Вставить содержимое',
'wizard.step3.grpc.parse_proto': 'Разобрать proto',
'wizard.step3.grpc.streaming_excluded': 'Стриминговые методы исключены',
'wizard.step3.grpc.streaming_hidden': 'Найдено и скрыто streaming method(s): {count} — они несовместимы с MCP tool calls.',
'wizard.step3.grpc.streaming_hidden.one': 'Найден и скрыт {count} streaming method — он не поддерживается в MCP tool calls.',
'wizard.step3.grpc.streaming_hidden.few': 'Найдены и скрыты {count} streaming methods — они не поддерживаются в MCP tool calls.',
'wizard.step3.grpc.streaming_hidden.many': 'Найдено и скрыто {count} streaming methods — они не поддерживаются в MCP tool calls.',
'wizard.step3.grpc.streaming_hidden.other': 'Найдены и скрыты {count} streaming methods — они не поддерживаются в MCP tool calls.',
'wizard.step3.grpc.select_method': 'Выберите метод',
'wizard.step3.grpc.discovery_live_title': 'Поиск через descriptor set',
'wizard.step3.grpc.discovery_live_subtitle': 'Используйте сохраненный черновик и descriptor set, чтобы открыть сервисы через backend',
@@ -1822,9 +1849,12 @@ var TRANSLATIONS = {
'wizard.test.completed_body': 'Текущий черновик успешно выполнился против upstream.',
'wizard.test.failed_body': 'Черновик выполнился, но backend вернул ошибки валидации или runtime.',
'wizard.test.window_completed': 'Оконный тест завершен',
'wizard.test.window_completed_body': 'Окно завершено: {window_complete}. Усечено: {truncated}. Есть продолжение: {has_more}.',
'wizard.test.window_completed_body': 'Собран ограниченный результат окна.',
'wizard.test.window_truncated_note': 'Результат был усечен.',
'wizard.test.window_more_note': 'Доступно продолжение, если перейти к streaming-представлениям.',
'wizard.test.window_incomplete_note': 'Upstream-поток завершился раньше, чем удалось собрать всё окно.',
'wizard.test.session_started': 'Session-тест запущен',
'wizard.test.session_started_body': 'Создана session {session_id}. Следующий poll можно делать через {poll_after_ms} мс из представления stream sessions.',
'wizard.test.session_started_body': 'Сессия {session_id} создана. Проверьте статус в разделе Stream sessions через несколько секунд.',
'wizard.test.async_job_started': 'Async job тест запущен',
'wizard.test.async_job_started_body': 'Создан async job {job_id}. Отслеживайте статус и результат через представление async jobs.',
'wizard.test.no_response': 'Тестового ответа пока нет',
@@ -1857,7 +1887,15 @@ var TRANSLATIONS = {
'wizard.grpc.no_methods': 'Unary RPC methods не найдены.',
'wizard.grpc.fields_unresolved': 'Поля не разрешены.',
'wizard.grpc.fields_unresolved_body': 'Import-ы или внешние типы не разворачиваются на стороне клиента.',
'wizard.grpc.services_found': 'Сервисов: {services} · unary методов: {methods}',
'wizard.grpc.services_found': 'Найдено {services_label} и {methods_label}.',
'wizard.grpc.services_label.one': '{count} сервис',
'wizard.grpc.services_label.few': '{count} сервиса',
'wizard.grpc.services_label.many': '{count} сервисов',
'wizard.grpc.services_label.other': '{count} сервиса',
'wizard.grpc.methods_label.one': '{count} unary-метод',
'wizard.grpc.methods_label.few': '{count} unary-метода',
'wizard.grpc.methods_label.many': '{count} unary-методов',
'wizard.grpc.methods_label.other': '{count} unary-метода',
'wizard.grpc.services_discovered': 'Сервисов открыто: {services} · unary методов: {methods}',
'wizard.grpc.services_none': 'Unary methods пока не открыты',
@@ -1979,7 +2017,8 @@ var TRANSLATIONS = {
'login.request_prefix': 'Нет аккаунта?',
'login.request_link': 'Запросить доступ',
'login.planned_badge': 'Запланировано',
'login.password_only': 'В этой сборке доступен только вход по паролю. Сброс пароля, Google SSO и самостоятельный запрос доступа пока не подключены.',
'login.password_only': 'Войдите с помощью email и пароля.',
'login.coming_soon': 'Сброс пароля и SSO появятся позже.',
'login.error.required': 'Введите email и пароль.',
'login.error.invalid': 'Неверный email или пароль. Попробуйте еще раз.',
'login.error.generic': 'Сейчас не удается войти. Попробуйте еще раз.',
@@ -2232,6 +2271,33 @@ function tf(key, vars) {
}, t(key));
}
function pluralForm(lang, count) {
if (typeof Intl !== 'undefined' && typeof Intl.PluralRules === 'function') {
return new Intl.PluralRules(lang).select(Number(count) || 0);
}
return Number(count) === 1 ? 'one' : 'other';
}
function tPlural(key, count, vars) {
var lang = localStorage.getItem('crank_lang') || 'en';
var tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
var form = pluralForm(lang, count);
var fallbackForm = pluralForm('en', count);
var resolvedKey = tr[key + '.' + form] !== undefined
? key + '.' + form
: tr[key + '.other'] !== undefined
? key + '.other'
: TRANSLATIONS.en[key + '.' + fallbackForm] !== undefined
? key + '.' + fallbackForm
: TRANSLATIONS.en[key + '.other'] !== undefined
? key + '.other'
: key;
var payload = Object.assign({ count: count }, vars || {});
return Object.keys(payload).reduce(function(result, name) {
return result.replaceAll('{' + name + '}', payload[name]);
}, t(resolvedKey));
}
function hasTranslation(key) {
return t(key) !== key;
}
@@ -2289,6 +2355,7 @@ function localizeDemoOperation(operation) {
window.localizeDemoAgent = localizeDemoAgent;
window.localizeDemoOperation = localizeDemoOperation;
window.tPlural = tPlural;
function applyLang() {
// text content
+6 -4
View File
@@ -265,7 +265,7 @@ document.addEventListener('DOMContentLoaded', function () {
'<td>' + secretKindLabel(secret.kind) + '</td>',
'<td class="col-mono">v' + secret.current_version + '</td>',
'<td>' + (secret.last_used_at ? formatDate(secret.last_used_at) : tKey('secrets.last_used.never')) + '</td>',
'<td>' + tfKey('secrets.used_by_count', { count: references.length }) + '</td>',
'<td>' + window.tPlural('secrets.used_by_count', references.length, { count: references.length }) + '</td>',
'<td><span class="badge ' + (secret.status === 'disabled' ? 'badge-revoked' : 'badge-active') + '">' + tKey('secrets.status.' + secret.status) + '</span></td>',
'<td class="col-actions"></td>'
].join('');
@@ -298,9 +298,11 @@ document.addEventListener('DOMContentLoaded', function () {
secretsById[secret.id] = secret;
});
profilesSummary.textContent = tfKey('secrets.profiles.subtitle_count', {
count: state.profiles.length,
});
profilesSummary.textContent = window.tPlural(
'secrets.profiles.subtitle_count',
state.profiles.length,
{ count: state.profiles.length }
);
if (state.error) {
profilesList.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + tKey('secrets.profiles.error_title') + '</div><div class="empty-state-text">' + state.error + '</div></div>';
+27 -9
View File
@@ -1064,9 +1064,15 @@ function renderServiceResults(parsed, ids) {
var totalMethods = parsed.services.reduce(function(n, s) { return n + s.methods.length; }, 0);
var summaryEl = document.getElementById(ids.summary);
if (summaryEl) {
var servicesLabel = window.tPlural('wizard.grpc.services_label', parsed.services.length, {
count: parsed.services.length,
});
var methodsLabel = window.tPlural('wizard.grpc.methods_label', totalMethods, {
count: totalMethods,
});
summaryEl.textContent = tfKey('wizard.grpc.services_found', {
services: parsed.services.length,
methods: totalMethods,
services_label: servicesLabel,
methods_label: methodsLabel,
});
}
@@ -1109,7 +1115,9 @@ function renderServiceResults(parsed, ids) {
function renderParsedProto(parsed) {
var notice = document.getElementById('proto-streaming-text');
if (notice && parsed && parsed.streamingCount) {
notice.textContent = tfKey('wizard.step3.grpc.streaming_hidden', { count: parsed.streamingCount });
notice.textContent = window.tPlural('wizard.step3.grpc.streaming_hidden', parsed.streamingCount, {
count: parsed.streamingCount,
});
}
renderServiceResults(parsed, {
notice: 'proto-streaming-notice', count: 'proto-streaming-count',
@@ -1120,7 +1128,9 @@ function renderParsedProto(parsed) {
function renderReflectionResult(parsed) {
var reflectText = document.querySelector('#reflect-streaming-notice .info-callout-text');
if (reflectText && parsed && parsed.streamingCount) {
reflectText.textContent = tfKey('wizard.step3.grpc.streaming_hidden', { count: parsed.streamingCount });
reflectText.textContent = window.tPlural('wizard.step3.grpc.streaming_hidden', parsed.streamingCount, {
count: parsed.streamingCount,
});
}
renderServiceResults(parsed, {
notice: 'reflect-streaming-notice', count: 'reflect-streaming-count',
@@ -2572,13 +2582,21 @@ function describeWizardTestResult(result) {
}
if (result.window) {
var bodyParts = [
tKey('wizard.test.window_completed_body')
];
if (result.window.truncated) {
bodyParts.push(tKey('wizard.test.window_truncated_note'));
}
if (result.window.has_more) {
bodyParts.push(tKey('wizard.test.window_more_note'));
}
if (!result.window.window_complete) {
bodyParts.push(tKey('wizard.test.window_incomplete_note'));
}
return {
title: result.ok ? tKey('wizard.test.window_completed') : tKey('wizard.test.failed'),
body: tfKey('wizard.test.window_completed_body', {
truncated: result.window.truncated ? tKey('wizard.boolean.yes') : tKey('wizard.boolean.no'),
has_more: result.window.has_more ? tKey('wizard.boolean.yes') : tKey('wizard.boolean.no'),
window_complete: result.window.window_complete ? tKey('wizard.boolean.yes') : tKey('wizard.boolean.no')
}),
body: bodyParts.join(' '),
isError: !result.ok,
};
}
+5 -3
View File
@@ -149,9 +149,11 @@ function renderMembers() {
workspaceFormState.memberships.forEach(function (membership) {
addMemberRow(container, membership);
});
label.textContent = workspaceFormState.memberships.length === 1
? tKey('workspace_setup.member_count_one')
: tfKey('workspace_setup.member_count', { count: workspaceFormState.memberships.length });
label.textContent = window.tPlural(
'workspace_setup.member_count',
workspaceFormState.memberships.length,
{ count: workspaceFormState.memberships.length }
);
}
function renderInvitations() {