feat: localize settings workspace and wizard

This commit is contained in:
a.tolmachev
2026-04-03 00:03:40 +03:00
parent 581d50c33c
commit 140548bb20
16 changed files with 763 additions and 247 deletions
+37 -37
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>
</button>
<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 class="ws-dropdown-footer">
<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>
Create workspace
<span data-i18n="nav.create_workspace">Create workspace</span>
</button>
</div>
</div>
@@ -58,7 +58,7 @@
<svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg>
<span>⌘K</span>
</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>
</button>
<div class="nav-divider"></div>
@@ -95,8 +95,8 @@
<div class="page-header" style="margin-bottom: 24px;">
<div class="page-header-text">
<h1 class="page-title">Account settings</h1>
<p class="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.</p>
<h1 class="page-title" data-i18n="settings.page.title">Account settings</h1>
<p class="page-subtitle" data-i18n="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.</p>
</div>
</div>
@@ -108,7 +108,7 @@
<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 5.5h12M4 2.5h8a1.5 1.5 0 011.5 1.5v8A1.5 1.5 0 0112 13.5H4A1.5 1.5 0 012.5 12V4A1.5 1.5 0 014 2.5z"/>
</svg>
<span>Workspace</span>
<span data-i18n="settings.nav.workspace">Workspace</span>
</button>
<button class="settings-nav-item active" data-section="profile">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round">
@@ -147,15 +147,15 @@
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.name">Workspace name</label>
<input class="field-input" id="settings-ws-slug" type="text" value="acme-workspace" autocomplete="off">
<div class="field-hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
<div class="field-hint" data-i18n="settings.ws.slug_hint">Used in API endpoints and across the console. Only lowercase letters, numbers and hyphens.</div>
</div>
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.display">Display name</label>
<input class="field-input" id="settings-ws-display-name" type="text" value="Acme Corp" autocomplete="off">
</div>
<div class="field-group">
<label class="field-label">Description <span style="font-size:11px;color:var(--text-muted);">(optional)</span></label>
<textarea class="field-textarea" id="settings-ws-description" rows="2" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
<label class="field-label"><span data-i18n="settings.ws.description_optional">Description (optional)</span></label>
<textarea class="field-textarea" id="settings-ws-description" rows="2" data-i18n-ph="settings.ws.description_placeholder" placeholder="What does this workspace do?">Primary production workspace for CRM and e-commerce API operations.</textarea>
</div>
<div class="field-row">
<div class="field-group">
@@ -172,7 +172,7 @@
<div class="field-group">
<label class="field-label" data-i18n="settings.ws.protocol">Default protocol</label>
<select class="field-select" id="settings-ws-protocol">
<option value="rest" selected>REST / HTTP</option>
<option value="rest" selected>REST / HTTP</option>
<option value="graphql">GraphQL</option>
<option value="grpc">gRPC</option>
</select>
@@ -215,31 +215,31 @@
<div class="avatar-upload-actions">
<div class="avatar-name" id="profile-display-name">Operator</div>
<div class="avatar-sub" id="profile-email">operator@acme-workspace</div>
<div class="field-hint">Your avatar is generated from your display name and email.</div>
<div class="field-hint" data-i18n="settings.profile.avatar_hint">Your avatar is generated from your display name and email.</div>
</div>
</div>
<div class="field-hint" style="margin-bottom:16px;">Your name and email are stored on the backend and used across the console and session identity.</div>
<div class="field-hint" style="margin-bottom:16px;" data-i18n="settings.profile.backend_hint">Your name and email are stored on the backend and used across the console and session identity.</div>
<div class="field-row">
<div class="field-group">
<label class="field-label">First name</label>
<label class="field-label" data-i18n="settings.profile.first_name">First name</label>
<input class="field-input" id="field-firstname" type="text" value="Operator" autocomplete="given-name">
</div>
<div class="field-group">
<label class="field-label">Last name</label>
<input class="field-input" id="field-lastname" type="text" value="" autocomplete="family-name" placeholder="Optional">
<label class="field-label" data-i18n="settings.profile.last_name">Last name</label>
<input class="field-input" id="field-lastname" type="text" value="" autocomplete="family-name" data-i18n-ph="settings.profile.last_name_placeholder" placeholder="Optional">
</div>
</div>
<div class="field-group">
<label class="field-label">Email address</label>
<label class="field-label" data-i18n="settings.profile.email_label">Email address</label>
<input class="field-input" id="field-email" type="email" value="operator@acme.com" autocomplete="email">
<div class="field-hint">Changing email updates the login identity for the current account.</div>
<div class="field-hint" data-i18n="settings.profile.email_hint">Changing email updates the login identity for the current account.</div>
</div>
<div class="field-hint" id="settings-profile-status" style="margin-bottom:12px;"></div>
<div style="display:flex;justify-content:flex-end;">
<button class="btn-primary" id="settings-profile-save-btn" type="button">Save profile</button>
<button class="btn-primary" id="settings-profile-save-btn" type="button" data-i18n="settings.profile.save">Save profile</button>
</div>
</div>
</div>
@@ -249,38 +249,38 @@
<div id="section-security" class="section-anchor">
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Security</div>
<div class="section-card-title" data-i18n="settings.security.title">Security</div>
</div>
<div class="section-card-body" style="padding-bottom:8px;">
<div class="field-group">
<label class="field-label">Current password</label>
<label class="field-label" data-i18n="settings.security.current_password">Current password</label>
<input class="field-input" id="security-current-password" type="password" placeholder="••••••••" autocomplete="current-password">
</div>
<div class="field-row">
<div class="field-group">
<label class="field-label">New password</label>
<input class="field-input" id="security-new-password" type="password" placeholder="min. 12 characters" autocomplete="new-password">
<label class="field-label" data-i18n="settings.security.new_password">New password</label>
<input class="field-input" id="security-new-password" type="password" data-i18n-ph="settings.security.new_password_placeholder" placeholder="min. 12 characters" autocomplete="new-password">
</div>
<div class="field-group">
<label class="field-label">Confirm new password</label>
<label class="field-label" data-i18n="settings.security.confirm_password">Confirm new password</label>
<input class="field-input" id="security-confirm-password" type="password" placeholder="••••••••" autocomplete="new-password">
</div>
</div>
<div class="field-hint" id="settings-password-status" style="margin-bottom:12px;"></div>
<div style="display:flex;justify-content:flex-end;padding-bottom:8px;">
<button class="btn-primary" id="settings-password-save-btn" type="button">Change password</button>
<button class="btn-primary" id="settings-password-save-btn" type="button" data-i18n="settings.security.change_password">Change password</button>
</div>
</div>
<div style="padding: 4px 20px 16px;">
<div class="settings-section-divider" style="margin-top:0;"></div>
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;">Planned security capabilities</div>
<div style="font-size:13.5px;font-weight:600;color:var(--text-primary);margin-bottom:12px;" data-i18n="settings.security.capabilities_title">Planned security capabilities</div>
<div class="callout warning" style="margin-bottom:0;">
<svg class="callout-icon" width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="var(--amber)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<polygon points="8,1.5 15.5,14.5 0.5,14.5" fill="none"/><path d="M8 6v4M8 11.5v.5"/>
</svg>
<div>
<strong>Not available in this build.</strong> Two-factor authentication, passkeys and multi-session inventory are planned, but the current live flow is password-based login with one HttpOnly browser session.
<strong data-i18n="settings.security.not_available">Not available in this build.</strong> <span data-i18n="settings.security.capabilities_body">Two-factor authentication, passkeys and multi-session inventory are planned, but the current live flow is password-based login with one HttpOnly browser session.</span>
</div>
</div>
</div>
@@ -288,13 +288,13 @@
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Current session</div>
<div class="section-card-title" data-i18n="settings.session.title">Current session</div>
</div>
<div style="padding: 8px 20px 16px;">
<div class="member-row">
<div class="member-info">
<div class="member-name">Browser session — <span style="color:var(--green);font-size:12px;">current</span></div>
<div class="member-email" id="settings-session-summary">Loading current session details…</div>
<div class="member-name">Browser session — <span style="color:var(--green);font-size:12px;" data-i18n="settings.session.current">current</span></div>
<div class="member-email" id="settings-session-summary" data-i18n="settings.session.loading">Loading current session details…</div>
</div>
</div>
</div>
@@ -305,7 +305,7 @@
<div id="section-notifications" class="section-anchor">
<div class="section-card" style="margin-bottom: 20px;">
<div class="section-card-header">
<div class="section-card-title">Notification preferences</div>
<div class="section-card-title" data-i18n="settings.notifications.title">Notification preferences</div>
</div>
<div style="padding: 8px 20px 16px;">
<div class="callout info" style="margin-bottom:14px;">
@@ -314,21 +314,21 @@
<path d="M8 11V8M8 5.5V5"/>
</svg>
<div>
<strong>Notifications are not wired yet.</strong> Alert routing and per-user preferences are planned, but this build does not persist or deliver notification settings.
<strong data-i18n="settings.notifications.not_ready_title">Notifications are not wired yet.</strong> <span data-i18n="settings.notifications.not_ready_body">Alert routing and per-user preferences are planned, but this build does not persist or deliver notification settings.</span>
</div>
</div>
<div class="planned-list">
<div class="planned-item">
<div class="planned-item-title">Operation error spike</div>
<div class="planned-item-desc">Alert when error rate exceeds a rolling threshold for a published tool.</div>
<div class="planned-item-title" data-i18n="settings.notifications.spike_title">Operation error spike</div>
<div class="planned-item-desc" data-i18n="settings.notifications.spike_body">Alert when error rate exceeds a rolling threshold for a published tool.</div>
</div>
<div class="planned-item">
<div class="planned-item-title">Upstream latency degradation</div>
<div class="planned-item-desc">Notify operators when p99 latency rises sharply above the baseline.</div>
<div class="planned-item-title" data-i18n="settings.notifications.latency_title">Upstream latency degradation</div>
<div class="planned-item-desc" data-i18n="settings.notifications.latency_body">Notify operators when p99 latency rises sharply above the baseline.</div>
</div>
<div class="planned-item">
<div class="planned-item-title">Quota and usage digests</div>
<div class="planned-item-desc">Deliver periodic summaries for quota consumption, errors and workspace activity.</div>
<div class="planned-item-title" data-i18n="settings.notifications.digest_title">Quota and usage digests</div>
<div class="planned-item-desc" data-i18n="settings.notifications.digest_body">Deliver periodic summaries for quota consumption, errors and workspace activity.</div>
</div>
</div>
</div>
+24 -24
View File
@@ -35,12 +35,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>
</button>
<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 class="ws-dropdown-footer">
<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>
Create workspace
<span data-i18n="nav.create_workspace">Create workspace</span>
</button>
</div>
</div>
@@ -62,7 +62,7 @@
<span>⌘K</span>
</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>
</button>
@@ -102,17 +102,17 @@
<!-- ══ TOP PROGRESS STRIP ══ -->
<div class="progress-strip">
<button class="back-to-catalog-btn" id="back-to-catalog" title="Back to catalog">
<button class="back-to-catalog-btn" id="back-to-catalog" data-i18n-title="wizard.back_catalog" title="Back to catalog">
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor"><path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/></svg>
Operations
<span data-i18n="wizard.back_catalog">Operations</span>
</button>
<div class="progress-divider"></div>
<span class="progress-label">Create operation</span>
<span class="progress-label" data-i18n="wizard.progress.create">Create operation</span>
<div class="progress-bar-wrap">
<div class="progress-bar-fill"></div>
</div>
<span class="progress-pct">20%</span>
<button class="progress-close" title="Exit wizard" aria-label="Exit wizard">
<button class="progress-close" data-i18n-title="wizard.exit" title="Exit wizard" aria-label="Exit wizard">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round">
<line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/>
</svg>
@@ -128,13 +128,13 @@
<div class="step-sidebar-header">
<div class="step-sidebar-logo-mark">M</div>
<div class="step-sidebar-brand">
<div class="step-sidebar-brand" data-i18n="wizard.sidebar.brand.create">
Create <span>operation</span>
</div>
</div>
<div class="step-sidebar-body">
<div class="step-sidebar-section-label">Progress</div>
<div class="step-sidebar-section-label" data-i18n="wizard.progress.section">Progress</div>
<div class="steps-list">
@@ -142,8 +142,8 @@
<div class="step-indicator">1</div>
<div class="step-content">
<div class="step-number">Step 1</div>
<div class="step-name">Protocol</div>
<div class="step-status-text">In progress</div>
<div class="step-name" data-i18n="wizard.step_name.protocol">Protocol</div>
<div class="step-status-text" data-i18n="wizard.status.in_progress">In progress</div>
</div>
</div>
@@ -151,8 +151,8 @@
<div class="step-indicator">2</div>
<div class="step-content">
<div class="step-number">Step 2</div>
<div class="step-name">Upstream target</div>
<div class="step-status-text">Not started</div>
<div class="step-name" data-i18n="wizard.step_name.upstream">Upstream target</div>
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
</div>
</div>
@@ -161,7 +161,7 @@
<div class="step-content">
<div class="step-number">Step 3</div>
<div class="step-name" id="sidebar-step-3-name">Request config</div>
<div class="step-status-text">Not started</div>
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
</div>
</div>
@@ -169,8 +169,8 @@
<div class="step-indicator">4</div>
<div class="step-content">
<div class="step-number">Step 4</div>
<div class="step-name">Tool config</div>
<div class="step-status-text">Not started</div>
<div class="step-name" data-i18n="wizard.step_name.tool">Tool config</div>
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
</div>
</div>
@@ -178,8 +178,8 @@
<div class="step-indicator">5</div>
<div class="step-content">
<div class="step-number">Step 5</div>
<div class="step-name">Mapping</div>
<div class="step-status-text">Not started</div>
<div class="step-name" data-i18n="wizard.step_name.mapping">Mapping</div>
<div class="step-status-text" data-i18n="wizard.status.not_started">Not started</div>
</div>
</div>
@@ -191,11 +191,11 @@
<circle cx="8" cy="8" r="7"/>
<path d="M8 12v-4M8 5v-.5"/>
</svg>
Need help?
<span data-i18n="wizard.help.title">Need help?</span>
</div>
<div class="sidebar-help-text">
Unsure which protocol to choose? Read the
<a href="#" class="sidebar-help-link">protocol comparison guide →</a>
<span data-i18n="wizard.help.body">Unsure which protocol to choose? Read the</span>
<a href="#" class="sidebar-help-link" data-i18n="wizard.help.link">protocol comparison guide →</a>
</div>
</div>
</div><!-- /step-sidebar-body -->
@@ -214,17 +214,17 @@
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path d="M9.78 12.78a.75.75 0 01-1.06 0L4.47 8.53a.75.75 0 010-1.06l4.25-4.25a.75.75 0 011.06 1.06L6.06 8l3.72 3.72a.75.75 0 010 1.06z"/>
</svg>
Back
<span data-i18n="wizard.button.back">Back</span>
</button>
<button class="btn-save-draft" type="button">Save draft</button>
<button class="btn-save-draft" type="button" data-i18n="wizard.button.save_draft">Save draft</button>
<div class="action-bar-spacer"></div>
<span class="step-counter">Step <strong>1</strong> of 5</span>
<button id="btn-continue" class="btn btn-continue" type="button">
Continue
<span data-i18n="wizard.button.continue">Continue</span>
<svg width="13" height="13" viewBox="0 0 16 16" fill="currentColor">
<path d="M6.22 3.22a.75.75 0 011.06 0l4.25 4.25a.75.75 0 010 1.06l-4.25 4.25a.75.75 0 01-1.06-1.06L9.94 8 6.22 4.28a.75.75 0 010-1.06z"/>
</svg>
+5 -5
View File
@@ -3,10 +3,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 1 of 5
<span data-i18n-html="wizard.step_of">Step 1 of 5</span>
</div>
<h1 class="step-panel-title">Choose a protocol</h1>
<p class="step-panel-subtitle">Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.</p>
<h1 class="step-panel-title" data-i18n="wizard.step1.title">Choose a protocol</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step1.subtitle">Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.</p>
</div>
<!-- Protocol selection cards -->
@@ -85,9 +85,9 @@
<path d="M8 6v4M8 11.5v.5"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">You can change this later</div>
<div class="info-callout-title" data-i18n="wizard.step1.tip_title">You can change this later</div>
<div class="info-callout-text">
Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See <a href="#">protocol migration guide</a> for details.
<span data-i18n="wizard.step1.tip_body">Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details.</span>
</div>
</div>
</div>
+3 -3
View File
@@ -3,10 +3,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 2 of 5
<span data-i18n-html="wizard.step_of">Step 2 of 5</span>
</div>
<h1 class="step-panel-title">Select upstream &amp; endpoint</h1>
<p class="step-panel-subtitle">Choose an existing upstream — a shared host with its auth config — or register a new one. Then define the specific endpoint path this operation will call.</p>
<h1 class="step-panel-title" data-i18n="wizard.step2.title">Select upstream &amp; endpoint</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step2.subtitle">Choose an existing upstream — a shared host with its auth config — or register a new one. Then define the specific endpoint path this operation will call.</p>
</div>
<!-- ── Upstream selector ── -->
+3 -3
View File
@@ -2,10 +2,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 3 of 5
<span data-i18n-html="wizard.step_of">Step 3 of 5</span>
</div>
<h1 class="step-panel-title">GraphQL operation</h1>
<p class="step-panel-subtitle">Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.</p>
<h1 class="step-panel-title" data-i18n="wizard.step3.graphql.title">GraphQL operation</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step3.graphql.subtitle">Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.</p>
</div>
<!-- Operation type -->
+3 -3
View File
@@ -2,10 +2,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 3 of 5
<span data-i18n-html="wizard.step_of">Step 3 of 5</span>
</div>
<h1 class="step-panel-title">RPC method</h1>
<p class="step-panel-subtitle">Choose how to describe the gRPC service contract. Upload a <code style="font-family:monospace;font-size:13px;color:var(--accent);">.proto</code> file, discover methods from a live server via reflection, or enter the route manually. Only unary RPCs are supported — streaming connections cannot be held open during MCP tool invocations.</p>
<h1 class="step-panel-title" data-i18n="wizard.step3.grpc.title">RPC method</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step3.grpc.subtitle">Choose how to describe the gRPC service contract. Upload a .proto file, discover methods from a live server via reflection, or enter the route manually. Only unary RPCs are supported.</p>
</div>
<!-- ── Source selector ── -->
+3 -3
View File
@@ -3,10 +3,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 3 of 5
<span data-i18n-html="wizard.step_of">Step 3 of 5</span>
</div>
<h1 class="step-panel-title">HTTP method &amp; format</h1>
<p class="step-panel-subtitle">Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.</p>
<h1 class="step-panel-title" data-i18n="wizard.step3.rest.title">HTTP method &amp; format</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step3.rest.subtitle">Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.</p>
</div>
<!-- HTTP method picker -->
+3 -3
View File
@@ -2,10 +2,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 4 of 5
<span data-i18n-html="wizard.step_of">Step 4 of 5</span>
</div>
<h1 class="step-panel-title">Tool config</h1>
<p class="step-panel-subtitle">Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.</p>
<h1 class="step-panel-title" data-i18n="wizard.step4.title">Tool config</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step4.subtitle">Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.</p>
</div>
<div class="config-card" style="margin-bottom: 20px;">
+3 -3
View File
@@ -2,10 +2,10 @@
<div class="step-panel-header">
<div class="step-panel-eyebrow">
<div class="step-panel-eyebrow-line"></div>
Step 5 of 5
<span data-i18n-html="wizard.step_of">Step 5 of 5</span>
</div>
<h1 class="step-panel-title">Mapping and execution</h1>
<p class="step-panel-subtitle">Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.</p>
<h1 class="step-panel-title" data-i18n="wizard.step5.title">Mapping and execution</h1>
<p class="step-panel-subtitle" data-i18n="wizard.step5.subtitle">Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.</p>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
+48 -48
View File
@@ -31,7 +31,7 @@
</a>
<a href="javascript:history.back()" class="ws-setup-back">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M10 4l-4 4 4 4"/></svg>
Back
<span data-i18n="workspace_setup.back">Back</span>
</a>
</div>
@@ -40,18 +40,18 @@
<div class="ws-setup-container">
<div class="ws-setup-header">
<div class="ws-setup-title" id="page-title">Workspace settings</div>
<div class="ws-setup-subtitle" id="page-subtitle">Configure your workspace identity, default settings, and team members.</div>
<div class="ws-setup-title" id="page-title" data-i18n="workspace_setup.title">Workspace settings</div>
<div class="ws-setup-subtitle" id="page-subtitle" data-i18n="workspace_setup.page_subtitle">Configure your workspace identity, default settings, and team members.</div>
</div>
<!-- ── Identity ── -->
<div class="ws-form-section">
<div class="ws-form-section-title">Workspace identity</div>
<div class="ws-form-section-title" data-i18n="workspace_setup.identity.title">Workspace identity</div>
<div class="ws-avatar-row">
<div class="ws-avatar-preview" id="ws-avatar-preview">A</div>
<div>
<div class="ws-avatar-hint">Avatar is derived from your workspace name.</div>
<div class="ws-avatar-hint" data-i18n="workspace_setup.identity.avatar_hint">Avatar is derived from your workspace name.</div>
<div class="ws-color-swatches">
<div class="ws-color-swatch active" style="background:#0d9488;" data-color="#0d9488" onclick="pickColor(this)" title="Teal"></div>
<div class="ws-color-swatch" style="background:#7c3aed;" data-color="#7c3aed" onclick="pickColor(this)" title="Purple"></div>
@@ -65,41 +65,41 @@
</div>
<div class="form-group" style="margin-bottom:14px;">
<label class="form-label">Workspace name <span class="form-label-required">required</span></label>
<label class="form-label"><span data-i18n="workspace_setup.name">Workspace name</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<input class="form-input" id="ws-name" type="text" placeholder="Acme Inc" autocomplete="off" oninput="onWsNameInput(this.value)">
</div>
<div class="form-group" style="margin-bottom:14px;">
<label class="form-label">Slug <span class="form-label-required">required</span></label>
<label class="form-label"><span data-i18n="workspace_setup.slug">Slug</span> <span class="form-label-required" data-i18n="workspace_setup.required">required</span></label>
<div style="position:relative;">
<span style="position:absolute;left:12px;top:50%;transform:translateY(-50%);font-size:13px;color:var(--text-muted);font-family:monospace;pointer-events:none;">mcp.crank.io/</span>
<input class="form-input input-mono" id="ws-slug" type="text" placeholder="acme-inc" autocomplete="off" style="padding-left: 112px;" oninput="onWsSlugInput(this.value)">
</div>
<div class="form-hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
<div class="form-hint" data-i18n="workspace_setup.slug_hint">Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.</div>
</div>
<div class="form-group">
<label class="form-label">Description <span class="form-label-optional">(optional)</span></label>
<textarea class="form-textarea" id="ws-desc" rows="2" placeholder="What does this workspace do?"></textarea>
<label class="form-label"><span data-i18n="workspace_setup.description">Description</span> <span class="form-label-optional" data-i18n="workspace_setup.optional">(optional)</span></label>
<textarea class="form-textarea" id="ws-desc" rows="2" data-i18n-ph="workspace_setup.description_placeholder" placeholder="What does this workspace do?"></textarea>
</div>
</div>
<!-- ── Default settings ── -->
<div class="ws-form-section">
<div class="ws-form-section-title">Default settings</div>
<div class="ws-form-section-title" data-i18n="workspace_setup.defaults.title">Default settings</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Default protocol</label>
<label class="form-label" data-i18n="workspace_setup.protocol">Default protocol</label>
<select class="form-input" id="ws-protocol">
<option value="rest" selected>REST</option>
<option value="graphql">GraphQL</option>
<option value="grpc">gRPC</option>
</select>
<div class="form-hint">Pre-selected in the operation wizard.</div>
<div class="form-hint" data-i18n="workspace_setup.protocol_hint">Pre-selected in the operation wizard.</div>
</div>
<div class="form-group">
<label class="form-label">Timezone</label>
<label class="form-label" data-i18n="workspace_setup.timezone">Timezone</label>
<select class="form-input" id="ws-timezone">
<option value="UTC" selected>UTC</option>
<option value="America/New_York">Eastern Time</option>
@@ -114,7 +114,7 @@
</div>
<div class="form-group" style="margin-top:4px;margin-bottom:0;">
<label class="form-label">Interface language</label>
<label class="form-label" data-i18n="workspace_setup.language">Interface language</label>
<div class="lang-switcher">
<button class="lang-btn active" data-lang="en" onclick="setLang('en')">
<span class="lang-flag">🇬🇧</span>
@@ -132,12 +132,12 @@
<div id="section-members" class="ws-form-section" style="display:none; padding:0; overflow:hidden;">
<div style="display:flex;align-items:center;justify-content:space-between;padding:20px 24px 16px;">
<div>
<div class="ws-form-section-title" style="margin-bottom:2px;">Team members</div>
<div class="ws-form-section-title" style="margin-bottom:2px;" data-i18n="workspace_setup.members.title">Team members</div>
<div style="font-size:12px;color:var(--text-muted);" id="members-count-label">4 members</div>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="toggleInviteForm()">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor" style="margin-right:4px;"><path d="M7.75 2a.75.75 0 01.75.75V7h4.25a.75.75 0 010 1.5H8.5v4.25a.75.75 0 01-1.5 0V8.5H2.75a.75.75 0 010-1.5H7V2.75A.75.75 0 017.75 2z"/></svg>
Invite member
<span data-i18n="workspace_setup.members.invite">Invite member</span>
</button>
</div>
@@ -145,21 +145,21 @@
<div id="invite-form" style="display:none; padding: 0 24px 16px; border-bottom: 1px solid var(--border-subtle);">
<div style="display:flex; gap:10px; align-items:flex-end; flex-wrap:wrap;">
<div class="form-group" style="flex:1; min-width:200px; margin-bottom:0;">
<label class="form-label">Email address</label>
<label class="form-label" data-i18n="workspace_setup.members.email">Email address</label>
<input class="form-input" id="invite-email" type="email" placeholder="colleague@company.com" autocomplete="off">
</div>
<div class="form-group" style="width:140px; margin-bottom:0;">
<label class="form-label">Role</label>
<label class="form-label" data-i18n="workspace_setup.members.role">Role</label>
<select class="form-input" id="invite-role">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
</select>
</div>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="sendInvite()">Send invite</button>
<button class="btn-ghost-sm" type="button" style="padding:7px 12px;" onclick="toggleInviteForm()">Cancel</button>
<button class="btn-primary" type="button" style="padding:7px 14px;font-size:13px;" onclick="sendInvite()" data-i18n="workspace_setup.members.send_invite">Send invite</button>
<button class="btn-ghost-sm" type="button" style="padding:7px 12px;" onclick="toggleInviteForm()" data-i18n="workspace_setup.members.cancel">Cancel</button>
</div>
<div id="invite-success" style="display:none; margin-top:10px; font-size:13px; color: #3fb950;">✓ Invite sent.</div>
<div id="invite-success" style="display:none; margin-top:10px; font-size:13px; color: #3fb950;" data-i18n="workspace_setup.members.invite_sent">✓ Invite sent.</div>
</div>
<!-- Members list -->
@@ -226,7 +226,7 @@
<!-- Pending invites -->
<div id="pending-invites" style="border-top: 1px solid var(--border-subtle); padding: 12px 24px 16px;">
<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">
Pending invites <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">1</span>
<span data-i18n="workspace_setup.members.pending">Pending invites</span> <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">1</span>
</div>
<div class="member-row" id="invite-john">
<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>
@@ -244,22 +244,22 @@
<!-- Roles reference -->
<div style="border-top:1px solid var(--border-subtle);padding:16px 24px 20px;">
<div style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:14px;">Roles &amp; permissions</div>
<div style="font-size:12px;font-weight:600;color:var(--text-muted);text-transform:uppercase;letter-spacing:0.06em;margin-bottom:14px;" data-i18n="workspace_setup.roles.title">Roles &amp; permissions</div>
<div class="role-def-row">
<span class="member-role-badge role-owner" style="min-width:80px;text-align:center;">Owner</span>
<div class="role-def-text">Full workspace control, billing access, can transfer or delete the workspace.</div>
<div class="role-def-text" data-i18n="workspace_setup.roles.owner_body">Full workspace control, billing access, can transfer or delete the workspace.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-admin" style="min-width:80px;text-align:center;">Admin</span>
<div class="role-def-text">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
<div class="role-def-text" data-i18n="workspace_setup.roles.admin_body">Manage members and roles, create and edit all operations and agents, manage API keys.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-developer" style="min-width:80px;text-align:center;">Developer</span>
<div class="role-def-text">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
<div class="role-def-text" data-i18n="workspace_setup.roles.developer_body">Create and edit operations and agents. Cannot manage members or workspace settings.</div>
</div>
<div class="role-def-row">
<span class="member-role-badge role-viewer" style="min-width:80px;text-align:center;">Viewer</span>
<div class="role-def-text">Read-only access to operations, agents, and logs.</div>
<div class="role-def-text" data-i18n="workspace_setup.roles.viewer_body">Read-only access to operations, agents, and logs.</div>
</div>
</div>
</div>
@@ -267,19 +267,19 @@
<!-- ── Invite team members (create mode only) ── -->
<div id="section-invite" class="ws-form-section" style="display:none;">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:4px;">
<div class="ws-form-section-title" style="margin-bottom:0;">Invite team members <span style="font-size:12px;font-weight:400;color:var(--text-muted);">(optional)</span></div>
<button class="btn-ghost-sm" type="button" onclick="addInviteRow()" style="font-size:12px;padding:4px 10px;">+ Add person</button>
<div class="ws-form-section-title" style="margin-bottom:0;"><span data-i18n="workspace_setup.invite.title">Invite team members</span> <span style="font-size:12px;font-weight:400;color:var(--text-muted);" data-i18n="workspace_setup.optional">(optional)</span></div>
<button class="btn-ghost-sm" type="button" onclick="addInviteRow()" style="font-size:12px;padding:4px 10px;" data-i18n="workspace_setup.invite.add_person">+ Add person</button>
</div>
<div class="form-hint" style="margin-bottom:14px;">You can invite more people later from Workspace settings.</div>
<div class="form-hint" style="margin-bottom:14px;" data-i18n="workspace_setup.invite.later">You can invite more people later from Workspace settings.</div>
<div class="invite-rows" id="invite-rows">
<div class="invite-row">
<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">
<select class="form-input" style="width:130px;margin-bottom:0;">
<option value="admin">Admin</option>
<option value="developer" selected>Developer</option>
<option value="viewer">Viewer</option>
<option value="admin" data-i18n="workspace_setup.role.admin">Admin</option>
<option value="developer" selected data-i18n="workspace_setup.role.operator">Developer</option>
<option value="viewer" data-i18n="workspace_setup.role.viewer">Viewer</option>
</select>
<button class="invite-row-remove" onclick="removeInviteRow(this)" title="Remove">
<button class="invite-row-remove" onclick="removeInviteRow(this)" data-i18n-title="workspace_setup.members.remove" title="Remove">
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><line x1="1" y1="1" x2="11" y2="11"/><line x1="11" y1="1" x2="1" y2="11"/></svg>
</button>
</div>
@@ -288,33 +288,33 @@
<!-- ── Actions ── -->
<div class="ws-setup-actions">
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);">Cancel</a>
<button class="btn-primary" id="submit-btn" type="button" onclick="submitForm()" style="padding:9px 22px;font-size:13px;">Save changes</button>
<a href="javascript:history.back()" class="btn-ghost-sm" style="padding:9px 18px;font-size:13px;text-decoration:none;color:var(--text-secondary);" data-i18n="btn.cancel">Cancel</a>
<button class="btn-primary" id="submit-btn" type="button" onclick="submitForm()" style="padding:9px 22px;font-size:13px;" data-i18n="workspace_setup.actions.save">Save changes</button>
</div>
<div class="ws-setup-footer-note" id="footer-note" style="display:none;">
You'll be the Owner of this workspace. You can invite additional members after creation.
<span data-i18n="workspace_setup.create.footer">You'll be the Owner of this workspace. You can invite additional members after creation.</span>
</div>
<!-- ── Danger zone (edit mode only) ── -->
<div id="section-danger" style="display:none; margin-top:32px;">
<div class="ws-form-section" style="border-color: rgba(248,81,73,0.25); padding:0; overflow:hidden;">
<div style="padding:16px 24px; border-bottom: 1px solid rgba(248,81,73,0.15);">
<div style="font-size:14px;font-weight:600;color:var(--red);">Danger zone</div>
<div style="font-size:14px;font-weight:600;color:var(--red);" data-i18n="workspace_setup.danger.title">Danger zone</div>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<div class="danger-zone-title">Export all data</div>
<div class="danger-zone-desc">Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.</div>
<div class="danger-zone-title" data-i18n="workspace_setup.danger.export_title">Export all data</div>
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.export_body">Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.</div>
</div>
<button class="btn-danger" id="export-workspace-btn" type="button">Export</button>
<button class="btn-danger" id="export-workspace-btn" type="button" data-i18n="workspace_setup.export">Export</button>
</div>
<div class="danger-zone-action">
<div class="danger-zone-text">
<div class="danger-zone-title">Delete workspace</div>
<div class="danger-zone-desc">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
<div class="danger-zone-title" data-i18n="workspace_setup.danger.delete_title">Delete workspace</div>
<div class="danger-zone-desc" data-i18n="workspace_setup.danger.delete_body">Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.</div>
</div>
<button class="btn-danger" id="delete-workspace-btn" type="button">Delete workspace</button>
<button class="btn-danger" id="delete-workspace-btn" type="button" data-i18n="workspace_setup.delete">Delete workspace</button>
</div>
</div>
</div>
+10 -4
View File
@@ -2,6 +2,12 @@
var STORAGE_KEY = 'crank_user';
var sessionCache = null;
var sessionPromise = null;
function tKey(key) {
return typeof t === 'function' ? t(key) : key;
}
function tfKey(key, vars) {
return typeof tf === 'function' ? tf(key, vars) : key;
}
function currentWorkspaceLabel() {
if (window.getCurrentWorkspace) {
@@ -11,9 +17,9 @@
}
}
try {
return localStorage.getItem('crank_workspace_slug') || 'workspace';
return localStorage.getItem('crank_workspace_slug') || tKey('settings.nav.workspace');
} catch (_error) {
return 'workspace';
return tKey('settings.nav.workspace');
}
}
@@ -80,7 +86,7 @@
id: session.user.id,
name: session.user.display_name,
email: session.user.email,
role: membership ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); }) : 'Viewer',
role: membership ? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); }) : tKey('workspace_setup.role.viewer'),
workspace: membership ? membership.workspace.slug : '',
workspaceId: membership ? membership.workspace.id : '',
initials: initials(session.user.display_name, session.user.email),
@@ -110,7 +116,7 @@
: fallbackUser && fallbackUser.email;
var role = membership
? String(membership.role || '').replace(/^\w/, function(char) { return char.toUpperCase(); })
: (fallbackUser && fallbackUser.role) || 'Viewer';
: (fallbackUser && fallbackUser.role) || tKey('workspace_setup.role.viewer');
var workspace = membership && membership.workspace
? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id)
: currentWorkspaceLabel();
+477
View File
@@ -272,6 +272,243 @@ var TRANSLATIONS = {
'settings.profile.name': 'Full name',
'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.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?',
'settings.profile.avatar_hint': 'Your avatar is generated from your display name and email.',
'settings.profile.backend_hint': 'Your name and email are stored on the backend and used across the console and session identity.',
'settings.profile.first_name': 'First name',
'settings.profile.last_name': 'Last name',
'settings.profile.last_name_placeholder': 'Optional',
'settings.profile.email_label': 'Email address',
'settings.profile.email_hint': 'Changing email updates the login identity for the current account.',
'settings.profile.save': 'Save profile',
'settings.profile.saving': 'Saving…',
'settings.profile.saved': 'Saved ✓',
'settings.profile.saved_status': 'Profile saved.',
'settings.profile.save_error': 'Failed to save profile',
'settings.security.title': 'Security',
'settings.security.current_password': 'Current password',
'settings.security.new_password': 'New password',
'settings.security.new_password_placeholder': 'min. 12 characters',
'settings.security.confirm_password': 'Confirm new password',
'settings.security.change_password': 'Change password',
'settings.security.mismatch': 'New password and confirmation do not match.',
'settings.security.saved': 'Password updated.',
'settings.security.save_error': 'Failed to change password',
'settings.security.capabilities_title': 'Planned security capabilities',
'settings.security.capabilities_body': 'Two-factor authentication, passkeys and multi-session inventory are planned, but the current live flow is password-based login with one HttpOnly browser session.',
'settings.security.not_available': 'Not available in this build.',
'settings.session.title': 'Current session',
'settings.session.current': 'current',
'settings.session.loading': 'Loading current session details…',
'settings.session.unavailable': 'Session details are unavailable. Use Log out to revoke the current browser session.',
'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.spike_title': 'Operation error spike',
'settings.notifications.spike_body': 'Alert when error rate exceeds a rolling threshold for a published tool.',
'settings.notifications.latency_title': 'Upstream latency degradation',
'settings.notifications.latency_body': 'Notify operators when p99 latency rises sharply above the baseline.',
'settings.notifications.digest_title': 'Quota and usage digests',
'settings.notifications.digest_body': 'Deliver periodic summaries for quota consumption, errors and workspace activity.',
'settings.workspace.saving': 'Saving…',
'settings.workspace.saved': 'Saved ✓',
'settings.workspace.save_error': 'Failed to save workspace settings',
'settings.workspace.save_error_title': 'Workspace save failed',
// Workspace setup
'workspace_setup.title': 'Workspace settings',
'workspace_setup.page_subtitle': 'Configure your workspace identity, default settings, and team members.',
'workspace_setup.back': 'Back',
'workspace_setup.identity.title': 'Workspace identity',
'workspace_setup.identity.avatar_hint': 'Avatar is derived from your workspace name.',
'workspace_setup.name': 'Workspace name',
'workspace_setup.slug': 'Slug',
'workspace_setup.required': 'required',
'workspace_setup.optional': '(optional)',
'workspace_setup.slug_hint': 'Only lowercase letters, numbers, and hyphens. Used in MCP endpoint URLs.',
'workspace_setup.description': 'Description',
'workspace_setup.description_placeholder': 'What does this workspace do?',
'workspace_setup.defaults.title': 'Default settings',
'workspace_setup.protocol': 'Default protocol',
'workspace_setup.protocol_hint': 'Pre-selected in the operation wizard.',
'workspace_setup.timezone': 'Timezone',
'workspace_setup.language': 'Interface language',
'workspace_setup.members.title': 'Team members',
'workspace_setup.members.invite': 'Invite member',
'workspace_setup.members.email': 'Email address',
'workspace_setup.members.role': 'Role',
'workspace_setup.members.send_invite': 'Send invite',
'workspace_setup.members.cancel': 'Cancel',
'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.week_ago': '1 week ago',
'workspace_setup.members.never': 'Never',
'workspace_setup.members.pending': 'Pending invites',
'workspace_setup.members.invited_pending': 'Invited {date} · pending',
'workspace_setup.members.revoke': 'Revoke invite',
'workspace_setup.members.remove': 'Remove member',
'workspace_setup.roles.title': 'Roles & permissions',
'workspace_setup.roles.owner_body': 'Full workspace control, billing access, can transfer or delete the workspace.',
'workspace_setup.roles.admin_body': 'Manage members and roles, create and edit all operations and agents, manage API keys.',
'workspace_setup.roles.developer_body': 'Create and edit operations and agents. Cannot manage members or workspace settings.',
'workspace_setup.roles.viewer_body': 'Read-only access to operations, agents, and logs.',
'workspace_setup.invite.title': 'Invite team members',
'workspace_setup.invite.add_person': '+ Add person',
'workspace_setup.invite.later': 'You can invite more people later from Workspace settings.',
'workspace_setup.actions.create': 'Create workspace',
'workspace_setup.actions.save': 'Save changes',
'workspace_setup.create.title': 'Create a new workspace',
'workspace_setup.create.subtitle': "A workspace is a shared environment for your team's MCP operations and agents. Each workspace gets its own MCP endpoint namespace.",
'workspace_setup.create.footer': "You'll be the Owner of this workspace. You can invite additional members after creation.",
'workspace_setup.danger.title': 'Danger zone',
'workspace_setup.danger.export_title': 'Export all data',
'workspace_setup.danger.export_body': 'Download a JSON snapshot of workspace settings, memberships, invitations, operations, agents and platform API keys.',
'workspace_setup.danger.delete_title': 'Delete workspace',
'workspace_setup.danger.delete_body': 'Permanently deletes all operations, keys, logs and settings. This action cannot be undone. You will be logged out immediately.',
'workspace_setup.export': 'Export',
'workspace_setup.delete': 'Delete workspace',
'workspace_setup.role.owner': 'Owner',
'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.saving_create': 'Creating…',
'workspace_setup.saving_update': 'Saving…',
'workspace_setup.saved': 'Saved ✓',
'workspace_setup.save_error': 'Failed to save workspace',
'workspace_setup.save_error_title': 'Save failed',
'workspace_setup.invite_error': 'Failed to send invite',
'workspace_setup.invite_error_title': 'Invite failed',
'workspace_setup.role_error': 'Failed to update role',
'workspace_setup.role_error_title': 'Role update failed',
'workspace_setup.remove_confirm': 'Remove {name} from this workspace?',
'workspace_setup.remove_error': 'Failed to remove member',
'workspace_setup.remove_error_title': 'Member removal failed',
'workspace_setup.revoke_error': 'Failed to revoke invite',
'workspace_setup.revoke_error_title': 'Invite revoke failed',
'workspace_setup.export_error': 'Failed to export workspace',
'workspace_setup.export_error_title': 'Export failed',
'workspace_setup.delete_confirm': 'Delete this workspace? This cannot be undone.',
'workspace_setup.delete_error': 'Failed to delete workspace',
'workspace_setup.delete_error_title': 'Delete failed',
'workspace.switch_error': 'Failed to switch workspace',
'workspace.switch_error_title': 'Workspace switch failed',
'workspace_setup.custom_protocol.rest': 'REST',
'workspace_setup.custom_protocol.graphql': 'GraphQL',
'workspace_setup.custom_protocol.grpc': 'gRPC',
// Wizard
'wizard.back_catalog': 'Operations',
'wizard.progress.create': 'Create operation',
'wizard.progress.edit': 'Edit operation',
'wizard.exit': 'Exit wizard',
'wizard.step_of': 'Step <strong>{step}</strong> of {total}',
'wizard.status.completed': 'Completed',
'wizard.status.in_progress': 'In progress',
'wizard.status.not_started': 'Not started',
'wizard.help.title': 'Need help?',
'wizard.help.body': 'Unsure which protocol to choose? Read the',
'wizard.help.link': 'protocol comparison guide →',
'wizard.progress.section': 'Progress',
'wizard.step_name.protocol': 'Protocol',
'wizard.step_name.upstream': 'Upstream target',
'wizard.step_name.tool': 'Tool config',
'wizard.step_name.mapping': 'Mapping',
'wizard.button.back': 'Back',
'wizard.button.continue': 'Continue',
'wizard.button.save_draft': 'Save draft',
'wizard.button.create': 'Create operation',
'wizard.button.save_changes': 'Save changes',
'wizard.sidebar.brand.create': 'Create <span>operation</span>',
'wizard.sidebar.brand.edit': 'Edit <span>operation</span>',
'wizard.step1.title': 'Choose a protocol',
'wizard.step1.subtitle': 'Select the transport protocol your upstream service uses. This determines how Crank will communicate with your API and which configuration options are available in subsequent steps.',
'wizard.step1.tip_title': 'You can change this later',
'wizard.step1.tip_body': 'Switching protocol after configuring subsequent steps will clear schema and mapping data. Save your operation as a draft before experimenting. See protocol migration guide for details.',
'wizard.step2.title': 'Select upstream & endpoint',
'wizard.step2.subtitle': 'Choose an existing upstream or register a new one. Then define the specific endpoint path this operation will call.',
'wizard.step3.rest.label': 'Request config',
'wizard.step3.graphql.label': 'GQL operation',
'wizard.step3.grpc.label': 'RPC method',
'wizard.step3.rest.title': 'HTTP method & format',
'wizard.step3.rest.subtitle': 'Choose the HTTP verb this operation sends and configure content negotiation headers. Crank will serialize MCP tool arguments into the appropriate request body format.',
'wizard.step3.graphql.title': 'GraphQL operation',
'wizard.step3.graphql.subtitle': 'Define the GraphQL operation this tool will execute. All requests are sent as HTTP POST to the endpoint configured in the previous step. Variables are populated from MCP tool arguments at runtime.',
'wizard.step3.grpc.title': 'RPC method',
'wizard.step3.grpc.subtitle': 'Choose how to describe the gRPC service contract. Upload a .proto file, discover methods from a live server via reflection, or enter the route manually. Only unary RPCs are supported.',
'wizard.step4.title': 'Tool config',
'wizard.step4.subtitle': 'Name your tool, write the LLM-facing description, and define the input/output schemas. The description is the primary signal the model uses when deciding whether to call this tool.',
'wizard.step5.title': 'Mapping and execution',
'wizard.step5.subtitle': 'Define how MCP tool arguments map to upstream request fields, and how the upstream response maps back to MCP output. Then set execution parameters — auth is configured per-upstream in step 3.',
'wizard.live.failed_title': 'Live action failed',
'wizard.live.failed_body': 'The backend request failed. Review the draft and try again.',
'wizard.error.no_workspace': 'No workspace selected',
'wizard.error.tool_name': 'Tool name is required',
'wizard.error.parser_unavailable': 'YAML parser is not available',
'wizard.error.select_upstream': 'Select or register an upstream first',
'wizard.error.save': 'Failed to save operation',
'wizard.error.save_title': 'Save failed',
'wizard.save.created': 'Draft created',
'wizard.save.updated': 'Draft updated',
'wizard.save.body': 'The current draft is saved on the backend and ready for live actions in this wizard.',
'wizard.save.saved': 'Saved ✓',
'wizard.busy.input_sample': 'Saving input sample…',
'wizard.busy.output_sample': 'Saving output sample…',
'wizard.busy.generate': 'Generating draft…',
'wizard.busy.test': 'Running test…',
'wizard.busy.export_yaml': 'Exporting YAML…',
'wizard.busy.import_yaml': 'Importing YAML…',
'wizard.busy.publish': 'Publishing operation…',
'wizard.busy.descriptor': 'Uploading descriptor set…',
'wizard.sample.input_saved': 'Input sample saved',
'wizard.sample.input_saved_body': 'The input sample is now stored against the current draft version.',
'wizard.sample.output_saved': 'Output sample saved',
'wizard.sample.output_saved_body': 'The output sample is now stored against the current draft version.',
'wizard.sample.generated': 'Draft generated',
'wizard.sample.generated_body': 'Schemas and mappings were regenerated from the saved samples.',
'wizard.test.completed': 'Test run completed',
'wizard.test.failed': 'Test run returned errors',
'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.no_response': 'No test response yet',
'wizard.test.no_response_body': 'Run a test first, then copy the response preview into the output sample editor.',
'wizard.test.response_copied': 'Response copied',
'wizard.test.response_copied_body': 'The latest test response was copied into the output sample editor.',
'wizard.yaml.exported': 'YAML exported',
'wizard.yaml.exported_body': 'The current draft YAML was downloaded from the backend.',
'wizard.yaml.none': 'No YAML provided',
'wizard.yaml.none_body': 'Paste a YAML document or load it from a file before importing.',
'wizard.yaml.imported': 'YAML imported',
'wizard.yaml.imported_body': 'The imported operation draft was stored. The wizard will reopen it now.',
'wizard.yaml.loaded': 'YAML loaded',
'wizard.yaml.loaded_body': 'The YAML document was loaded into the import editor.',
'wizard.publish.done': 'Operation published',
'wizard.publish.done_body': 'Version {version} is now published and can be bound into agents.',
'wizard.descriptor.selected': 'Descriptor set selected',
'wizard.descriptor.selected_body': 'Upload the descriptor set to discover unary gRPC methods from the backend.',
'wizard.descriptor.no_file': 'No descriptor set selected',
'wizard.descriptor.no_file_body': 'Choose a descriptor-set file before uploading it, or save a draft that already has one attached.',
'wizard.descriptor.discovered': 'Descriptor services discovered',
'wizard.descriptor.discovered_body': 'The backend parsed the descriptor set. Pick a method to apply its route and schemas.',
'wizard.descriptor.applied': 'gRPC method applied',
'wizard.descriptor.applied_body': 'The route and schemas were filled from the uploaded descriptor set.',
'wizard.descriptor.uploaded': 'Descriptor set uploaded',
'wizard.grpc.discovery_title': 'gRPC discovery moved to live descriptor flow',
'wizard.grpc.discovery_body': 'Server reflection is not wired in this deployment. Save the draft, then use the descriptor-set tools in Step 5 to upload a real descriptor set and discover unary methods from the backend.',
'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_discovered': '{services} services · {methods} unary methods discovered',
'wizard.grpc.services_none': 'No unary methods discovered yet',
// Common buttons
'btn.save': 'Save changes',
@@ -649,6 +886,243 @@ var TRANSLATIONS = {
'settings.profile.name': 'Полное имя',
'settings.profile.email': 'Email',
'settings.profile.role': 'Роль',
'settings.page.title': 'Настройки аккаунта',
'settings.page.subtitle': 'Управляйте живыми настройками профиля, пароля и воркспейса, доступными в этой сборке. Запланированные возможности остаются видимыми, но не притворяются уже подключенными.',
'settings.ws.slug_hint': 'Используется в API endpoint-ах и по всей консоли. Только строчные буквы, цифры и дефисы.',
'settings.ws.description_optional': 'Описание (необязательно)',
'settings.ws.description_placeholder': 'Чем занимается этот воркспейс?',
'settings.profile.avatar_hint': 'Аватар строится из отображаемого имени и email.',
'settings.profile.backend_hint': 'Имя и email хранятся в backend и используются по всей консоли и в сессионной идентичности.',
'settings.profile.first_name': 'Имя',
'settings.profile.last_name': 'Фамилия',
'settings.profile.last_name_placeholder': 'Необязательно',
'settings.profile.email_label': 'Email адрес',
'settings.profile.email_hint': 'Изменение email обновляет логин для текущего аккаунта.',
'settings.profile.save': 'Сохранить профиль',
'settings.profile.saving': 'Сохранение…',
'settings.profile.saved': 'Сохранено ✓',
'settings.profile.saved_status': 'Профиль сохранен.',
'settings.profile.save_error': 'Не удалось сохранить профиль',
'settings.security.title': 'Безопасность',
'settings.security.current_password': 'Текущий пароль',
'settings.security.new_password': 'Новый пароль',
'settings.security.new_password_placeholder': 'минимум 12 символов',
'settings.security.confirm_password': 'Подтвердите новый пароль',
'settings.security.change_password': 'Сменить пароль',
'settings.security.mismatch': 'Новый пароль и подтверждение не совпадают.',
'settings.security.saved': 'Пароль обновлен.',
'settings.security.save_error': 'Не удалось изменить пароль',
'settings.security.capabilities_title': 'Запланированные возможности безопасности',
'settings.security.capabilities_body': 'Двухфакторная аутентификация, passkeys и инвентарь нескольких сессий запланированы, но в текущем live-flow используется логин по паролю и одна HttpOnly browser session.',
'settings.security.not_available': 'Недоступно в этой сборке.',
'settings.session.title': 'Текущая сессия',
'settings.session.current': 'текущая',
'settings.session.loading': 'Загрузка деталей текущей сессии…',
'settings.session.unavailable': 'Детали сессии недоступны. Используйте «Выйти», чтобы отозвать текущую browser session.',
'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.spike_title': 'Всплеск ошибок операции',
'settings.notifications.spike_body': 'Алерт, когда доля ошибок превышает скользящий порог для опубликованного инструмента.',
'settings.notifications.latency_title': 'Деградация задержки upstream',
'settings.notifications.latency_body': 'Уведомлять операторов, когда p99 latency резко растет выше базовой линии.',
'settings.notifications.digest_title': 'Сводки по квотам и использованию',
'settings.notifications.digest_body': 'Доставлять периодические сводки по расходу квот, ошибкам и активности воркспейса.',
'settings.workspace.saving': 'Сохранение…',
'settings.workspace.saved': 'Сохранено ✓',
'settings.workspace.save_error': 'Не удалось сохранить настройки воркспейса',
'settings.workspace.save_error_title': 'Не удалось сохранить воркспейс',
// Workspace setup
'workspace_setup.title': 'Настройки воркспейса',
'workspace_setup.page_subtitle': 'Настройте идентичность воркспейса, значения по умолчанию и состав команды.',
'workspace_setup.back': 'Назад',
'workspace_setup.identity.title': 'Идентичность воркспейса',
'workspace_setup.identity.avatar_hint': 'Аватар формируется из имени воркспейса.',
'workspace_setup.name': 'Имя воркспейса',
'workspace_setup.slug': 'Slug',
'workspace_setup.required': 'обязательно',
'workspace_setup.optional': '(необязательно)',
'workspace_setup.slug_hint': 'Только строчные буквы, цифры и дефисы. Используется в URL MCP endpoint-ов.',
'workspace_setup.description': 'Описание',
'workspace_setup.description_placeholder': 'Чем занимается этот воркспейс?',
'workspace_setup.defaults.title': 'Настройки по умолчанию',
'workspace_setup.protocol': 'Протокол по умолчанию',
'workspace_setup.protocol_hint': 'Предварительно выбирается в мастере операций.',
'workspace_setup.timezone': 'Часовой пояс',
'workspace_setup.language': 'Язык интерфейса',
'workspace_setup.members.title': 'Участники команды',
'workspace_setup.members.invite': 'Пригласить участника',
'workspace_setup.members.email': 'Email адрес',
'workspace_setup.members.role': 'Роль',
'workspace_setup.members.send_invite': 'Отправить приглашение',
'workspace_setup.members.cancel': 'Отмена',
'workspace_setup.members.invite_sent': '✓ Приглашение отправлено.',
'workspace_setup.members.you': '(вы)',
'workspace_setup.members.today': 'Сегодня',
'workspace_setup.members.days_ago': '{count} дня назад',
'workspace_setup.members.week_ago': '1 неделю назад',
'workspace_setup.members.never': 'Никогда',
'workspace_setup.members.pending': 'Ожидающие приглашения',
'workspace_setup.members.invited_pending': 'Приглашен {date} · ожидает принятия',
'workspace_setup.members.revoke': 'Отозвать приглашение',
'workspace_setup.members.remove': 'Удалить участника',
'workspace_setup.roles.title': 'Роли и права',
'workspace_setup.roles.owner_body': 'Полный контроль над воркспейсом, доступ к биллингу, возможность передать или удалить воркспейс.',
'workspace_setup.roles.admin_body': 'Управление участниками и ролями, создание и редактирование всех операций и агентов, управление API-ключами.',
'workspace_setup.roles.developer_body': 'Создание и редактирование операций и агентов. Нельзя управлять участниками или настройками воркспейса.',
'workspace_setup.roles.viewer_body': 'Доступ только на чтение к операциям, агентам и логам.',
'workspace_setup.invite.title': 'Пригласить участников команды',
'workspace_setup.invite.add_person': '+ Добавить человека',
'workspace_setup.invite.later': 'Позже можно пригласить людей из настроек воркспейса.',
'workspace_setup.actions.create': 'Создать воркспейс',
'workspace_setup.actions.save': 'Сохранить изменения',
'workspace_setup.create.title': 'Создать новый воркспейс',
'workspace_setup.create.subtitle': 'Воркспейс — это общее окружение для MCP-операций и агентов вашей команды. У каждого воркспейса свой namespace MCP endpoint-ов.',
'workspace_setup.create.footer': 'Вы станете Owner этого воркспейса. После создания можно пригласить дополнительных участников.',
'workspace_setup.danger.title': 'Опасная зона',
'workspace_setup.danger.export_title': 'Экспортировать все данные',
'workspace_setup.danger.export_body': 'Скачать JSON-снимок настроек воркспейса, участников, приглашений, операций, агентов и platform API keys.',
'workspace_setup.danger.delete_title': 'Удалить воркспейс',
'workspace_setup.danger.delete_body': 'Безвозвратно удаляет все операции, ключи, логи и настройки. Действие нельзя отменить. Вы будете немедленно разлогинены.',
'workspace_setup.export': 'Экспорт',
'workspace_setup.delete': 'Удалить воркспейс',
'workspace_setup.role.owner': 'Owner',
'workspace_setup.role.admin': 'Admin',
'workspace_setup.role.operator': 'Developer',
'workspace_setup.role.viewer': 'Viewer',
'workspace_setup.member_count': '{count} участников',
'workspace_setup.member_count_one': '1 участник',
'workspace_setup.saving_create': 'Создание…',
'workspace_setup.saving_update': 'Сохранение…',
'workspace_setup.saved': 'Сохранено ✓',
'workspace_setup.save_error': 'Не удалось сохранить воркспейс',
'workspace_setup.save_error_title': 'Не удалось сохранить',
'workspace_setup.invite_error': 'Не удалось отправить приглашение',
'workspace_setup.invite_error_title': 'Не удалось пригласить',
'workspace_setup.role_error': 'Не удалось обновить роль',
'workspace_setup.role_error_title': 'Не удалось обновить роль',
'workspace_setup.remove_confirm': 'Удалить {name} из этого воркспейса?',
'workspace_setup.remove_error': 'Не удалось удалить участника',
'workspace_setup.remove_error_title': 'Не удалось удалить участника',
'workspace_setup.revoke_error': 'Не удалось отозвать приглашение',
'workspace_setup.revoke_error_title': 'Не удалось отозвать приглашение',
'workspace_setup.export_error': 'Не удалось экспортировать воркспейс',
'workspace_setup.export_error_title': 'Не удалось экспортировать',
'workspace_setup.delete_confirm': 'Удалить этот воркспейс? Действие нельзя отменить.',
'workspace_setup.delete_error': 'Не удалось удалить воркспейс',
'workspace_setup.delete_error_title': 'Не удалось удалить',
'workspace.switch_error': 'Не удалось переключить воркспейс',
'workspace.switch_error_title': 'Не удалось переключить воркспейс',
'workspace_setup.custom_protocol.rest': 'REST',
'workspace_setup.custom_protocol.graphql': 'GraphQL',
'workspace_setup.custom_protocol.grpc': 'gRPC',
// Wizard
'wizard.back_catalog': 'Операции',
'wizard.progress.create': 'Создать операцию',
'wizard.progress.edit': 'Редактировать операцию',
'wizard.exit': 'Выйти из мастера',
'wizard.step_of': 'Шаг <strong>{step}</strong> из {total}',
'wizard.status.completed': 'Завершено',
'wizard.status.in_progress': 'В процессе',
'wizard.status.not_started': 'Не начато',
'wizard.help.title': 'Нужна помощь?',
'wizard.help.body': 'Не уверены, какой протокол выбрать? Откройте',
'wizard.help.link': 'гайд по сравнению протоколов →',
'wizard.progress.section': 'Прогресс',
'wizard.step_name.protocol': 'Протокол',
'wizard.step_name.upstream': 'Upstream target',
'wizard.step_name.tool': 'Конфиг инструмента',
'wizard.step_name.mapping': 'Mapping',
'wizard.button.back': 'Назад',
'wizard.button.continue': 'Продолжить',
'wizard.button.save_draft': 'Сохранить черновик',
'wizard.button.create': 'Создать операцию',
'wizard.button.save_changes': 'Сохранить изменения',
'wizard.sidebar.brand.create': 'Создать <span>операцию</span>',
'wizard.sidebar.brand.edit': 'Редактировать <span>операцию</span>',
'wizard.step1.title': 'Выберите протокол',
'wizard.step1.subtitle': 'Выберите транспортный протокол upstream-сервиса. От этого зависит, как Crank будет общаться с вашим API и какие настройки будут доступны на следующих шагах.',
'wizard.step1.tip_title': 'Это можно изменить позже',
'wizard.step1.tip_body': 'Смена протокола после настройки следующих шагов очистит схемы и mapping. Сохраните операцию как черновик перед экспериментами. См. гайд по миграции протоколов.',
'wizard.step2.title': 'Выберите upstream и endpoint',
'wizard.step2.subtitle': 'Выберите существующий upstream или зарегистрируйте новый. Затем задайте конкретный путь endpoint-а для этой операции.',
'wizard.step3.rest.label': 'Конфиг запроса',
'wizard.step3.graphql.label': 'GQL операция',
'wizard.step3.grpc.label': 'RPC метод',
'wizard.step3.rest.title': 'HTTP метод и формат',
'wizard.step3.rest.subtitle': 'Выберите HTTP-метод, который отправляет эта операция, и настройте content negotiation headers. Crank сериализует аргументы MCP-инструмента в нужный формат request body.',
'wizard.step3.graphql.title': 'GraphQL операция',
'wizard.step3.graphql.subtitle': 'Определите GraphQL-операцию, которую будет выполнять этот инструмент. Все запросы отправляются как HTTP POST на endpoint, настроенный на предыдущем шаге. Variables заполняются из аргументов MCP-инструмента во время выполнения.',
'wizard.step3.grpc.title': 'RPC метод',
'wizard.step3.grpc.subtitle': 'Выберите, как описывать контракт gRPC-сервиса. Загрузите .proto файл, откройте методы через reflection или введите route вручную. Поддерживаются только unary RPC.',
'wizard.step4.title': 'Конфиг инструмента',
'wizard.step4.subtitle': 'Задайте имя инструмента, описание для LLM и определите входную/выходную схему. Описание — главный сигнал, по которому модель решает, вызывать ли этот инструмент.',
'wizard.step5.title': 'Mapping и исполнение',
'wizard.step5.subtitle': 'Определите, как аргументы MCP-инструмента мапятся в поля upstream-запроса и как ответ upstream-а мапится обратно в MCP output. Затем задайте execution-параметры — auth настраивается на уровне upstream на шаге 3.',
'wizard.live.failed_title': 'Не удалось выполнить live-действие',
'wizard.live.failed_body': 'Запрос к backend завершился ошибкой. Проверьте черновик и попробуйте еще раз.',
'wizard.error.no_workspace': 'Воркспейс не выбран',
'wizard.error.tool_name': 'Имя инструмента обязательно',
'wizard.error.parser_unavailable': 'YAML parser недоступен',
'wizard.error.select_upstream': 'Сначала выберите или зарегистрируйте upstream',
'wizard.error.save': 'Не удалось сохранить операцию',
'wizard.error.save_title': 'Не удалось сохранить',
'wizard.save.created': 'Черновик создан',
'wizard.save.updated': 'Черновик обновлен',
'wizard.save.body': 'Текущий черновик сохранен в backend и готов к live-действиям в этом мастере.',
'wizard.save.saved': 'Сохранено ✓',
'wizard.busy.input_sample': 'Сохранение входного sample…',
'wizard.busy.output_sample': 'Сохранение выходного sample…',
'wizard.busy.generate': 'Генерация черновика…',
'wizard.busy.test': 'Запуск теста…',
'wizard.busy.export_yaml': 'Экспорт YAML…',
'wizard.busy.import_yaml': 'Импорт YAML…',
'wizard.busy.publish': 'Публикация операции…',
'wizard.busy.descriptor': 'Загрузка descriptor set…',
'wizard.sample.input_saved': 'Входной sample сохранен',
'wizard.sample.input_saved_body': 'Входной sample теперь сохранен в текущей версии черновика.',
'wizard.sample.output_saved': 'Выходной sample сохранен',
'wizard.sample.output_saved_body': 'Выходной sample теперь сохранен в текущей версии черновика.',
'wizard.sample.generated': 'Черновик сгенерирован',
'wizard.sample.generated_body': 'Схемы и mapping были пересобраны из сохраненных sample-ов.',
'wizard.test.completed': 'Тестовый прогон завершен',
'wizard.test.failed': 'Тестовый прогон вернул ошибки',
'wizard.test.completed_body': 'Текущий черновик успешно выполнился против upstream.',
'wizard.test.failed_body': 'Черновик выполнился, но backend вернул ошибки валидации или runtime.',
'wizard.test.no_response': 'Тестового ответа пока нет',
'wizard.test.no_response_body': 'Сначала выполните тест, затем скопируйте response preview в editor output sample.',
'wizard.test.response_copied': 'Ответ скопирован',
'wizard.test.response_copied_body': 'Последний тестовый ответ скопирован в editor output sample.',
'wizard.yaml.exported': 'YAML экспортирован',
'wizard.yaml.exported_body': 'YAML текущего черновика скачан из backend.',
'wizard.yaml.none': 'YAML не предоставлен',
'wizard.yaml.none_body': 'Вставьте YAML-документ или загрузите файл перед импортом.',
'wizard.yaml.imported': 'YAML импортирован',
'wizard.yaml.imported_body': 'Импортированный черновик операции сохранен. Мастер сейчас откроет его заново.',
'wizard.yaml.loaded': 'YAML загружен',
'wizard.yaml.loaded_body': 'YAML-документ загружен в editor импорта.',
'wizard.publish.done': 'Операция опубликована',
'wizard.publish.done_body': 'Версия {version} опубликована и теперь может быть привязана к агентам.',
'wizard.descriptor.selected': 'Descriptor set выбран',
'wizard.descriptor.selected_body': 'Загрузите descriptor set, чтобы открыть unary gRPC methods из backend.',
'wizard.descriptor.no_file': 'Descriptor set не выбран',
'wizard.descriptor.no_file_body': 'Выберите файл descriptor set перед загрузкой или сохраните черновик, у которого он уже прикреплен.',
'wizard.descriptor.discovered': 'Сервисы descriptor-а открыты',
'wizard.descriptor.discovered_body': 'Backend разобрал descriptor set. Выберите метод, чтобы применить route и схемы.',
'wizard.descriptor.applied': 'gRPC метод применен',
'wizard.descriptor.applied_body': 'Route и схемы заполнены из загруженного descriptor set.',
'wizard.descriptor.uploaded': 'Descriptor set загружен',
'wizard.grpc.discovery_title': 'gRPC discovery переведен в live descriptor flow',
'wizard.grpc.discovery_body': 'Server reflection в этом развертывании не подключен. Сохраните черновик и используйте инструменты descriptor set на шаге 5, чтобы загрузить реальный descriptor set и открыть unary methods из backend.',
'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_discovered': 'Сервисов открыто: {services} · unary методов: {methods}',
'wizard.grpc.services_none': 'Unary methods пока не открыты',
// Common buttons
'btn.save': 'Сохранить',
@@ -776,6 +1250,9 @@ function applyLang() {
document.querySelectorAll('[data-i18n]').forEach(function(el) {
el.textContent = t(el.getAttribute('data-i18n'));
});
document.querySelectorAll('[data-i18n-html]').forEach(function(el) {
el.innerHTML = t(el.getAttribute('data-i18n-html'));
});
// placeholder
document.querySelectorAll('[data-i18n-ph]').forEach(function(el) {
el.placeholder = t(el.getAttribute('data-i18n-ph'));
+25 -15
View File
@@ -1,4 +1,10 @@
var settingsSession = null;
function tKey(key) {
return typeof t === 'function' ? t(key) : key;
}
function tfKey(key, vars) {
return typeof tf === 'function' ? tf(key, vars) : key;
}
function settingsWorkspaceId() {
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
@@ -37,7 +43,7 @@ function populateCurrentSession(session) {
}
if (!(session && session.user)) {
summary.textContent = 'Session details are unavailable. Use Log out to revoke the current browser session.';
summary.textContent = tKey('settings.session.unavailable');
return;
}
@@ -51,8 +57,12 @@ function populateCurrentSession(session) {
var workspaceLabel = membership && membership.workspace
? (membership.workspace.display_name || membership.workspace.slug || membership.workspace.id)
: 'current workspace';
var roleLabel = membership ? titleCaseRole(membership.role) : 'Viewer';
summary.textContent = session.user.email + ' · ' + roleLabel + ' in ' + workspaceLabel + '. Use Log out to revoke the current browser session.';
var roleLabel = membership ? titleCaseRole(membership.role) : tKey('workspace_setup.role.viewer');
summary.textContent = tfKey('settings.session.summary', {
email: session.user.email,
role: roleLabel,
workspace: workspaceLabel,
});
}
function populateProfile(session) {
@@ -96,7 +106,7 @@ function bindProfileSave() {
button.addEventListener('click', async function() {
var original = button.textContent;
button.disabled = true;
button.textContent = 'Saving';
button.textContent = tKey('settings.profile.saving');
setStatus('settings-profile-status', '', false);
try {
@@ -109,10 +119,10 @@ function bindProfileSave() {
window.CrankAuth.replaceSession(session);
}
populateProfile(session);
setStatus('settings-profile-status', 'Profile saved.', false);
button.textContent = 'Saved ✓';
setStatus('settings-profile-status', tKey('settings.profile.saved_status'), false);
button.textContent = tKey('settings.profile.saved');
} catch (error) {
setStatus('settings-profile-status', error.message || 'Failed to save profile', true);
setStatus('settings-profile-status', error.message || tKey('settings.profile.save_error'), true);
button.textContent = original;
} finally {
setTimeout(function() {
@@ -136,13 +146,13 @@ function bindPasswordSave() {
var confirmPassword = document.getElementById('security-confirm-password').value;
if (newPassword !== confirmPassword) {
setStatus('settings-password-status', 'New password and confirmation do not match.', true);
setStatus('settings-password-status', tKey('settings.security.mismatch'), true);
return;
}
var original = button.textContent;
button.disabled = true;
button.textContent = 'Saving';
button.textContent = tKey('settings.profile.saving');
setStatus('settings-password-status', '', false);
try {
@@ -153,10 +163,10 @@ function bindPasswordSave() {
document.getElementById('security-current-password').value = '';
document.getElementById('security-new-password').value = '';
document.getElementById('security-confirm-password').value = '';
setStatus('settings-password-status', 'Password updated.', false);
button.textContent = 'Saved ✓';
setStatus('settings-password-status', tKey('settings.security.saved'), false);
button.textContent = tKey('settings.profile.saved');
} catch (error) {
setStatus('settings-password-status', error.message || 'Failed to change password', true);
setStatus('settings-password-status', error.message || tKey('settings.security.save_error'), true);
button.textContent = original;
} finally {
setTimeout(function() {
@@ -268,7 +278,7 @@ async function loadWorkspaceSettings() {
var saveButton = this;
var original = saveButton.textContent;
saveButton.disabled = true;
saveButton.textContent = 'Saving';
saveButton.textContent = tKey('settings.workspace.saving');
try {
await window.CrankApi.updateWorkspace(item.id, {
@@ -291,10 +301,10 @@ async function loadWorkspaceSettings() {
}
}
saveButton.textContent = 'Saved ✓';
saveButton.textContent = tKey('settings.workspace.saved');
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to save workspace settings', 'Workspace save failed');
window.CrankUi.error(error.message || tKey('settings.workspace.save_error'), tKey('settings.workspace.save_error_title'));
}
saveButton.textContent = original;
} finally {
+73 -65
View File
@@ -10,6 +10,12 @@ var wizardProtoUpload = null;
var wizardDescriptorSetUpload = null;
var wizardTestResponsePreview = null;
var grpcDescriptorServices = [];
function tKey(key) {
return typeof t === 'function' ? t(key) : key;
}
function tfKey(key, vars) {
return typeof tf === 'function' ? tf(key, vars) : key;
}
/* ── Dynamic step loading ── */
function _stepFile(n) {
@@ -24,6 +30,7 @@ function loadStep(n, callback) {
.then(function(html) {
var c = document.getElementById('step-panel-container');
if (c) c.insertAdjacentHTML('beforeend', html);
if (typeof applyLang === 'function') applyLang();
if (callback) callback();
})
.catch(function() { if (callback) callback(); });
@@ -38,9 +45,9 @@ function step3PanelId() {
/* Sidebar step-3 label by protocol */
var STEP3_LABELS = {
rest: 'Request config',
graphql: 'GQL operation',
grpc: 'RPC method',
rest: tKey('wizard.step3.rest.label'),
graphql: tKey('wizard.step3.graphql.label'),
grpc: tKey('wizard.step3.grpc.label'),
};
var CHECK_SVG = '<svg width="10" height="10"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>';
@@ -68,15 +75,15 @@ function _doGoToStep(n) {
if (num < n) {
item.classList.add('done');
if (ind) ind.innerHTML = CHECK_SVG;
if (statusEl) statusEl.textContent = 'Completed';
if (statusEl) statusEl.textContent = tKey('wizard.status.completed');
} else if (num === n) {
item.classList.add('active');
if (ind) ind.textContent = num;
if (statusEl) statusEl.textContent = 'In progress';
if (statusEl) statusEl.textContent = tKey('wizard.status.in_progress');
} else {
item.classList.add('pending');
if (ind) ind.textContent = num;
if (statusEl) statusEl.textContent = 'Not started';
if (statusEl) statusEl.textContent = tKey('wizard.status.not_started');
}
});
@@ -89,7 +96,7 @@ function _doGoToStep(n) {
// 4. update step counter
var counter = document.querySelector('.step-counter');
if (counter) counter.innerHTML = 'Step <strong>' + n + '</strong> of ' + TOTAL_STEPS;
if (counter) counter.innerHTML = tfKey('wizard.step_of', { step: n, total: TOTAL_STEPS });
// 5. back button
var backBtn = document.querySelector('.btn-back');
@@ -109,11 +116,11 @@ function _doGoToStep(n) {
var contBtn = document.querySelector('.btn-continue');
if (contBtn) {
if (n === TOTAL_STEPS) {
var finalLabel = wizardMode === 'edit' ? 'Save changes' : 'Create operation';
var finalLabel = wizardMode === 'edit' ? tKey('wizard.button.save_changes') : tKey('wizard.button.create');
contBtn.innerHTML = finalLabel + ' ' + ARROW_SVG;
contBtn.style.cssText = 'background:#3fb950;border-color:#2ea043;box-shadow:0 1px 4px rgba(63,185,80,0.4);';
} else {
contBtn.innerHTML = 'Continue ' + ARROW_SVG;
contBtn.innerHTML = tKey('wizard.button.continue') + ' ' + ARROW_SVG;
contBtn.style.cssText = '';
}
}
@@ -153,7 +160,7 @@ function bindProtocolCards() {
wizardProtocol = proto;
var s3name = document.getElementById('sidebar-step-3-name');
if (s3name) s3name.textContent = STEP3_LABELS[proto] || 'Protocol config';
if (s3name) s3name.textContent = STEP3_LABELS[proto] || tKey('wizard.step3.rest.label');
if (proto === 'graphql') {
var pathInput = document.getElementById('endpoint-path');
@@ -219,7 +226,7 @@ document.addEventListener('DOMContentLoaded', async function() {
if (params.get('mode') === 'edit' && params.get('operationId')) {
wizardMode = 'edit';
wizardEditId = params.get('operationId');
document.title = 'Crank — Edit Operation';
document.title = 'Crank — ' + tKey('wizard.progress.edit');
await loadOperationForEdit();
}
@@ -293,7 +300,7 @@ function renderDropdownList(filter) {
});
if (filtered.length === 0) {
list.innerHTML = '<div class="upstream-dropdown-empty">No upstreams match "' + escapeHtml(filter) + '"</div>';
list.innerHTML = '<div class="upstream-dropdown-empty">' + tfKey('agents.drawer.ops_no_match', { query: escapeHtml(filter) }) + '</div>';
return;
}
@@ -413,7 +420,7 @@ function startNewUpstream() {
// Clear combobox trigger & hide preview
var val = document.getElementById('upstream-combobox-value');
if (val) val.innerHTML = '<span class="upstream-combobox-placeholder">Select an upstream…</span>';
if (val) val.innerHTML = '<span class="upstream-combobox-placeholder">' + tKey('wizard.step2.title') + '</span>';
var preview = document.getElementById('upstream-preview');
if (preview) preview.style.display = 'none';
selectedUpstreamId = null;
@@ -589,9 +596,10 @@ 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) {
summaryEl.textContent =
parsed.services.length + ' service' + (parsed.services.length !== 1 ? 's' : '') +
' · ' + totalMethods + ' unary method' + (totalMethods !== 1 ? 's' : '') + ' found';
summaryEl.textContent = tfKey('wizard.grpc.services_found', {
services: parsed.services.length,
methods: totalMethods,
});
}
var listEl = document.getElementById(ids.list);
@@ -601,7 +609,7 @@ function renderServiceResults(parsed, ids) {
if (parsed.services.length === 0) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'proto-empty';
emptyDiv.textContent = 'No unary RPC methods found.';
emptyDiv.textContent = tKey('wizard.grpc.no_methods');
listEl.appendChild(emptyDiv);
} else {
var svcTmpl = document.getElementById('tmpl-proto-service-group');
@@ -687,7 +695,7 @@ function renderFieldList(elId, typeName) {
if (!fields || fields.length === 0) {
var emptyEl = document.createElement('div');
emptyEl.className = 'proto-field-empty';
emptyEl.innerHTML = 'Fields not resolved.<br><span style="color:var(--text-muted);font-size:11px;">Import or external types are not expanded client-side.</span>';
emptyEl.innerHTML = tKey('wizard.grpc.fields_unresolved') + '<br><span style="color:var(--text-muted);font-size:11px;">' + tKey('wizard.grpc.fields_unresolved_body') + '</span>';
el.appendChild(emptyEl);
return;
}
@@ -831,8 +839,8 @@ var REFLECTION_MOCK = {
function startReflection() {
goToStep(5);
showWizardLiveStatus(
'gRPC discovery moved to live descriptor flow',
'Server reflection is not wired in this deployment. Save the draft, then use the descriptor-set tools in Step 5 to upload a real descriptor set and discover unary methods from the backend.'
tKey('wizard.grpc.discovery_title'),
tKey('wizard.grpc.discovery_body')
);
}
@@ -892,7 +900,7 @@ function parseStructuredText(text) {
try {
return JSON.parse(text);
} catch (_error) {
if (!window.jsyaml) throw new Error('YAML parser is not available');
if (!window.jsyaml) throw new Error(tKey('wizard.error.parser_unavailable'));
return window.jsyaml.load(text) || {};
}
}
@@ -1063,7 +1071,7 @@ function graphqlOperationName(queryText) {
function buildTarget() {
var upstream = currentUpstream();
if (!upstream || !upstream.url) {
throw new Error('Select or register an upstream first');
throw new Error(tKey('wizard.error.select_upstream'));
}
var pathTemplate = textValue('endpoint-path') || '/';
@@ -1153,7 +1161,7 @@ function buildToolDescription() {
function collectWizardPayload() {
var name = textValue('tool-name');
if (!name) throw new Error('Tool name is required');
if (!name) throw new Error(tKey('wizard.error.tool_name'));
var inputSchemaValue = parseStructuredText(textValue('tool-input-schema'));
var outputSchemaValue = parseStructuredText(textValue('tool-output-schema'));
@@ -1180,7 +1188,7 @@ async function saveOperation(stayOnPage) {
await persistCurrentDraft(stayOnPage);
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to save operation', 'Save failed');
window.CrankUi.error(error.message || tKey('wizard.error.save'), tKey('wizard.error.save_title'));
}
}
}
@@ -1202,9 +1210,9 @@ async function loadOperationForEdit() {
function setEditModePresentation() {
var progressLabel = document.querySelector('.progress-label');
if (progressLabel) progressLabel.textContent = 'Edit operation';
if (progressLabel) progressLabel.textContent = tKey('wizard.progress.edit');
var sidebarBrand = document.querySelector('.step-sidebar-brand');
if (sidebarBrand) sidebarBrand.innerHTML = 'Edit <span>operation</span>';
if (sidebarBrand) sidebarBrand.innerHTML = tKey('wizard.sidebar.brand.edit');
}
function selectProtocol(protocol) {
@@ -1217,7 +1225,7 @@ function selectProtocol(protocol) {
card.setAttribute('aria-checked', selected ? 'true' : 'false');
});
var s3name = document.getElementById('sidebar-step-3-name');
if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || 'Protocol config';
if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || tKey('wizard.step3.rest.label');
updateWizardProtocolVisibility();
}
@@ -1352,19 +1360,19 @@ function prefillWizardFromEdit(detail, versionDocument) {
}
function bindWizardLiveActions() {
bindLiveAction('wizard-upload-input-sample', 'Saving input sample', uploadInputSampleFromWizard);
bindLiveAction('wizard-upload-output-sample', 'Saving output sample', uploadOutputSampleFromWizard);
bindLiveAction('wizard-generate-draft', 'Generating draft…', generateDraftFromWizard);
bindLiveAction('wizard-run-test', 'Running test', runWizardTest);
bindLiveAction('wizard-upload-input-sample', tKey('wizard.busy.input_sample'), uploadInputSampleFromWizard);
bindLiveAction('wizard-upload-output-sample', tKey('wizard.busy.output_sample'), uploadOutputSampleFromWizard);
bindLiveAction('wizard-generate-draft', tKey('wizard.busy.generate'), generateDraftFromWizard);
bindLiveAction('wizard-run-test', tKey('wizard.busy.test'), runWizardTest);
bindClick('wizard-copy-test-response', copyTestResponseToOutputSample);
bindLiveAction('wizard-export-yaml', 'Exporting YAML…', exportWizardYaml);
bindLiveAction('wizard-import-yaml', 'Importing YAML…', importWizardYaml);
bindLiveAction('wizard-publish-operation', 'Publishing operation…', publishWizardOperation);
bindLiveAction('wizard-export-yaml', tKey('wizard.busy.export_yaml'), exportWizardYaml);
bindLiveAction('wizard-import-yaml', tKey('wizard.busy.import_yaml'), importWizardYaml);
bindLiveAction('wizard-publish-operation', tKey('wizard.busy.publish'), publishWizardOperation);
bindClick('wizard-select-descriptor-set', function() {
var input = document.getElementById('wizard-descriptor-set-input');
if (input) input.click();
});
bindLiveAction('wizard-upload-descriptor-set', 'Uploading descriptor set…', uploadDescriptorSetAndDiscover);
bindLiveAction('wizard-upload-descriptor-set', tKey('wizard.busy.descriptor'), uploadDescriptorSetAndDiscover);
bindClick('wizard-import-yaml-file-trigger', function() {
var input = document.getElementById('wizard-import-yaml-file');
if (input) input.click();
@@ -1410,14 +1418,14 @@ async function runWizardLiveAction(button, busyLabel, handler) {
await handler();
} catch (error) {
showWizardLiveStatus(
'Live action failed',
error && error.message ? error.message : 'The backend request failed. Review the draft and try again.',
tKey('wizard.live.failed_title'),
error && error.message ? error.message : tKey('wizard.live.failed_body'),
true
);
if (window.CrankUi) {
window.CrankUi.error(
error && error.message ? error.message : 'The backend request failed. Review the draft and try again.',
'Wizard action failed'
error && error.message ? error.message : tKey('wizard.live.failed_body'),
tKey('wizard.live.failed_title')
);
}
} finally {
@@ -1516,7 +1524,7 @@ async function uploadPendingGrpcArtifacts(payload) {
);
wizardDescriptorSetUpload = null;
var descriptorName = document.getElementById('wizard-descriptor-set-name');
if (descriptorName) descriptorName.textContent = 'Descriptor set uploaded';
if (descriptorName) descriptorName.textContent = tKey('wizard.descriptor.uploaded');
}
if (!descriptorResponse) return;
@@ -1532,7 +1540,7 @@ async function uploadPendingGrpcArtifacts(payload) {
async function persistCurrentDraft(stayOnPage) {
if (!wizardWorkspaceId) {
throw new Error('No workspace selected');
throw new Error(tKey('wizard.error.no_workspace'));
}
var payload = collectWizardPayload();
@@ -1568,7 +1576,7 @@ async function persistCurrentDraft(stayOnPage) {
var saveDraftBtn = document.querySelector('.btn-save-draft');
if (saveDraftBtn) {
var label = saveDraftBtn.textContent;
saveDraftBtn.textContent = 'Saved ✓';
saveDraftBtn.textContent = tKey('wizard.save.saved');
setTimeout(function() {
saveDraftBtn.textContent = label;
}, 1400);
@@ -1576,8 +1584,8 @@ async function persistCurrentDraft(stayOnPage) {
}
showWizardLiveStatus(
createdNow ? 'Draft created' : 'Draft updated',
'The current draft is saved on the backend and ready for live actions in this wizard.'
createdNow ? tKey('wizard.save.created') : tKey('wizard.save.updated'),
tKey('wizard.save.body')
);
return response;
}
@@ -1601,7 +1609,7 @@ async function uploadInputSampleFromWizard() {
wizardEditId,
parseStructuredText(textValue('wizard-input-sample'))
);
showWizardLiveStatus('Input sample saved', 'The input sample is now stored against the current draft version.');
showWizardLiveStatus(tKey('wizard.sample.input_saved'), tKey('wizard.sample.input_saved_body'));
}
async function uploadOutputSampleFromWizard() {
@@ -1611,7 +1619,7 @@ async function uploadOutputSampleFromWizard() {
wizardEditId,
parseStructuredText(textValue('wizard-output-sample'))
);
showWizardLiveStatus('Output sample saved', 'The output sample is now stored against the current draft version.');
showWizardLiveStatus(tKey('wizard.sample.output_saved'), tKey('wizard.sample.output_saved_body'));
}
async function generateDraftFromWizard() {
@@ -1621,7 +1629,7 @@ async function generateDraftFromWizard() {
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(generated.output_schema), null, 2));
setValue('tool-input-mapping', mappingSetToEditorValue(generated.input_mapping, 'input', wizardProtocol));
setValue('tool-output-mapping', mappingSetToEditorValue(generated.output_mapping, 'output', wizardProtocol));
showWizardLiveStatus('Draft generated', 'Schemas and mappings were regenerated from the saved samples.');
showWizardLiveStatus(tKey('wizard.sample.generated'), tKey('wizard.sample.generated_body'));
}
async function runWizardTest() {
@@ -1635,21 +1643,21 @@ async function runWizardTest() {
setTextareaValue('wizard-test-response-preview', result.response_preview);
setTextareaValue('wizard-test-errors', result.errors && result.errors.length ? result.errors : []);
showWizardLiveStatus(
result.ok ? 'Test run completed' : 'Test run returned errors',
result.ok ? tKey('wizard.test.completed') : tKey('wizard.test.failed'),
result.ok
? 'The current draft executed successfully against the upstream.'
: 'The draft executed, but the backend returned validation or runtime errors.',
? tKey('wizard.test.completed_body')
: tKey('wizard.test.failed_body'),
!result.ok
);
}
function copyTestResponseToOutputSample() {
if (!wizardTestResponsePreview) {
showWizardLiveStatus('No test response yet', 'Run a test first, then copy the response preview into the output sample editor.', true);
showWizardLiveStatus(tKey('wizard.test.no_response'), tKey('wizard.test.no_response_body'), true);
return;
}
setTextareaValue('wizard-output-sample', wizardTestResponsePreview);
showWizardLiveStatus('Response copied', 'The latest test response was copied into the output sample editor.');
showWizardLiveStatus(tKey('wizard.test.response_copied'), tKey('wizard.test.response_copied_body'));
}
function downloadTextFile(fileName, content, mimeType) {
@@ -1669,20 +1677,20 @@ async function exportWizardYaml() {
version: currentDraftVersion(),
});
downloadTextFile((textValue('tool-name') || 'operation') + '.yaml', yaml, 'application/yaml');
showWizardLiveStatus('YAML exported', 'The current draft YAML was downloaded from the backend.');
showWizardLiveStatus(tKey('wizard.yaml.exported'), tKey('wizard.yaml.exported_body'));
}
async function importWizardYaml() {
if (!wizardWorkspaceId) {
throw new Error('No workspace selected');
throw new Error(tKey('wizard.error.no_workspace'));
}
var yamlDocument = textValue('wizard-import-yaml-text');
if (!yamlDocument) {
showWizardLiveStatus('No YAML provided', 'Paste a YAML document or load it from a file before importing.', true);
showWizardLiveStatus(tKey('wizard.yaml.none'), tKey('wizard.yaml.none_body'), true);
return;
}
var imported = await window.CrankApi.importOperation(wizardWorkspaceId, yamlDocument, 'upsert');
showWizardLiveStatus('YAML imported', 'The imported operation draft was stored. The wizard will reopen it now.');
showWizardLiveStatus(tKey('wizard.yaml.imported'), tKey('wizard.yaml.imported_body'));
window.location.href = window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(imported.operation_id);
}
@@ -1695,8 +1703,8 @@ async function publishWizardOperation() {
);
await refreshCurrentOperationState();
showWizardLiveStatus(
'Operation published',
'Version ' + published.published_version + ' is now published and can be bound into agents.'
tKey('wizard.publish.done'),
tfKey('wizard.publish.done_body', { version: published.published_version })
);
}
@@ -1706,7 +1714,7 @@ function handleImportYamlFileSelection(event) {
var reader = new FileReader();
reader.onload = function(loadEvent) {
setValue('wizard-import-yaml-text', loadEvent.target.result || '');
showWizardLiveStatus('YAML loaded', 'The YAML document was loaded into the import editor.');
showWizardLiveStatus(tKey('wizard.yaml.loaded'), tKey('wizard.yaml.loaded_body'));
};
reader.readAsText(file);
}
@@ -1722,7 +1730,7 @@ function handleDescriptorSetSelection(event) {
};
var fileNameEl = document.getElementById('wizard-descriptor-set-name');
if (fileNameEl) fileNameEl.textContent = file.name;
showWizardLiveStatus('Descriptor set selected', 'Upload the descriptor set to discover unary gRPC methods from the backend.');
showWizardLiveStatus(tKey('wizard.descriptor.selected'), tKey('wizard.descriptor.selected_body'));
};
reader.readAsArrayBuffer(file);
}
@@ -1737,8 +1745,8 @@ function renderDescriptorServiceList(services) {
return total + ((service.methods || []).length);
}, 0);
summaryEl.textContent = grpcDescriptorServices.length
? (grpcDescriptorServices.length + ' services · ' + methodCount + ' unary methods discovered')
: 'No unary methods discovered yet';
? tfKey('wizard.grpc.services_discovered', { services: grpcDescriptorServices.length, methods: methodCount })
: tKey('wizard.grpc.services_none');
listEl.innerHTML = '';
grpcDescriptorServices.forEach(function(service, serviceIndex) {
@@ -1788,7 +1796,7 @@ function applyDiscoveredGrpcMethod(serviceIndex, methodIndex) {
grpcManualTypeInput();
setValue('tool-input-schema', JSON.stringify(crankSchemaToJsonSchema(method.input_schema), null, 2));
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(method.output_schema), null, 2));
showWizardLiveStatus('gRPC method applied', 'The route and schemas were filled from the uploaded descriptor set.');
showWizardLiveStatus(tKey('wizard.descriptor.applied'), tKey('wizard.descriptor.applied_body'));
}
async function uploadDescriptorSetAndDiscover() {
@@ -1800,7 +1808,7 @@ async function uploadDescriptorSetAndDiscover() {
);
if (!wizardDescriptorSetUpload && !hasSavedDescriptor) {
showWizardLiveStatus('No descriptor set selected', 'Choose a descriptor-set file before uploading it, or save a draft that already has one attached.', true);
showWizardLiveStatus(tKey('wizard.descriptor.no_file'), tKey('wizard.descriptor.no_file_body'), true);
return;
}
@@ -1812,5 +1820,5 @@ async function uploadDescriptorSetAndDiscover() {
currentDraftVersion()
);
renderDescriptorServiceList(services.services || []);
showWizardLiveStatus('Descriptor services discovered', 'The backend parsed the descriptor set. Pick a method to apply its route and schemas.');
showWizardLiveStatus(tKey('wizard.descriptor.discovered'), tKey('wizard.descriptor.discovered_body'));
}
+37 -28
View File
@@ -9,6 +9,12 @@ var workspaceFormState = {
invitations: [],
currentUserId: null,
};
function tKey(key) {
return typeof t === 'function' ? t(key) : key;
}
function tfKey(key, vars) {
return typeof tf === 'function' ? tf(key, vars) : key;
}
function normalizeSlug(value) {
return value
@@ -26,9 +32,10 @@ function uiRoleToApi(value) {
}
function roleLabel(value) {
if (value === 'operator') {
return 'Developer';
}
if (value === 'operator') return tKey('workspace_setup.role.operator');
if (value === 'owner') return tKey('workspace_setup.role.owner');
if (value === 'admin') return tKey('workspace_setup.role.admin');
if (value === 'viewer') return tKey('workspace_setup.role.viewer');
return value.charAt(0).toUpperCase() + value.slice(1);
}
@@ -142,7 +149,9 @@ function renderMembers() {
workspaceFormState.memberships.forEach(function (membership) {
addMemberRow(container, membership);
});
label.textContent = workspaceFormState.memberships.length + ' member' + (workspaceFormState.memberships.length === 1 ? '' : 's');
label.textContent = workspaceFormState.memberships.length === 1
? tKey('workspace_setup.member_count_one')
: tfKey('workspace_setup.member_count', { count: workspaceFormState.memberships.length });
}
function renderInvitations() {
@@ -165,7 +174,7 @@ function renderInvitations() {
pending.style.display = '';
pending.innerHTML =
'<div style="font-size:11px;font-weight:600;text-transform:uppercase;letter-spacing:0.06em;color:var(--text-muted);margin-bottom:10px;">' +
'Pending invites <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">' + items.length + '</span>' +
tKey('workspace_setup.members.pending') + ' <span id="pending-count" style="background:rgba(139,148,158,0.15);border-radius:10px;padding:1px 7px;font-size:10px;">' + items.length + '</span>' +
'</div>';
items.forEach(function (record) {
@@ -176,7 +185,7 @@ function renderInvitations() {
'<div class="member-avatar" style="background:var(--bg-canvas);border:1.5px dashed var(--border);color:var(--text-muted);font-size:16px;">?</div>' +
'<div class="member-info">' +
'<div class="member-name">' + invitation.email + '</div>' +
'<div class="member-email">Invited ' + invitation.created_at.slice(0, 10) + ' · pending</div>' +
'<div class="member-email">' + tfKey('workspace_setup.members.invited_pending', { date: invitation.created_at.slice(0, 10) }) + '</div>' +
'</div>' +
'<div class="member-last-active">—</div>' +
'<span class="member-role-badge" style="background:rgba(139,148,158,0.12);color:var(--text-muted);border:1px solid var(--border);">' + roleLabel(invitation.role) + '</span>' +
@@ -213,19 +222,19 @@ function updatePageMode() {
var submit = document.getElementById('submit-btn');
if (workspaceFormState.isCreateMode) {
document.title = 'Crank — Create workspace';
document.getElementById('page-title').textContent = 'Create a new workspace';
document.getElementById('page-subtitle').textContent = 'A workspace is a shared environment for your team\'s MCP operations and agents. Each workspace gets its own MCP endpoint namespace.';
document.title = 'Crank — ' + tKey('workspace_setup.create.title');
document.getElementById('page-title').textContent = tKey('workspace_setup.create.title');
document.getElementById('page-subtitle').textContent = tKey('workspace_setup.create.subtitle');
document.getElementById('section-invite').style.display = '';
document.getElementById('footer-note').style.display = '';
submit.textContent = 'Create workspace';
submit.textContent = tKey('workspace_setup.actions.create');
submit.disabled = true;
submit.style.opacity = '0.5';
submit.style.cursor = 'not-allowed';
} else {
document.getElementById('section-members').style.display = '';
document.getElementById('section-danger').style.display = '';
submit.textContent = 'Save changes';
submit.textContent = tKey('workspace_setup.actions.save');
}
}
@@ -289,7 +298,7 @@ async function submitForm() {
var submit = document.getElementById('submit-btn');
var originalLabel = submit.textContent;
submit.disabled = true;
submit.textContent = workspaceFormState.isCreateMode ? 'Creating…' : 'Saving…';
submit.textContent = workspaceFormState.isCreateMode ? tKey('workspace_setup.saving_create') : tKey('workspace_setup.saving_update');
try {
var response;
@@ -317,7 +326,7 @@ async function submitForm() {
id: workspace.id,
slug: workspace.slug,
name: workspace.display_name,
role: 'Owner',
role: tKey('workspace_setup.role.owner'),
letter: (workspace.display_name || workspace.slug).charAt(0).toUpperCase(),
color: payload.settings.color || '#0d9488',
status: workspace.status,
@@ -336,14 +345,14 @@ async function submitForm() {
applyWorkspaceRecord({ workspace: workspace });
await loadWorkspaceAccessData();
submit.textContent = 'Saved ✓';
submit.textContent = tKey('workspace_setup.saved');
setTimeout(function () {
submit.textContent = originalLabel;
submit.disabled = false;
}, 1400);
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to save workspace', 'Save failed');
window.CrankUi.error(error.message || tKey('workspace_setup.save_error'), tKey('workspace_setup.save_error_title'));
}
submit.disabled = false;
submit.textContent = originalLabel;
@@ -357,11 +366,11 @@ function addInviteRow() {
node.innerHTML =
'<input class="form-input" type="email" placeholder="colleague@company.com" autocomplete="off" style="flex:1;margin-bottom:0;">' +
'<select class="form-input" style="width:130px;margin-bottom:0;">' +
'<option value="admin">Admin</option>' +
'<option value="developer" selected>Developer</option>' +
'<option value="viewer">Viewer</option>' +
'<option value="admin">' + tKey('workspace_setup.role.admin') + '</option>' +
'<option value="developer" selected>' + tKey('workspace_setup.role.operator') + '</option>' +
'<option value="viewer">' + tKey('workspace_setup.role.viewer') + '</option>' +
'</select>' +
'<button class="invite-row-remove" type="button" onclick="removeInviteRow(this)" title="Remove">' +
'<button class="invite-row-remove" type="button" onclick="removeInviteRow(this)" title="' + tKey('workspace_setup.members.remove') + '">' +
'<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/close.svg#icon"/></svg>' +
'</button>';
rows.appendChild(node);
@@ -407,7 +416,7 @@ async function sendInvite() {
await loadWorkspaceAccessData();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to send invite', 'Invite failed');
window.CrankUi.error(error.message || tKey('workspace_setup.invite_error'), tKey('workspace_setup.invite_error_title'));
}
}
}
@@ -429,7 +438,7 @@ async function updateRole(select, userId) {
} catch (error) {
select.value = previous;
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to update role', 'Role update failed');
window.CrankUi.error(error.message || tKey('workspace_setup.role_error'), tKey('workspace_setup.role_error_title'));
}
} finally {
select.disabled = false;
@@ -440,7 +449,7 @@ async function removeMember(userId, name) {
if (!workspaceFormState.workspaceId || !window.CrankApi) {
return;
}
if (!confirm('Remove ' + name + ' from this workspace?')) {
if (!confirm(tfKey('workspace_setup.remove_confirm', { name: name }))) {
return;
}
@@ -449,7 +458,7 @@ async function removeMember(userId, name) {
await loadWorkspaceAccessData();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to remove member', 'Member removal failed');
window.CrankUi.error(error.message || tKey('workspace_setup.remove_error'), tKey('workspace_setup.remove_error_title'));
}
}
}
@@ -464,7 +473,7 @@ async function revokeInviteById(invitationId) {
await loadWorkspaceAccessData();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to revoke invite', 'Invite revoke failed');
window.CrankUi.error(error.message || tKey('workspace_setup.revoke_error'), tKey('workspace_setup.revoke_error_title'));
}
}
}
@@ -496,11 +505,11 @@ async function exportWorkspaceSnapshot() {
var snapshot = await window.CrankApi.exportWorkspace(workspaceFormState.workspaceId);
var slug = workspaceFormState.workspaceRecord && workspaceFormState.workspaceRecord.workspace
? workspaceFormState.workspaceRecord.workspace.slug
: 'workspace';
: tKey('settings.nav.workspace');
downloadJsonFile(slug + '-snapshot.json', snapshot);
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to export workspace', 'Export failed');
window.CrankUi.error(error.message || tKey('workspace_setup.export_error'), tKey('workspace_setup.export_error_title'));
}
}
}
@@ -509,7 +518,7 @@ async function deleteWorkspaceAction() {
if (!workspaceFormState.workspaceId || !window.CrankApi || !window.CrankAuth) {
return;
}
if (!confirm('Delete this workspace? This cannot be undone.')) {
if (!confirm(tKey('workspace_setup.delete_confirm'))) {
return;
}
@@ -518,7 +527,7 @@ async function deleteWorkspaceAction() {
await window.CrankAuth.logout();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to delete workspace', 'Delete failed');
window.CrankUi.error(error.message || tKey('workspace_setup.delete_error'), tKey('workspace_setup.delete_error_title'));
}
}
}
+9 -3
View File
@@ -5,6 +5,12 @@ var WS_LIST = [
var workspaceLoadPromise = null;
var workspaceLoadFailed = false;
var lastWorkspaceId = null;
function tKey(key) {
return typeof t === 'function' ? t(key) : key;
}
function tfKey(key, vars) {
return typeof tf === 'function' ? tf(key, vars) : key;
}
function workspaceColor(index) {
return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5];
@@ -16,7 +22,7 @@ function mapWorkspace(record, index) {
var settings = workspace.settings || {};
var role = record && record.role
? String(record.role).replace(/^\w/, function(char) { return char.toUpperCase(); })
: 'Owner';
: tKey('workspace_setup.role.owner');
return {
id: workspace.id,
slug: workspace.slug,
@@ -119,7 +125,7 @@ function renderWorkspaceList() {
'<div class="ws-dropdown-divider"></div>' +
'<a class="ws-dropdown-mgmt-link" href="' + ((window.CrankRoutes && window.CrankRoutes.workspaceSetup) || '/workspace-setup') + '" onclick="var dd=document.getElementById(\'ws-dropdown\');if(dd)dd.style.display=\'none\'">' +
'<svg width="14" height="14"><use href="' + (window.APP_BASE || '') + 'icons/general/settings.svg#icon"/></svg>' +
'Workspace settings' +
tKey('settings.ws.title') +
'</a>';
}
@@ -191,7 +197,7 @@ function switchWorkspace(workspaceId) {
})
.catch(function(error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || 'Failed to switch workspace', 'Workspace switch failed');
window.CrankUi.error(error.message || tKey('workspace.switch_error'), tKey('workspace.switch_error_title'));
}
throw error;
});