feat: complete Epic 1 production foundation

This commit is contained in:
2026-08-25 01:24:11 +03:00
parent 767428436d
commit 182bde8ac0
298 changed files with 35719 additions and 5299 deletions
+20
View File
@@ -135,6 +135,26 @@
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
.refresh-btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.log-pagination {
display: flex;
justify-content: center;
padding: 12px 20px 18px;
}
.log-pagination .refresh-btn {
margin-left: 0;
}
.log-id-filter {
width: 180px;
margin: 0;
}
.approval-panel {
margin-bottom: 18px;
}
+82
View File
@@ -0,0 +1,82 @@
.onboarding-trigger {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 780;
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 42px;
padding: 9px 14px;
border: 1px solid var(--border-color, #30363d);
border-radius: 999px;
background: var(--surface-raised, #161b22);
color: var(--text-primary, #f0f6fc);
box-shadow: 0 8px 28px rgba(0, 0, 0, .28);
cursor: pointer;
font: inherit;
font-weight: 600;
}
.onboarding-trigger:hover { border-color: var(--accent, #58a6ff); }
.onboarding-trigger:focus-visible,
.onboarding-panel button:focus-visible,
.onboarding-panel a:focus-visible { outline: 2px solid var(--accent, #58a6ff); outline-offset: 2px; }
.onboarding-panel {
position: fixed;
right: 22px;
bottom: 76px;
z-index: 790;
width: min(410px, calc(100vw - 28px));
max-height: min(700px, calc(100vh - 104px));
overflow: auto;
scroll-behavior: auto;
border: 1px solid var(--border-color, #30363d);
border-radius: 14px;
background: var(--surface-raised, #161b22);
color: var(--text-primary, #f0f6fc);
box-shadow: 0 18px 48px rgba(0, 0, 0, .4);
}
.onboarding-panel[hidden] { display: none; }
.onboarding-head { display: flex; justify-content: space-between; gap: 12px; padding: 17px 18px 12px; border-bottom: 1px solid var(--border-color, #30363d); }
.onboarding-title { margin: 0; font-size: 17px; }
.onboarding-subtitle { margin: 4px 0 0; color: var(--text-muted, #8b949e); font-size: 12px; }
.onboarding-head-actions { display: flex; gap: 4px; }
.onboarding-icon-button { border: 0; background: transparent; color: inherit; cursor: pointer; border-radius: 6px; min-width: 32px; min-height: 32px; }
.onboarding-body { padding: 14px 18px 18px; }
.onboarding-status { min-height: 20px; color: var(--text-muted, #8b949e); font-size: 12px; margin-bottom: 10px; }
.onboarding-error { padding: 12px; border: 1px solid var(--danger, #f85149); border-radius: 8px; color: var(--danger, #f85149); }
.onboarding-error-ids { margin-top: 6px; overflow-wrap: anywhere; font-size: 12px; }
.onboarding-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; }
.onboarding-step { display: grid; grid-template-columns: 26px 1fr; gap: 9px; padding: 10px; border: 1px solid var(--border-color, #30363d); border-radius: 9px; }
.onboarding-step[aria-current="step"] { border-color: var(--accent, #58a6ff); background: rgba(88, 166, 255, .08); }
.onboarding-step.is-regressed { border-color: var(--amber, #d29922); }
.onboarding-step-marker { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--surface-muted, #21262d); font-size: 12px; }
.onboarding-step.is-complete .onboarding-step-marker { background: var(--success, #3fb950); color: #07140a; }
.onboarding-step-title { font-weight: 600; font-size: 13px; }
.onboarding-step-reason { margin-top: 3px; color: var(--text-muted, #8b949e); font-size: 12px; }
.onboarding-step-action { margin-top: 8px; border: 0; border-radius: 7px; padding: 7px 10px; background: var(--accent, #58a6ff); color: #08111c; font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; }
.onboarding-completion { padding: 12px; border: 1px solid var(--success, #3fb950); border-radius: 9px; overflow-wrap: anywhere; }
.onboarding-completion a { color: var(--accent, #58a6ff); }
.onboarding-first-call-evidence { display: grid; grid-template-columns: max-content 1fr; gap: 4px 10px; margin: 10px 0; font-size: 12px; }
.onboarding-first-call-evidence dt { color: var(--text-muted, #8b949e); }
.onboarding-first-call-evidence dd { margin: 0; font-family: var(--font-mono, monospace); overflow-wrap: anywhere; }
.onboarding-deep-link-stale { position: fixed; left: 50%; top: 76px; z-index: 810; width: min(520px, calc(100vw - 28px)); transform: translateX(-50%); padding: 14px; border: 1px solid var(--amber, #d29922); border-radius: 10px; background: var(--surface-raised, #161b22); color: var(--text-primary, #f0f6fc); box-shadow: 0 12px 36px rgba(0, 0, 0, .35); }
.onboarding-footer { display: flex; justify-content: space-between; gap: 8px; margin-top: 12px; }
.onboarding-link-button { border: 0; background: transparent; color: var(--text-muted, #8b949e); text-decoration: underline; cursor: pointer; font: inherit; font-size: 12px; }
/* Active task surfaces own the pointer plane. The optional onboarding helper
must never cover drawer controls while guiding the user through that drawer. */
body:has(.drawer.open) .onboarding-trigger,
body:has(.drawer.open) .onboarding-panel { z-index: 149; }
@media (max-width: 640px) {
.onboarding-trigger { right: 14px; bottom: 14px; }
.onboarding-panel { right: 14px; bottom: 68px; }
}
@media (prefers-reduced-motion: reduce) {
.onboarding-panel, .onboarding-trigger { transition: none !important; animation: none !important; scroll-behavior: auto; }
}
+6 -4
View File
@@ -215,6 +215,7 @@
</button>
</div>
<div class="agent-card-date" style="margin-top:8px;" x-text="endpointHelpText(agent)"></div>
<div class="agent-card-date" style="margin-top:6px;" x-text="agentRevisionText(agent)"></div>
<!-- Footer -->
<div class="agent-card-footer">
@@ -224,15 +225,15 @@
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M11 2l3 3-8 8H3v-3l8-8z"/></svg>
<span data-i18n="agents.action.edit">Edit</span>
</button>
<button class="agent-action-btn" @click.stop="applyLifecycle(agent, lifecycleAction(agent).key)">
<button class="agent-action-btn" :disabled="lifecycleDisabled(agent)" @click.stop="applyLifecycle(agent, lifecycleAction(agent).key)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3.5v9l8-4.5-8-4.5z"/></svg>
<span x-text="lifecycleAction(agent).label"></span>
</button>
<button class="agent-action-btn" x-show="agent.raw_status !== 'archived'" @click.stop="applyLifecycle(agent, 'archive')">
<button class="agent-action-btn" x-show="agent.raw_status !== 'archived'" :disabled="lifecycleDisabled(agent)" @click.stop="applyLifecycle(agent, 'archive')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2.5 4.5h11v2l-1 6.5h-8l-1-6.5v-2z"/><path d="M5 4.5v-1A1.5 1.5 0 016.5 2h3A1.5 1.5 0 0111 3.5v1"/></svg>
<span data-i18n="agents.action.archive">Archive</span>
</button>
<button class="agent-action-btn danger" @click.stop="deleteAgent(agent.id)">
<button class="agent-action-btn danger" :disabled="lifecycleDisabled(agent)" @click.stop="deleteAgent(agent.id)">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
<span data-i18n="agents.action.delete">Delete</span>
</button>
@@ -295,6 +296,7 @@
<div class="form-group">
<label class="form-label" data-i18n="agents.drawer.status">Status</label>
<div class="form-hint" x-show="drawerMode === 'edit'" x-text="drawerRevisionText()"></div>
<div class="agent-status-toggle">
<button class="agent-status-opt" :class="{ active: form.status === 'published' }" @click="form.status = 'published'">
<span class="status-dot" style="background: var(--success, #3fb950)"></span> <span data-i18n="agents.lifecycle.published">Published</span>
@@ -474,7 +476,7 @@
<div class="drawer-footer">
<button class="btn-ghost-sm" style="padding: 8px 16px; font-size: 13px;" @click="closeDrawer()" data-i18n="agents.drawer.cancel">Cancel</button>
<button class="btn-primary-sm" style="padding: 8px 20px; font-size: 13px;"
:disabled="!form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
:disabled="saving || !form.display_name.trim() || !form.slug.trim() || !catalogConfigValid"
@click="saveAgent()"
x-text="drawerMode === 'create' ? tKey('agents.drawer.create') : tKey('agents.drawer.save')">
Create agent
+22 -2
View File
@@ -251,10 +251,10 @@
<!-- ══ Create Key Modal ══ -->
<div class="modal-overlay" id="modal-create">
<div class="modal">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modal-create-title">
<div class="modal-header">
<span class="modal-title" id="modal-create-title" data-i18n="apikeys.modal.title">Create agent key</span>
<button class="modal-close" id="modal-close-btn" type="button">
<button class="modal-close" id="modal-close-btn" type="button" aria-label="Close">
<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>
@@ -277,6 +277,15 @@
<div style="display:flex;flex-direction:column;gap:8px;margin-top:2px;" id="scope-checkboxes">
</div>
</div>
<div class="callout warning" id="ambiguous-create-warning" data-testid="ambiguous-create-warning" hidden>
<div>
<strong data-i18n="apikeys.ambiguous.title">Creation result is uncertain.</strong>
<span data-i18n="apikeys.ambiguous.body">Key metadata was refreshed. Review the list before deliberately creating another key.</span>
<div style="margin-top:10px;">
<button class="btn-secondary" id="ambiguous-create-retry-btn" type="button" data-i18n="apikeys.ambiguous.retry">Refresh metadata and allow another attempt</button>
</div>
</div>
</div>
</div>
<div class="modal-body" id="modal-reveal-body" hidden>
<div class="callout warning" style="margin-bottom:16px;">
@@ -294,6 +303,13 @@
</svg>
</button>
</div>
<div class="callout warning" id="onboarding-clipboard-warning" data-testid="onboarding-clipboard-warning" hidden style="margin-top:12px;">
<div data-i18n="apikeys.modal.clipboard_warning">Your operating system clipboard is outside Crank control. Clear it after saving the key.</div>
</div>
<div id="onboarding-connection-config" data-testid="onboarding-connection-config" hidden style="margin-top:14px;">
<div class="field-label" data-i18n="apikeys.modal.connection_title">MCP connection configuration</div>
<div id="onboarding-connection-clients"></div>
</div>
</div>
<div class="modal-footer" id="modal-footer-create">
<button class="btn-secondary" id="modal-cancel-btn" type="button" data-i18n="apikeys.modal.cancel">Cancel</button>
@@ -305,6 +321,10 @@
</div>
</div>
<div class="callout warning" id="onboarding-key-lost" data-testid="onboarding-key-lost" hidden style="position:fixed;right:22px;bottom:82px;z-index:760;max-width:420px;">
<div data-i18n="apikeys.onboarding.key_lost">The key cannot be shown again. Create a new key and revoke the old one if its value was not saved.</div>
</div>
<!-- Template: scope checkbox row -->
<template id="tmpl-scope-checkbox">
<label class="scope-checkbox-label">
+7 -2
View File
@@ -30,17 +30,22 @@
</div>
<form id="login-form" novalidate>
<div class="field">
<div class="field" id="login-email-field">
<label class="field-label" for="email" data-i18n="login.email_label">Email address</label>
<input class="field-input" type="email" id="email" data-i18n-ph="login.email_placeholder" placeholder="you@acme.com" autocomplete="email" autofocus>
</div>
<div class="field" id="login-bootstrap-token-field" hidden>
<label class="field-label" for="bootstrap-token" data-i18n="login.bootstrap.token_label">Bootstrap token</label>
<input class="field-input" type="password" id="bootstrap-token" data-i18n-ph="login.bootstrap.token_placeholder" placeholder="Paste one-time bootstrap token" autocomplete="one-time-code"> <!-- community-scope: allow=one-time-token -->
</div>
<div class="field">
<label class="field-label" for="password" data-i18n="login.password">Password</label>
<input class="field-input" type="password" id="password" placeholder="••••••••" autocomplete="current-password">
</div>
<button class="btn-signin" type="submit" data-i18n="login.submit">Sign in</button>
<button class="btn-signin" id="login-submit" type="submit" data-i18n="login.submit">Sign in</button>
</form>
</div>
+33
View File
@@ -108,6 +108,31 @@
<div class="toolbar-sep"></div>
<select class="time-range-select" id="status-filter" data-i18n-title="logs.status_filter" title="Status">
<option value="all" selected data-i18n="logs.status.all">All statuses</option>
<option value="ok" data-i18n="logs.status.ok">Success</option>
<option value="error" data-i18n="logs.status.error">Error</option>
</select>
<select class="time-range-select" id="outcome-filter" data-i18n-title="logs.outcome_filter" title="Outcome">
<option value="all" selected data-i18n="logs.outcome.all">All outcomes</option>
<option value="success" data-i18n="usage.outcome.success">Success</option>
<option value="upstream" data-i18n="usage.outcome.upstream">Upstream</option>
<option value="client" data-i18n="usage.outcome.client">Client</option>
<option value="schema" data-i18n="usage.outcome.schema">Schema</option>
<option value="crank" data-i18n="usage.outcome.crank">Crank</option>
</select>
<div class="filter-bar-search log-id-filter">
<input type="text" id="operation-filter" data-i18n-ph="logs.filter.operation" placeholder="Operation ID" autocomplete="off" spellcheck="false">
</div>
<div class="filter-bar-search log-id-filter">
<input type="text" id="agent-filter" data-i18n-ph="logs.filter.agent" placeholder="Agent ID" autocomplete="off" spellcheck="false">
</div>
<div class="toolbar-sep"></div>
<button class="filter-chip active" data-level="all" id="chip-all" data-i18n="logs.level.all">All</button>
<button class="filter-chip" data-level="info" id="chip-info">
<span class="log-level info" style="padding:0 4px;font-size:10px;">INFO</span>
@@ -128,10 +153,18 @@
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
<span data-i18n="logs.refresh">Refresh</span>
</button>
<button class="refresh-btn" id="export-logs-btn" type="button">
<span data-i18n="logs.export">Export CSV</span>
</button>
</div>
<div class="log-list" id="log-list">
</div>
<div class="log-pagination">
<button class="refresh-btn" id="load-more-logs-btn" type="button" hidden>
<span data-i18n="logs.load_more">Load more</span>
</button>
</div>
</div>
</div>
+10
View File
@@ -147,6 +147,16 @@
</div>
</div>
<div class="section-card" style="margin-bottom:20px;">
<div class="section-card-header">
<div>
<div class="section-card-title" data-i18n="usage.outcomes.title">Outcomes</div>
<div class="section-card-subtitle" data-i18n="usage.outcomes.subtitle">Grouped by safe execution outcome.</div>
</div>
</div>
<div class="resource-list usage-card-list" id="usage-outcome-list" style="display:grid;padding:16px 20px;"></div>
</div>
<!-- Per-operation table -->
<div class="section-card">
<div class="section-card-header">
+11 -1
View File
@@ -131,7 +131,7 @@ tls:
<div class="section-divider-line"></div>
</div>
<div id="wizard-live-status" class="info-callout" hidden style="margin-bottom: 20px;">
<div id="wizard-live-status" class="info-callout" role="status" aria-live="polite" aria-atomic="true" hidden style="margin-bottom: 20px;">
<svg class="info-callout-icon" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<circle cx="8" cy="8" r="7"/>
<path d="M8 11V8M8 5v-.5"/>
@@ -284,6 +284,16 @@ tls:
<button id="wizard-run-test" class="btn-primary-sm" type="button" data-i18n="wizard.step5.run_test">Запустить тест</button>
<button id="wizard-copy-test-response" class="btn-ghost-sm" type="button" data-i18n="wizard.step5.use_response_output">Использовать ответ как выходной пример</button>
</div>
<div id="wizard-test-correlation" class="info-callout" role="status" aria-live="polite" hidden>
<div class="info-callout-body">
<div><span data-i18n="wizard.test.request_id">Request ID</span>: <code id="wizard-test-request-id"></code></div>
<div><span data-i18n="wizard.test.trace_id">Trace ID</span>: <code id="wizard-test-trace-id"></code></div>
<div style="display:flex; gap:8px; flex-wrap:wrap; margin-top:8px;">
<button id="wizard-copy-request-id" class="btn-ghost-sm" type="button" data-i18n="wizard.test.copy_request_id">Copy Request ID</button>
<button id="wizard-copy-trace-id" class="btn-ghost-sm" type="button" data-i18n="wizard.test.copy_trace_id">Copy Trace ID</button>
</div>
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.step5.request_preview">Предпросмотр API-запроса</label>
+4 -1
View File
@@ -341,9 +341,12 @@
<button class="row-btn row-btn-edit" :title="tKey('ops.action.edit')" @click="editOperation(op)">
<svg width="14" height="14"><use href="icons/general/edit.svg#icon"/></svg>
</button>
<button class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
<button x-show="op.can_delete" class="row-btn row-btn-delete" :title="tKey('ops.action.delete')" @click="deleteOperation(op.id)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M2 4h12M5 4V3a1 1 0 011-1h4a1 1 0 011 1v1M10 8v4M6 8v4"/><path d="M3 4l1 9a1 1 0 001 1h6a1 1 0 001-1l1-9"/></svg>
</button>
<button x-show="op.raw_status !== 'archived' && !op.can_delete" class="row-btn" data-testid="operation-archive" :title="tKey('ops.action.archive')" @click="archiveOperation(op)">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M2 3h12v3H2zM3 6h10v7H3zM6 9h4"/></svg>
</button>
</div>
</td>
</tr>
+123 -18
View File
@@ -20,6 +20,7 @@ function mapAgent(agent) {
created_at: agent.created_at,
current_draft_version: agent.current_draft_version || 1,
latest_published_version: agent.latest_published_version,
catalog_revision: typeof agent.catalog_revision === 'number' ? agent.catalog_revision : 0,
mcp_endpoint: agent.mcp_endpoint || '',
};
@@ -60,6 +61,7 @@ document.addEventListener('alpine:init', function() {
drawerMode: 'create',
editingId: null,
saving: false,
lifecycleBusyId: null,
form: {
display_name: '',
@@ -79,6 +81,9 @@ document.addEventListener('alpine:init', function() {
searchPreviewItems: [],
searchPreviewLoading: false,
searchPreviewRan: false,
_loadGeneration: 0,
_mutationGeneration: 0,
_queryHydrated: false,
async init() {
var self = this;
@@ -88,6 +93,7 @@ document.addEventListener('alpine:init', function() {
this.workspaceId = workspace ? workspace.id : null;
await this.loadCapabilities();
await this.reload();
this.hydrateOnboardingQuery();
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && self.drawerOpen) {
@@ -100,6 +106,9 @@ document.addEventListener('alpine:init', function() {
});
window.addEventListener('crank:workspacechange', async function(event) {
self._loadGeneration += 1;
self._mutationGeneration += 1;
self._queryHydrated = false;
self.workspaceId = event.detail ? event.detail.id : null;
await self.loadCapabilities();
await self.reload();
@@ -119,6 +128,8 @@ document.addEventListener('alpine:init', function() {
},
async reload() {
var generation = ++this._loadGeneration;
var workspaceId = this.workspaceId;
this.loading = true;
this.loadError = '';
@@ -132,18 +143,39 @@ document.addEventListener('alpine:init', function() {
try {
var responses = await Promise.all([
window.CrankApi.listAgents(this.workspaceId),
window.CrankApi.listOperations(this.workspaceId),
window.CrankApi.listAgents(workspaceId),
window.CrankApi.listOperations(workspaceId),
]);
if (generation !== this._loadGeneration || workspaceId !== this.workspaceId) return;
this.agents = ((responses[0] && responses[0].items) || []).map(mapAgent);
this.operations = ((responses[1] && responses[1].items) || []).map(mapOperation);
} catch (error) {
if (generation !== this._loadGeneration || workspaceId !== this.workspaceId) return;
this.agents = [];
this.operations = [];
this.loadError = error.message || this.tKey('agents.error.load');
}
this.loading = false;
if (generation === this._loadGeneration && workspaceId === this.workspaceId) this.loading = false;
},
hydrateOnboardingQuery() {
if (this._queryHydrated) return;
this._queryHydrated = true;
var params = new URLSearchParams(window.location.search);
if (params.get('onboarding') !== '1' || params.get('action') !== 'create') return;
var operationId = params.get('operationId') || '';
var expectedVersion = Number(params.get('operationVersion') || 0);
var operation = this.operations.find(function(item) { return item.id === operationId; });
this.openCreate();
if (operation && operation.latest_published_version
&& (!expectedVersion || operation.latest_published_version === expectedVersion)) {
this.form.selectedOps = [operation.id];
}
setTimeout(function() {
var input = document.querySelector('.drawer input.form-input');
if (input) input.focus();
}, 0);
},
get filteredAgents() {
@@ -254,6 +286,9 @@ document.addEventListener('alpine:init', function() {
accessMode: 'direct',
groups: [],
searchMaxResults: 8,
catalogRevision: 0,
currentDraftVersion: 1,
latestPublishedVersion: null,
};
this.opSearch = '';
this.slugManuallyEdited = false;
@@ -283,6 +318,9 @@ document.addEventListener('alpine:init', function() {
searchMaxResults: policy.search && policy.search.max_results
? policy.search.max_results
: 8,
catalogRevision: agent.catalog_revision || 0,
currentDraftVersion: agent.current_draft_version || 1,
latestPublishedVersion: agent.latest_published_version,
};
this.opSearch = '';
this.slugManuallyEdited = true;
@@ -293,6 +331,7 @@ document.addEventListener('alpine:init', function() {
closeDrawer() {
this.drawerOpen = false;
this.saving = false;
this.lifecycleBusyId = null;
},
onNameInput(value) {
@@ -514,6 +553,8 @@ document.addEventListener('alpine:init', function() {
}
this.saving = true;
var mutationGeneration = ++this._mutationGeneration;
var workspaceId = this.workspaceId;
try {
var agentId = this.editingId;
@@ -524,7 +565,7 @@ document.addEventListener('alpine:init', function() {
var previousStatus = existingAgent ? (existingAgent.raw_status || 'draft') : 'draft';
if (this.drawerMode === 'create') {
var created = await window.CrankApi.createAgent(this.workspaceId, {
var created = await window.CrankApi.createAgent(workspaceId, {
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
@@ -534,17 +575,17 @@ document.addEventListener('alpine:init', function() {
agentId = created.agent_id;
currentVersion = created.version || 1;
} else {
await window.CrankApi.updateAgent(this.workspaceId, this.editingId, {
await window.CrankApi.updateAgent(workspaceId, this.editingId, {
slug: this.form.slug,
display_name: this.form.display_name,
description: this.form.description,
});
var agent = await window.CrankApi.getAgent(this.workspaceId, this.editingId);
var agent = await window.CrankApi.getAgent(workspaceId, this.editingId);
currentVersion = agent.current_draft_version || 1;
}
var savedVersion = await window.CrankApi.saveAgentBindings(
this.workspaceId,
workspaceId,
agentId,
{
bindings: this.agentBindings(),
@@ -554,16 +595,19 @@ document.addEventListener('alpine:init', function() {
currentVersion = savedVersion.version || currentVersion;
if (this.form.status === 'published') {
await window.CrankApi.publishAgent(this.workspaceId, agentId, {
await window.CrankApi.publishAgent(workspaceId, agentId, {
version: currentVersion,
});
} else if (this.form.status === 'archived') {
await window.CrankApi.archiveAgent(this.workspaceId, agentId);
await window.CrankApi.archiveAgent(workspaceId, agentId);
} else if (this.drawerMode === 'edit' && previousStatus !== 'draft') {
await window.CrankApi.unpublishAgent(this.workspaceId, agentId);
await window.CrankApi.unpublishAgent(workspaceId, agentId);
}
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
await this.reload();
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.success(
this.tfKey('agents.toast.saved_message', {
@@ -576,10 +620,12 @@ document.addEventListener('alpine:init', function() {
);
}
this.closeDrawer();
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
} catch (error) {
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.toast.save_error_message'),
this.agentMutationErrorMessage(error, this.tKey('agents.toast.save_error_message')),
this.tKey('agents.toast.save_error_title')
);
}
@@ -588,9 +634,11 @@ document.addEventListener('alpine:init', function() {
},
async deleteAgent(id) {
if (this.lifecycleBusyId) return;
if (!confirm(this.tKey('agents.toast.delete_confirm'))) return;
try {
this.lifecycleBusyId = id;
var agent = this.agents.find(function(item) { return item.id === id; });
await window.CrankApi.deleteAgent(this.workspaceId, id);
await this.reload();
@@ -603,29 +651,44 @@ document.addEventListener('alpine:init', function() {
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.toast.delete_error_message'),
this.agentMutationErrorMessage(error, this.tKey('agents.toast.delete_error_message')),
this.tKey('agents.toast.delete_error_title')
);
}
} finally {
this.lifecycleBusyId = null;
}
},
async applyLifecycle(agent, action) {
if (!this.workspaceId || !window.CrankApi) {
if (!this.workspaceId || !window.CrankApi || this.lifecycleBusyId) {
return;
}
var confirmKey = action === 'publish'
? 'agents.toast.lifecycle_publish_confirm'
: action === 'unpublish'
? 'agents.toast.lifecycle_unpublish_confirm'
: 'agents.toast.lifecycle_archive_confirm';
if (!confirm(this.tfKey(confirmKey, { name: agent.display_name }))) {
return;
}
try {
var mutationGeneration = ++this._mutationGeneration;
var workspaceId = this.workspaceId;
this.lifecycleBusyId = agent.id;
if (action === 'publish') {
await window.CrankApi.publishAgent(this.workspaceId, agent.id, {
await window.CrankApi.publishAgent(workspaceId, agent.id, {
version: agent.current_draft_version || 1,
});
} else if (action === 'unpublish') {
await window.CrankApi.unpublishAgent(this.workspaceId, agent.id);
await window.CrankApi.unpublishAgent(workspaceId, agent.id);
} else if (action === 'archive') {
await window.CrankApi.archiveAgent(this.workspaceId, agent.id);
await window.CrankApi.archiveAgent(workspaceId, agent.id);
}
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
await this.reload();
if (mutationGeneration !== this._mutationGeneration || workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.success(
action === 'publish'
@@ -636,13 +699,17 @@ document.addEventListener('alpine:init', function() {
this.tKey('agents.toast.lifecycle_title')
);
}
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
} catch (error) {
if (workspaceId !== this.workspaceId) return;
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('agents.toast.lifecycle_error_message'),
this.agentMutationErrorMessage(error, this.tKey('agents.toast.lifecycle_error_message')),
this.tKey('agents.toast.lifecycle_error_title')
);
}
} finally {
this.lifecycleBusyId = null;
}
},
@@ -651,7 +718,7 @@ document.addEventListener('alpine:init', function() {
return { key: 'unpublish', label: this.tKey('agents.lifecycle.unpublish') };
}
if (agent.raw_status === 'archived') {
return { key: 'unpublish', label: this.tKey('agents.lifecycle.restore_draft') };
return { key: 'archive', label: this.tKey('agents.lifecycle.archived') };
}
return { key: 'publish', label: this.tKey('agents.lifecycle.publish') };
},
@@ -662,6 +729,44 @@ document.addEventListener('alpine:init', function() {
return this.tKey('agents.lifecycle.draft');
},
lifecycleDisabled(agent) {
return this.saving
|| agent.raw_status === 'archived'
|| (this.lifecycleBusyId && this.lifecycleBusyId !== agent.id);
},
agentRevisionText(agent) {
return this.tfKey('agents.card.revision', {
draft: agent.current_draft_version || 1,
published: agent.latest_published_version || '—',
revision: agent.catalog_revision || 0,
});
},
drawerRevisionText() {
if (this.drawerMode !== 'edit') return '';
return this.tfKey('agents.drawer.revision', {
draft: this.form.currentDraftVersion || 1,
published: this.form.latestPublishedVersion || '—',
revision: this.form.catalogRevision || 0,
});
},
agentMutationErrorMessage(error, fallback) {
var errorCode = error
&& error.payload
&& error.payload.error
&& error.payload.error.context
&& error.payload.error.context.error_code;
if (errorCode === 'agent_stale_revision' || errorCode === 'agent_precondition_required') {
return this.tKey('agents.toast.stale_message');
}
if (errorCode === 'agent_delete_forbidden') {
return this.tKey('agents.toast.delete_forbidden_message');
}
return error && error.message ? error.message : fallback;
},
mcpEndpoint(agent) {
if (agent.mcp_endpoint) return agent.mcp_endpoint;
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
+324 -52
View File
@@ -3,8 +3,15 @@ var AGENTS = [];
var currentWorkspaceId = null;
var currentAgentId = null;
var activeKeyKind = 'mcp_client';
var selectedScopes = new Set(['read']);
var selectedScopes = new Set(['read', 'write']);
var search = '';
var loadGeneration = 0;
var modalGeneration = 0;
var keyMutations = {};
var onboardingQueryHydrated = false;
var onboardingRevealWasCreated = false;
var createReconciliationRequired = false;
var revealedKeyGeneration = 0;
var SCOPES_BY_KIND = {
mcp_client: ['read', 'write', 'deploy'],
@@ -58,6 +65,7 @@ function mapAgentRecord(record) {
slug: record.slug,
displayName: record.display_name || record.slug || record.id,
mcpEndpoint: record.mcp_endpoint || '',
catalogRevision: Number(record.catalog_revision || 0),
};
}
@@ -67,11 +75,13 @@ function currentWorkspace() {
async function loadKeys() {
var workspace = currentWorkspace();
currentWorkspaceId = workspace ? workspace.id : null;
var workspaceId = workspace ? workspace.id : null;
var generation = ++loadGeneration;
currentWorkspaceId = workspaceId;
setTableLoading(true);
if (!currentWorkspaceId || !window.CrankApi) {
if (!workspaceId || !window.CrankApi) {
AGENTS = [];
KEYS = [];
currentAgentId = null;
@@ -79,11 +89,14 @@ async function loadKeys() {
setCreateButtonState();
setTableLoading(false);
renderTable(tKey('apikeys.error.api'));
return;
return false;
}
try {
var response = await window.CrankApi.listAgents(currentWorkspaceId);
var response = await window.CrankApi.listAgents(workspaceId);
if (generation !== loadGeneration || workspaceId !== (currentWorkspace() && currentWorkspace().id)) {
return;
}
AGENTS = ((response && response.items) || []).map(mapAgentRecord);
if (!AGENTS.length) {
currentAgentId = null;
@@ -92,21 +105,38 @@ async function loadKeys() {
setCreateButtonState();
setTableLoading(false);
renderTable();
return;
return true;
}
if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
var onboardingParams = new URLSearchParams(window.location.search);
var requestedAgentId = onboardingParams.get('onboarding') === '1' ? onboardingParams.get('agentId') : '';
if (requestedAgentId && AGENTS.some(function(agent) { return agent.id === requestedAgentId; })) {
currentAgentId = requestedAgentId;
} else if (!currentAgentId || !AGENTS.some(function(agent) { return agent.id === currentAgentId; })) {
currentAgentId = AGENTS[0].id;
}
var agentId = currentAgentId;
renderAgentPicker();
var keysResponse = await window.CrankApi.listAgentPlatformApiKeys(
currentWorkspaceId,
currentAgentId
workspaceId,
agentId
);
if (
generation !== loadGeneration
|| workspaceId !== (currentWorkspace() && currentWorkspace().id)
|| agentId !== currentAgentId
) {
return;
}
KEYS = ((keysResponse && keysResponse.items) || []).map(mapKeyRecord);
setCreateButtonState();
setTableLoading(false);
renderTable();
hydrateOnboardingKeyQuery();
return true;
} catch (error) {
if (generation !== loadGeneration || workspaceId !== (currentWorkspace() && currentWorkspace().id)) {
return;
}
AGENTS = [];
KEYS = [];
currentAgentId = null;
@@ -114,6 +144,7 @@ async function loadKeys() {
setCreateButtonState();
setTableLoading(false);
renderTable(error.message || tKey('apikeys.error.load'));
return false;
}
}
@@ -133,48 +164,68 @@ function setTableLoading(on) {
async function revokeKey(id) {
if (!confirm(tKey('apikeys.confirm.revoke'))) return;
if (!currentAgentId) return;
if (keyMutations[id]) return;
try {
var workspaceId = currentWorkspaceId;
var agentId = currentAgentId;
var key = KEYS.find(function(item) { return item.id === id; });
await window.CrankApi.revokeAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
await loadKeys();
if (window.CrankUi) {
keyMutations[id] = true;
renderTable();
await window.CrankApi.revokeAgentPlatformApiKey(workspaceId, agentId, id);
if (workspaceId === currentWorkspaceId && agentId === currentAgentId) {
await loadKeys();
}
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.success(
tfKey('apikeys.toast.revoke_message', { name: key ? key.name : '' }),
tKey('apikeys.toast.revoke_title')
);
}
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.error(
error.message || tKey('apikeys.toast.revoke_error_message'),
tKey('apikeys.toast.revoke_error_title')
);
}
} finally {
delete keyMutations[id];
renderTable();
}
}
async function deleteKey(id) {
if (!confirm(tKey('apikeys.confirm.delete'))) return;
if (!currentAgentId) return;
if (keyMutations[id]) return;
try {
var workspaceId = currentWorkspaceId;
var agentId = currentAgentId;
var key = KEYS.find(function(item) { return item.id === id; });
await window.CrankApi.deleteAgentPlatformApiKey(currentWorkspaceId, currentAgentId, id);
await loadKeys();
if (window.CrankUi) {
keyMutations[id] = true;
renderTable();
await window.CrankApi.deleteAgentPlatformApiKey(workspaceId, agentId, id);
if (workspaceId === currentWorkspaceId && agentId === currentAgentId) {
await loadKeys();
}
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.success(
tfKey('apikeys.toast.delete_message', { name: key ? key.name : '' }),
tKey('apikeys.toast.delete_title')
);
}
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && workspaceId === currentWorkspaceId && agentId === currentAgentId) {
window.CrankUi.error(
error.message || tKey('apikeys.toast.delete_error_message'),
tKey('apikeys.toast.delete_error_title')
);
}
} finally {
delete keyMutations[id];
renderTable();
}
}
@@ -187,9 +238,84 @@ async function createKey(name, scopes) {
return {
rawKey: created.secret,
record: mapKeyRecord(created.api_key),
connection: created.connection || null,
};
}
function isOnboardingKeyQuery() {
var params = new URLSearchParams(window.location.search);
return params.get('onboarding') === '1' && params.get('action') === 'create';
}
function hydrateOnboardingKeyQuery() {
if (!isOnboardingKeyQuery() || onboardingQueryHydrated) return;
onboardingQueryHydrated = true;
var params = new URLSearchParams(window.location.search);
var selected = currentAgent();
var expectedRevision = Number(params.get('agentRevision') || 0);
if (!selected || (expectedRevision && selected.catalogRevision !== expectedRevision)) return;
var hasActiveKey = KEYS.some(function(key) { return key.status === 'active' && key.keyKind === 'mcp_client'; });
if (hasActiveKey) {
showLostKeyRecovery();
return;
}
activeKeyKind = 'mcp_client';
renderKeyKindTabs();
openModal();
}
function showLostKeyRecovery() {
var element = document.getElementById('onboarding-key-lost');
if (element) element.hidden = false;
}
function renderEphemeralConnection(connection) {
var root = document.getElementById('onboarding-connection-config');
var clients = document.getElementById('onboarding-connection-clients');
var warning = document.getElementById('onboarding-clipboard-warning');
if (!root || !clients || !warning) return;
clients.replaceChildren();
if (!connection || !connection.endpoint) {
root.hidden = true;
warning.hidden = true;
return;
}
var endpoint = document.createElement('code');
endpoint.textContent = connection.endpoint;
clients.appendChild(endpoint);
(connection.clients || []).forEach(function(client) {
var block = document.createElement('div');
block.style.marginTop = '10px';
var label = document.createElement('strong');
label.textContent = client.client;
var pre = document.createElement('pre');
pre.className = 'log-detail-block';
pre.textContent = JSON.stringify(client.config, null, 2);
var copy = document.createElement('button');
copy.type = 'button';
copy.className = 'btn-secondary';
copy.textContent = tKey('apikeys.modal.copy_config');
copy.addEventListener('click', async function() {
var value = pre.textContent;
copy.disabled = true;
try {
await copyEphemeralValue(value);
wipeRevealedKey();
} catch (_error) {
showClipboardFailure();
} finally {
copy.disabled = false;
}
});
block.appendChild(label);
block.appendChild(pre);
block.appendChild(copy);
clients.appendChild(block);
});
root.hidden = false;
warning.hidden = false;
}
function currentAgent() {
return AGENTS.find(function(agent) { return agent.id === currentAgentId; }) || null;
}
@@ -311,7 +437,7 @@ function renderTable(errorMessage) {
if (subtitle) {
var active = visibleKeys.filter(function(key) { return key.status === 'active'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status === 'revoked'; }).length;
var revoked = visibleKeys.filter(function(key) { return key.status !== 'active'; }).length;
subtitle.textContent = currentAgentId
? tfKey('apikeys.active.subtitle', { active: active, revoked: revoked })
: tKey('apikeys.agent.empty_hint');
@@ -325,7 +451,7 @@ function renderTable(errorMessage) {
errorCell.textContent = errorMessage;
errorRow.appendChild(errorCell);
tbody.appendChild(errorRow);
renderKeyCards([], errorMessage);
renderKeyCards([], errorMessage, visibleKeys.length);
return;
}
@@ -339,7 +465,7 @@ function renderTable(errorMessage) {
: tKey('apikeys.agent.empty_hint');
empty.appendChild(td);
tbody.appendChild(empty);
renderKeyCards(rows);
renderKeyCards(rows, null, visibleKeys.length);
return;
}
@@ -361,33 +487,42 @@ function renderTable(errorMessage) {
var badge = document.createElement('span');
badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
badge.textContent = key.status === 'active' ? tKey('apikeys.status.active') : tKey('apikeys.status.revoked');
badge.textContent = keyStatusLabel(key.status);
node.querySelector('.col-status').appendChild(badge);
var actionsActive = node.querySelector('.actions-active');
var actionsRevoked = node.querySelector('.actions-revoked');
if (key.status === 'active') {
actionsActive.querySelectorAll('button').forEach(function(button) {
button.disabled = Boolean(keyMutations[key.id]);
});
actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() {
copyPrefix(key.prefix);
});
actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() {
revokeKey(key.id);
});
} else {
} else if (key.status === 'revoked') {
actionsActive.hidden = true;
actionsRevoked.hidden = false;
actionsRevoked.querySelectorAll('button').forEach(function(button) {
button.disabled = Boolean(keyMutations[key.id]);
});
actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() {
deleteKey(key.id);
});
} else {
actionsActive.hidden = true;
actionsRevoked.hidden = true;
}
tbody.appendChild(node);
});
renderKeyCards(rows);
renderKeyCards(rows, null, visibleKeys.length);
}
function renderKeyCards(rows, errorMessage) {
function renderKeyCards(rows, errorMessage, visibleKeyCount) {
var cardList = document.getElementById('keys-card-list');
if (!cardList) return;
@@ -402,7 +537,7 @@ function renderKeyCards(rows, errorMessage) {
cardList.appendChild(
buildKeyCardMessage(
currentAgentId
? (visibleKeys.length ? tKey('apikeys.empty.search') : emptyTextForKind())
? (visibleKeyCount ? tKey('apikeys.empty.search') : emptyTextForKind())
: tKey('apikeys.agent.empty_hint'),
false
)
@@ -431,9 +566,7 @@ function renderKeyCards(rows, errorMessage) {
var statusBadge = document.createElement('span');
statusBadge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
statusBadge.textContent = key.status === 'active'
? tKey('apikeys.status.active')
: tKey('apikeys.status.revoked');
statusBadge.textContent = keyStatusLabel(key.status);
actions.appendChild(statusBadge);
header.appendChild(headerMain);
header.appendChild(actions);
@@ -451,14 +584,14 @@ function renderKeyCards(rows, errorMessage) {
if (key.status === 'active') {
actionRow.appendChild(buildCardAction('apikeys.action.copy_prefix', function() {
copyPrefix(key.prefix);
}));
}, false, Boolean(keyMutations[key.id])));
actionRow.appendChild(buildCardAction('apikeys.action.revoke', function() {
revokeKey(key.id);
}, true));
} else {
}, true, Boolean(keyMutations[key.id])));
} else if (key.status === 'revoked') {
actionRow.appendChild(buildCardAction('apikeys.action.delete', function() {
deleteKey(key.id);
}, true));
}, true, Boolean(keyMutations[key.id])));
}
card.appendChild(actionRow);
@@ -477,6 +610,12 @@ function buildKeyCardMessage(text, isError) {
return card;
}
function keyStatusLabel(status) {
if (status === 'active') return tKey('apikeys.status.active');
if (status === 'deleted') return tKey('apikeys.status.deleted');
return tKey('apikeys.status.revoked');
}
function emptyTextForKind() {
return activeKeyKind === 'approval'
? tKey('apikeys.empty.approval')
@@ -497,25 +636,70 @@ function buildMetaItem(labelKey, valueText) {
return item;
}
function buildCardAction(labelKey, onClick, isDanger) {
function buildCardAction(labelKey, onClick, isDanger, disabled) {
var button = document.createElement('button');
button.type = 'button';
button.className = isDanger ? 'btn-secondary' : 'btn-secondary';
button.textContent = tKey(labelKey);
button.disabled = Boolean(disabled);
button.addEventListener('click', onClick);
return button;
}
var modal = document.getElementById('modal-create');
function wipeRevealedKey(expectedGeneration) {
if (expectedGeneration && revealedKeyGeneration && revealedKeyGeneration !== expectedGeneration) {
return;
}
var keyValue = document.getElementById('reveal-key-value');
if (keyValue) {
keyValue.textContent = '';
}
var clients = document.getElementById('onboarding-connection-clients');
if (clients) clients.replaceChildren();
var connection = document.getElementById('onboarding-connection-config');
if (connection) connection.hidden = true;
var warning = document.getElementById('onboarding-clipboard-warning');
if (warning) warning.hidden = true;
var copyButton = document.getElementById('copy-key-btn');
if (copyButton) {
copyButton.replaceChildren(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/copy.svg#icon',
14,
14
)
);
}
revealedKeyGeneration = 0;
}
function resetCreateButton() {
var button = document.getElementById('modal-confirm-btn');
if (!button) return;
button.disabled = !currentAgentId;
button.textContent = tKey('apikeys.modal.create');
}
function invalidateModalState() {
modalGeneration += 1;
wipeRevealedKey();
resetCreateButton();
createReconciliationRequired = false;
var warning = document.getElementById('ambiguous-create-warning');
if (warning) warning.hidden = true;
}
function openModal() {
if (!currentAgentId) return;
invalidateModalState();
document.getElementById('modal-form-body').hidden = false;
document.getElementById('modal-reveal-body').hidden = true;
document.getElementById('modal-footer-create').hidden = false;
document.getElementById('modal-footer-done').hidden = true;
document.getElementById('new-key-name').value = '';
selectedScopes = new Set([activeKeyKind === 'approval' ? 'approve' : 'read']);
selectedScopes = new Set(activeKeyKind === 'approval' ? ['approve'] : ['read', 'write']);
document.getElementById('modal-create-title').textContent = activeKeyKind === 'approval'
? tKey('apikeys.modal.title_approval')
: tKey('apikeys.modal.title_mcp');
@@ -529,6 +713,7 @@ function openModal() {
function closeModal() {
modal.classList.remove('open');
invalidateModalState();
}
document.getElementById('btn-create-key').addEventListener('click', openModal);
@@ -558,14 +743,34 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun
try {
button.disabled = true;
button.textContent = tKey('apikeys.creating');
var requestGeneration = ++modalGeneration;
var workspaceId = currentWorkspaceId;
var agentId = currentAgentId;
var keyKind = activeKeyKind;
var created = await createKey(name, Array.from(selectedScopes));
if (
requestGeneration !== modalGeneration
|| workspaceId !== currentWorkspaceId
|| agentId !== currentAgentId
|| keyKind !== activeKeyKind
|| !modal.classList.contains('open')
) {
wipeRevealedKey(requestGeneration);
var reconciled = await loadKeys();
if (reconciled) showLostKeyRecovery();
return;
}
KEYS.unshift(created.record);
document.getElementById('reveal-key-value').textContent = created.rawKey;
revealedKeyGeneration = requestGeneration;
onboardingRevealWasCreated = true;
renderEphemeralConnection(keyKind === 'mcp_client' ? created.connection : null);
document.getElementById('modal-form-body').hidden = true;
document.getElementById('modal-reveal-body').hidden = false;
document.getElementById('modal-footer-create').hidden = true;
document.getElementById('modal-footer-done').hidden = false;
renderTable();
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
if (window.CrankUi) {
window.CrankUi.success(
tKey('apikeys.toast.create_message'),
@@ -573,38 +778,87 @@ document.getElementById('modal-confirm-btn').addEventListener('click', async fun
);
}
} catch (error) {
var ambiguous = !error.status || error.status === 408 || error.status >= 500;
if (ambiguous
&& workspaceId === currentWorkspaceId
&& agentId === currentAgentId
&& modal.classList.contains('open')) {
createReconciliationRequired = true;
var warning = document.getElementById('ambiguous-create-warning');
if (warning) warning.hidden = false;
await loadKeys();
}
if (window.CrankUi) {
window.CrankUi.error(
error.message || tKey('apikeys.toast.create_error_message'),
tKey('apikeys.toast.create_error_title')
ambiguous ? tKey('apikeys.ambiguous.body') : (error.message || tKey('apikeys.toast.create_error_message')),
ambiguous ? tKey('apikeys.ambiguous.title') : tKey('apikeys.toast.create_error_title')
);
}
} finally {
button.disabled = false;
button.textContent = tKey('apikeys.modal.create');
if (requestGeneration === modalGeneration && button && modal.classList.contains('open')) {
button.disabled = createReconciliationRequired || !currentAgentId;
button.textContent = createReconciliationRequired
? tKey('apikeys.ambiguous.blocked')
: tKey('apikeys.modal.create');
}
}
});
document.getElementById('modal-done-btn').addEventListener('click', closeModal);
document.getElementById('copy-key-btn').addEventListener('click', function() {
var value = document.getElementById('reveal-key-value').textContent;
if (navigator.clipboard) {
navigator.clipboard.writeText(value).catch(function() {});
function copyEphemeralValue(value) {
if (!value || !navigator.clipboard || typeof navigator.clipboard.writeText !== 'function') {
return Promise.reject(new Error('clipboard unavailable'));
}
this.replaceChildren(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
13,
13
)
);
return navigator.clipboard.writeText(value);
}
function showClipboardFailure() {
if (window.CrankUi) {
window.CrankUi.info(
tKey('apikeys.toast.copy_message'),
tKey('apikeys.toast.copy_title')
window.CrankUi.error(
tKey('apikeys.toast.copy_error_message'),
tKey('apikeys.toast.copy_error_title')
);
}
}
document.getElementById('copy-key-btn').addEventListener('click', async function() {
var value = document.getElementById('reveal-key-value').textContent;
this.disabled = true;
try {
await copyEphemeralValue(value);
this.replaceChildren(
buildIconSvg(
(window.APP_BASE || '') + 'icons/general/check.svg#icon',
13,
13
)
);
if (window.CrankUi) {
window.CrankUi.info(
tKey('apikeys.toast.copy_message'),
tKey('apikeys.toast.copy_title')
);
}
wipeRevealedKey();
} catch (_error) {
showClipboardFailure();
} finally {
this.disabled = false;
}
});
document.getElementById('ambiguous-create-retry-btn').addEventListener('click', async function() {
var button = this;
button.disabled = true;
var reconciled = await loadKeys();
if (reconciled && currentAgentId && modal.classList.contains('open')) {
createReconciliationRequired = false;
document.getElementById('ambiguous-create-warning').hidden = true;
resetCreateButton();
document.getElementById('new-key-name').focus();
}
button.disabled = false;
});
document.getElementById('key-search').addEventListener('input', function() {
@@ -613,12 +867,14 @@ document.getElementById('key-search').addEventListener('input', function() {
});
document.getElementById('agent-select').addEventListener('change', async function() {
closeModal();
currentAgentId = this.value || null;
await loadKeys();
});
document.querySelectorAll('[data-key-kind]').forEach(function(button) {
button.addEventListener('click', function() {
closeModal();
activeKeyKind = this.dataset.keyKind || 'mcp_client';
search = '';
document.getElementById('key-search').value = '';
@@ -641,6 +897,22 @@ document.addEventListener('DOMContentLoaded', async function() {
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
await loadKeys();
window.addEventListener('crank:workspacechange', function() {
keyMutations = {};
closeModal();
onboardingQueryHydrated = false;
loadKeys();
});
window.addEventListener('crank:langchange', function() {
closeModal();
if (onboardingRevealWasCreated && isOnboardingKeyQuery()) showLostKeyRecovery();
});
window.addEventListener('pagehide', function() {
closeModal();
});
window.addEventListener('pageshow', function(event) {
if (event.persisted) {
closeModal();
if (onboardingRevealWasCreated && isOnboardingKeyQuery()) showLostKeyRecovery();
}
});
});
+188 -5
View File
@@ -1,6 +1,8 @@
(function() {
var API_BASE = '/api/admin';
var AUTH_BASE = '/api/auth';
var operationEtags = Object.create(null);
var agentEtags = Object.create(null);
function headers(extra) {
return Object.assign({
@@ -8,6 +10,26 @@
}, extra || {});
}
function csrfExempt(path) {
return /\/api\/auth\/(?:login|bootstrap\/complete|session\/csrf)$/.test(path);
}
function attachCsrf(path, method, requestOptions) {
if (method === 'GET' || method === 'HEAD' || method === 'OPTIONS' || csrfExempt(path)) {
return;
}
if (!(path.indexOf('/api/auth/') === 0 || path.indexOf('/api/admin/') === 0)) {
return;
}
var token = window.CrankAuth && typeof window.CrankAuth.getCsrfToken === 'function'
? window.CrankAuth.getCsrfToken()
: '';
if (token) {
requestOptions.headers = headers(requestOptions.headers);
requestOptions.headers['x-csrf-token'] = token;
}
}
function attachCorrelation(error, response) {
var requestId = response.headers.get('x-request-id');
var traceId = response.headers.get('x-trace-id');
@@ -20,13 +42,88 @@
return error;
}
function operationMutationResource(path, method) {
if (!method || method === 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/operations\/[^/?]+)(?:\/(publish|archive|versions))?(?:\?.*)?$/);
if (!match) {
return null;
}
if (method === 'PATCH' || method === 'DELETE' || (method === 'POST' && match[2])) {
return match[1];
}
return null;
}
function operationDetailResource(path, method) {
if (method && method !== 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/operations\/[^/?]+)$/);
return match ? match[1] : null;
}
function agentMutationResource(path, method) {
if (!method || method === 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/agents\/[^/?]+)(?:\/(bindings|publish|unpublish|archive))?(?:\?.*)?$/);
if (!match) {
return null;
}
if (method === 'PATCH' || method === 'DELETE' || (method === 'POST' && match[2])) {
return match[1];
}
return null;
}
function agentDetailResource(path, method) {
if (method && method !== 'GET') {
return null;
}
var match = path.match(/^(\/api\/admin\/workspaces\/[^/]+\/agents\/[^/?]+)$/);
return match ? match[1] : null;
}
async function request(path, options) {
var response = await fetch(path, Object.assign({
var requestOptions = Object.assign({
credentials: 'same-origin',
headers: headers(),
}, options || {}));
}, options || {});
var method = (requestOptions.method || 'GET').toUpperCase();
attachCsrf(path, method, requestOptions);
var mutationResource = operationMutationResource(path, method);
var agentMutation = agentMutationResource(path, method);
if (mutationResource) {
if (!operationEtags[mutationResource]) {
await request(mutationResource);
}
requestOptions.headers = headers(requestOptions.headers);
requestOptions.headers['If-Match'] = operationEtags[mutationResource];
}
if (agentMutation) {
if (!agentEtags[agentMutation]) {
await request(agentMutation);
}
requestOptions.headers = headers(requestOptions.headers);
requestOptions.headers['If-Match'] = agentEtags[agentMutation];
}
var response = await fetch(path, requestOptions);
var detailResource = operationDetailResource(path, method);
var agentDetail = agentDetailResource(path, method);
var responseEtag = response.headers.get('etag');
if (detailResource && responseEtag) {
operationEtags[detailResource] = responseEtag;
}
if (agentDetail && responseEtag) {
agentEtags[agentDetail] = responseEtag;
}
if (response.status === 204) {
if (mutationResource) {
delete operationEtags[mutationResource];
}
return null;
}
@@ -40,6 +137,12 @@
}
if (!response.ok) {
if (mutationResource && (response.status === 409 || response.status === 428)) {
delete operationEtags[mutationResource];
}
if (agentMutation && (response.status === 409 || response.status === 428)) {
delete agentEtags[agentMutation];
}
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
@@ -54,9 +157,22 @@
var error = new Error(message);
error.status = response.status;
error.payload = payload;
var errorCode = payload && payload.error && typeof payload.error === 'object'
? (payload.error.code || payload.error.error_code)
: payload && (payload.code || payload.error_code);
if (typeof errorCode === 'string' && /^[A-Za-z0-9._-]{1,128}$/.test(errorCode)) {
error.code = errorCode;
}
throw attachCorrelation(error, response);
}
if (mutationResource) {
delete operationEtags[mutationResource];
}
if (agentMutation) {
delete agentEtags[agentMutation];
}
if (text && payload === null) {
throw attachCorrelation(new Error('Backend returned a non-JSON response'), response);
}
@@ -137,6 +253,16 @@
}
window.CrankApi = {
getBootstrapStatus: function() {
return request(AUTH_BASE + '/bootstrap/status');
},
completeBootstrap: function(payload) {
return request(AUTH_BASE + '/bootstrap/complete', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
});
},
login: function(payload) {
return request(AUTH_BASE + '/login', {
method: 'POST',
@@ -154,6 +280,13 @@
getSession: function() {
return request(AUTH_BASE + '/session');
},
refreshSessionCsrf: function() {
return request(AUTH_BASE + '/session/csrf', {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify({}),
});
},
getProfile: function() {
return request(AUTH_BASE + '/profile');
},
@@ -180,6 +313,21 @@
getWorkspace: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId));
},
getOnboarding: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/onboarding');
},
recordOnboardingEvent: function(workspaceId, payload, options) {
return request(API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/onboarding/events', Object.assign({}, options || {}, {
method: 'POST',
headers: headers({ 'Content-Type': 'application/json' }),
body: JSON.stringify(payload),
}));
},
resetOnboardingSelection: function(workspaceId, expectedRevision) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/onboarding/reset-selection', {
expected_revision: expectedRevision,
});
},
updateWorkspace: function(workspaceId, payload) {
return patch('/workspaces/' + encodeURIComponent(workspaceId), payload);
},
@@ -238,15 +386,28 @@
}
);
},
importOperation: function(workspaceId, yamlDocument, mode) {
return request(
importOperation: async function(workspaceId, yamlDocument, mode, existingOperationId) {
var importHeaders = headers({ 'Content-Type': 'application/yaml' });
if (existingOperationId) {
var resource = API_BASE + '/workspaces/' + encodeURIComponent(workspaceId)
+ '/operations/' + encodeURIComponent(existingOperationId);
if (!operationEtags[resource]) {
await request(resource);
}
importHeaders['If-Match'] = operationEtags[resource];
}
var result = await request(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/import' + query({ mode: mode }),
{
method: 'POST',
headers: headers({ 'Content-Type': 'application/yaml' }),
headers: importHeaders,
body: yamlDocument,
}
);
if (existingOperationId) {
delete operationEtags[resource];
}
return result;
},
previewOpenApiImport: function(workspaceId, documentText) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/imports/openapi/preview', {
@@ -331,6 +492,16 @@
listLogs: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs' + query(params));
},
exportLogsCsv: function(workspaceId, params) {
return requestText(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/logs/export.csv' + query(params)
);
},
exportUsageCsv: function(workspaceId, params) {
return requestText(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/usage/export.csv' + query(params)
);
},
getLog: function(workspaceId, logId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
},
@@ -340,6 +511,18 @@
getApproval: function(workspaceId, approvalId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId));
},
approveApproval: function(workspaceId, approvalId, payload) {
return post(
'/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId) + '/approve',
payload || { approve: 'yes' }
);
},
denyApproval: function(workspaceId, approvalId, payload) {
return post(
'/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId) + '/deny',
payload || { approve: 'no' }
);
},
getUsageOverview: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
},
+22
View File
@@ -175,6 +175,15 @@
}
sessionPromise = window.CrankApi.getSession()
.then(function(session) {
if (session && !session.csrf_token && window.CrankApi && typeof window.CrankApi.refreshSessionCsrf === 'function') {
return window.CrankApi.refreshSessionCsrf().then(function(csrf) {
session.csrf_token = csrf && csrf.csrf_token ? csrf.csrf_token : '';
return session;
});
}
return session;
})
.then(function(session) {
return replaceSession(session);
})
@@ -221,6 +230,15 @@
window.location.href = homeUrl();
}
async function completeBootstrap(token, password) {
var session = await window.CrankApi.completeBootstrap({
token: token,
password: password,
});
replaceSession(session);
window.location.href = homeUrl();
}
async function logout() {
try {
await window.CrankApi.logout();
@@ -243,12 +261,16 @@
fetchSession: fetchSession,
replaceSession: replaceSession,
getCachedSession: function() { return sessionCache; },
getCsrfToken: function() {
return sessionCache && sessionCache.csrf_token ? sessionCache.csrf_token : '';
},
renderShellIdentity: function() {
renderShellIdentity(sessionCache);
},
guardProtectedPage: guardProtectedPage,
guardLoginPage: guardLoginPage,
login: login,
completeBootstrap: completeBootstrap,
logout: logout,
handleUnauthorized: handleUnauthorized,
};
+36 -3
View File
@@ -44,6 +44,9 @@ function mapOperation(item) {
created_at: item.created_at,
updated_at: item.updated_at,
published_at: item.published_at,
current_draft_version: item.current_draft_version,
latest_published_version: item.latest_published_version,
can_delete: item.can_delete === true,
target_url: item.target_url || '',
method: item.target_action || '',
usage_summary: item.usage_summary || {
@@ -117,6 +120,7 @@ document.addEventListener('alpine:init', function() {
_agentsByOpIdCacheVersion: -1,
_activeAgentsCache: null,
_activeAgentsCacheVersion: -1,
_loadGeneration: 0,
async init() {
var self = this;
@@ -153,25 +157,31 @@ document.addEventListener('alpine:init', function() {
},
async reload() {
var generation = ++this._loadGeneration;
var requestedWorkspaceId = this.workspaceId;
this.loading = true;
this.loadError = '';
try {
var response = await window.CrankApi.listOperations(this.workspaceId);
var response = await window.CrankApi.listOperations(requestedWorkspaceId);
if (generation !== this._loadGeneration || requestedWorkspaceId !== this.workspaceId) return;
this.replaceOperations((response && response.items ? response.items : []).map(mapOperation));
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
return operation.category;
}).filter(Boolean))).sort();
this.stats = computeStats(this.operations);
} catch (error) {
if (generation !== this._loadGeneration || requestedWorkspaceId !== this.workspaceId) return;
this.replaceOperations([]);
this.categoryOptions = [];
this.stats = emptyStats();
this.loadError = error.message || 'Failed to load operations';
}
this.loading = false;
this.page = 1;
if (generation === this._loadGeneration && requestedWorkspaceId === this.workspaceId) {
this.loading = false;
this.page = 1;
}
},
replaceOperations(operations) {
@@ -453,6 +463,29 @@ document.addEventListener('alpine:init', function() {
}
},
async archiveOperation(operation) {
if (!operation || !confirm(this.tKey('ops.archive.confirm'))) return;
try {
await window.CrankApi.archiveOperation(this.workspaceId, operation.id);
await this.reload();
if (window.CrankUi) {
window.CrankUi.success(
this.tfKey('ops.archive.success.message', {
name: operation.display_name || operation.name
}),
this.tKey('ops.archive.success.title')
);
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(
error.message || this.tKey('ops.archive.error.message'),
this.tKey('ops.archive.error.title')
);
}
}
},
protocolLabel(operation) {
return operation.method ? ('REST · ' + operation.method) : 'REST';
},
+312 -2
View File
@@ -20,6 +20,43 @@ var TRANSLATIONS = {
'nav.user_fallback': 'Crank',
'nav.workspace_fallback': 'workspace',
'onboarding.title': 'Getting Started',
'onboarding.subtitle': 'A resumable path to your first real MCP tool call.',
'onboarding.loading': 'Checking authoritative progress…',
'onboarding.progress': '{count}/7 steps complete',
'onboarding.error': 'Getting Started progress is unavailable.',
'onboarding.retry': 'Retry',
'onboarding.refresh': 'Refresh progress',
'onboarding.dismiss': 'Dismiss',
'onboarding.resume': 'Resume Getting Started',
'onboarding.collapse': 'Collapse Getting Started checklist',
'onboarding.request_id': 'Request ID',
'onboarding.trace_id': 'Trace ID',
'onboarding.error_code': 'Error code',
'onboarding.progress_label': 'Getting Started progress',
'onboarding.action_needed': 'This is the next actionable step.',
'onboarding.regressed': 'The referenced object changed. Review and continue again.',
'onboarding.completed': 'Your first public MCP tool call is complete.',
'onboarding.open_invocation': 'Open invocation history',
'onboarding.tool': 'Tool',
'onboarding.timestamp': 'Timestamp',
'onboarding.deep_link_stale': 'This onboarding link references a changed Operation or Agent. Reset it and reselect the current published revision.',
'onboarding.reselect': 'Reset and reselect',
'onboarding.step.operation': 'Create an Operation',
'onboarding.step.test': 'Test the Operation',
'onboarding.step.publish_operation': 'Publish the Operation',
'onboarding.step.agent': 'Create and publish an Agent',
'onboarding.step.key': 'Create an MCP client key',
'onboarding.step.mcp_connection': 'Connect an MCP client',
'onboarding.step.first_call': 'Make the first tool call',
'onboarding.action.operation': 'Create Operation',
'onboarding.action.test': 'Open test',
'onboarding.action.publish_operation': 'Open publication',
'onboarding.action.agent': 'Create Agent',
'onboarding.action.key': 'Create key',
'onboarding.action.mcp_connection': 'Show connection',
'onboarding.action.first_call': 'Open Logs',
// Operations page
'ops.title': 'Operations',
'ops.subtitle': 'Catalog of tools. List of created MCP tools for API endpoints.',
@@ -37,6 +74,12 @@ var TRANSLATIONS = {
'ops.delete.error.message': 'Failed to delete operation',
'ops.action.edit': 'Edit operation',
'ops.action.delete':'Delete operation',
'ops.action.archive': 'Archive operation',
'ops.archive.confirm': 'Archive this operation? Existing published Agent snapshots remain available, but new publications and bindings will be blocked.',
'ops.archive.success.title': 'Operation archived',
'ops.archive.success.message': '{name} was archived.',
'ops.archive.error.title': 'Archive failed',
'ops.archive.error.message': 'Failed to archive operation',
'ops.stats.synced': 'Catalog synced with backend',
'ops.stats.none': 'No operations yet',
'ops.stats.share': '{value}% of total',
@@ -137,11 +180,16 @@ var TRANSLATIONS = {
'apikeys.modal.reveal_title': 'Copy this key now.',
'apikeys.modal.reveal_body': "It won't be shown again.",
'apikeys.modal.copy': 'Copy to clipboard',
'apikeys.modal.copy_config': 'Copy configuration',
'apikeys.modal.connection_title': 'MCP connection configuration',
'apikeys.modal.clipboard_warning': 'Your operating system clipboard is outside Crank control. Clear it after saving the key.',
'apikeys.onboarding.key_lost': 'The key cannot be shown again. Create a new key and revoke the old one if its value was not saved.',
'apikeys.modal.cancel': 'Cancel',
'apikeys.modal.create': 'Create key',
'apikeys.modal.done': "Done — I've copied the key",
'apikeys.status.active': 'Active',
'apikeys.status.revoked': 'Revoked',
'apikeys.status.deleted': 'Deleted',
'apikeys.last_used.never': 'Never',
'apikeys.empty.none': 'No API keys yet',
'apikeys.empty.approval': 'No approval keys yet. Create one only if this agent has tools that require human confirmation.',
@@ -167,12 +215,18 @@ var TRANSLATIONS = {
'apikeys.toast.create_error_message': 'Failed to create key',
'apikeys.toast.copy_title': 'Agent key copied',
'apikeys.toast.copy_message': 'Store the raw key securely. It cannot be revealed again.',
'apikeys.toast.copy_error_title': 'Clipboard write failed',
'apikeys.toast.copy_error_message': 'The one-time value is still visible. Copy it manually before closing this page.', // community-scope: allow=one-time-token
'apikeys.toast.prefix_title': 'Key prefix copied',
'apikeys.action.copy_prefix': 'Copy key prefix',
'apikeys.action.revoke': 'Revoke key',
'apikeys.action.delete': 'Delete',
'apikeys.creating': 'Creating…',
'apikeys.approval.warning': 'Do not pass this key to an LLM or MCP client. It is only for an external interface where a human confirms an action.',
'apikeys.ambiguous.title': 'Creation result is uncertain.',
'apikeys.ambiguous.body': 'Key metadata was refreshed. Review the list before deliberately creating another key.',
'apikeys.ambiguous.retry': 'Refresh metadata and allow another attempt',
'apikeys.ambiguous.blocked': 'Review metadata before retrying',
// Secrets page
'secrets.title': 'Secrets',
@@ -256,6 +310,16 @@ var TRANSLATIONS = {
'logs.live': 'Live',
'logs.paused': 'Paused',
'logs.refresh': 'Refresh',
'logs.export': 'Export CSV',
'logs.load_more': 'Load more',
'logs.status_filter': 'Status',
'logs.status.all': 'All statuses',
'logs.status.ok': 'Success',
'logs.status.error': 'Error',
'logs.outcome_filter': 'Outcome',
'logs.outcome.all': 'All outcomes',
'logs.filter.operation': 'Operation ID',
'logs.filter.agent': 'Agent ID',
'logs.range.30m': 'Last 30 min',
'logs.range.1h': 'Last hour',
'logs.range.6h': 'Last 6 hours',
@@ -281,6 +345,10 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Automatic polling is paused.',
'logs.refresh.title': 'Logs refreshed',
'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.',
'logs.export.done_title': 'Logs exported',
'logs.export.done_body': 'The filtered invocation history was exported as CSV.',
'logs.export.error_title': 'Export failed',
'logs.export.error_body': 'Failed to export invocation history.',
'approvals.title': 'Human confirmations',
'approvals.subtitle': 'Requests waiting for an external user decision and recent results.',
'approvals.refresh': 'Refresh',
@@ -289,6 +357,7 @@ var TRANSLATIONS = {
'approvals.loading': 'Loading confirmation requests…',
'approvals.empty': 'There are no confirmation requests yet.',
'approvals.error.load': 'Failed to load confirmation requests',
'approvals.error.decision': 'Failed to submit confirmation decision',
'approvals.untitled': 'Confirmation request',
'approvals.operation': 'Operation',
'approvals.agent': 'Agent',
@@ -296,6 +365,13 @@ var TRANSLATIONS = {
'approvals.updated_at': 'Updated',
'approvals.request': 'Request',
'approvals.response': 'Result',
'approvals.request_id': 'Request ID',
'approvals.trace_id': 'Trace ID',
'approvals.action.approve': 'Approve',
'approvals.action.deny': 'Deny',
'approvals.action.busy': 'Submitting…',
'approvals.confirm.approve': 'Approve this pending confirmation request?',
'approvals.confirm.deny': 'Deny this pending confirmation request?',
'approvals.status.pending': 'Pending',
'approvals.status.approved': 'Approved',
'approvals.status.denied': 'Denied',
@@ -328,6 +404,19 @@ var TRANSLATIONS = {
'usage.chart.error': 'Error',
'usage.chart.empty.title': 'No usage data yet',
'usage.chart.empty.sub': 'Invocation metrics will appear here after tests or published tool calls in the selected period.',
'usage.outcomes.title': 'Outcomes',
'usage.outcomes.subtitle': 'Grouped by safe execution outcome.',
'usage.outcomes.empty.title': 'No outcome breakdown yet',
'usage.outcomes.empty.sub': 'Outcome groups will appear after invocations are recorded.',
'usage.outcomes.p50': 'p50',
'usage.outcomes.p95': 'p95',
'usage.outcomes.p99': 'p99',
'usage.outcome.success': 'Success',
'usage.outcome.upstream': 'Upstream',
'usage.outcome.client': 'Client',
'usage.outcome.schema': 'Schema',
'usage.outcome.crank': 'Crank',
'usage.outcome.no_error_code': 'No error code',
'usage.table.title': 'By operation',
'usage.table.subtitle': 'Breakdown for {period}',
'usage.table.th.operation': 'Operation',
@@ -349,7 +438,9 @@ var TRANSLATIONS = {
'usage.export.empty.title': 'No usage data loaded',
'usage.export.empty.body': 'Load usage data before exporting the CSV snapshot.',
'usage.export.done.title': 'Usage exported',
'usage.export.done.body': 'The current usage snapshot was exported as CSV.',
'usage.export.done.body': 'The filtered usage dataset was exported as CSV.',
'usage.export.error.title': 'Export failed',
'usage.export.error.body': 'Failed to export usage data.',
'usage.chart.ok': '{count} ok',
'usage.chart.errors': '{count} errors',
'usage.chart.week': 'Wk {index}',
@@ -403,6 +494,7 @@ var TRANSLATIONS = {
'settings.security.change_password': 'Change password',
'settings.security.mismatch': 'New password and confirmation do not match.',
'settings.security.saved': 'Password updated.',
'settings.security.saved_relogin': 'Password updated. Please sign in again.',
'settings.security.save_error': 'Failed to change password',
'settings.capability.rest': 'REST / HTTP',
'settings.capability.standard': 'standard',
@@ -706,6 +798,36 @@ var TRANSLATIONS = {
'wizard.test.failed': 'Operation test returned errors',
'wizard.test.completed_body': 'The operation was executed successfully against the API.',
'wizard.test.failed_body': 'The operation was executed, but the API or validation returned errors.',
'execution.error.authorization_denied': 'Operation execution is denied.',
'execution.error.auth_profile_not_found': 'Authorization profile was not found.',
'execution.error.secret_not_found': 'Authorization secret was not found.',
'execution.error.secret_invalid': 'Authorization secret has an invalid format.',
'execution.error.input_schema_invalid': 'Input does not satisfy the operation schema.',
'execution.error.input_mapping_invalid': 'Input parameters could not be mapped.',
'execution.error.prepared_request_invalid': 'A valid upstream request could not be prepared.',
'execution.error.execution_overloaded': 'The service is temporarily overloaded.',
'execution.error.safety_store_unavailable': 'A mandatory safety store is unavailable.',
'execution.error.protocol_unsupported': 'The operation protocol is unsupported.',
'execution.error.execution_mode_unsupported': 'The execution mode is unsupported.',
'execution.error.adapter_configuration_invalid': 'The adapter configuration is invalid.',
'execution.error.outbound_target_rejected': 'The target was rejected by the safety policy.',
'execution.error.upstream_auth_error': 'The upstream API rejected authorization.',
'execution.error.upstream_not_found': 'The upstream resource was not found.',
'execution.error.upstream_rate_limited': 'The upstream API rate-limited the request.',
'execution.error.upstream_server_error': 'The upstream API is temporarily unavailable.',
'execution.error.upstream_status_error': 'The upstream API returned an error status.',
'execution.error.upstream_timeout': 'The upstream API timed out.',
'execution.error.upstream_transport_error': 'The upstream API could not be reached.',
'execution.error.upstream_response_too_large': 'The upstream response exceeded its limit.',
'execution.error.output_mapping_invalid': 'The upstream response could not be mapped.',
'execution.error.output_schema_invalid': 'The output does not satisfy the operation schema.',
'execution.error.persistence_unavailable': 'Mandatory result persistence is unavailable.',
'execution.error.runtime_internal': 'Internal execution failure.',
'execution.error.confirmation_required': 'The operation requires confirmation.',
'execution.error.confirmation_invalid': 'The confirmation is invalid or expired.',
'execution.error.idempotency_in_progress': 'The operation is already running for this key.',
'execution.error.idempotency_conflict': 'The idempotency key was used with different input.',
'execution.error.idempotency_outcome_unknown': 'The previous outcome is unknown; automatic retry is unsafe.',
'wizard.test.window_completed': 'Window test completed',
'wizard.test.window_completed_body': 'Collected a bounded result window.',
'wizard.test.window_truncated_note': 'Results were truncated.',
@@ -727,6 +849,7 @@ var TRANSLATIONS = {
'wizard.quality.blocked_title': 'Quality check found blocking issues',
'wizard.quality.blocked_body': 'Fix error-level findings before publishing.',
'wizard.quality.blocking_error': 'Fix blocking quality findings before publishing.',
'wizard.quality.import_findings_hint': 'These findings came from OpenAPI import. Run quality analysis to recalculate them for the current Draft.',
'wizard.quality.severity_error': 'Error',
'wizard.quality.severity_warning': 'Warning',
'wizard.quality.severity_info': 'Info',
@@ -743,6 +866,16 @@ var TRANSLATIONS = {
'wizard.yaml.loaded_body': 'The YAML configuration was loaded.',
'wizard.publish.done': 'Operation published',
'wizard.publish.done_body': 'Version {version} is now published and can be bound into agents.',
'wizard.publish.confirm': 'Publish the saved Draft as a new immutable version?',
'wizard.stale.title': 'Draft changed elsewhere',
'wizard.stale.body': 'Your edits are preserved in this browser. Reload the latest saved Draft before retrying.',
'wizard.test.correlation': 'Request ID: {requestId}\nTrace ID: {traceId}',
'wizard.test.request_id': 'Request ID',
'wizard.test.trace_id': 'Trace ID',
'wizard.test.copy_request_id': 'Copy Request ID',
'wizard.test.copy_trace_id': 'Copy Trace ID',
'wizard.test.id_copied': 'Support ID copied',
'wizard.test.id_copied_body': 'The identifier was copied without request or response payload.',
// Common buttons
'btn.save': 'Save changes',
@@ -785,6 +918,7 @@ var TRANSLATIONS = {
'agents.card.keys': 'keys',
'agents.card.calls_today': 'calls today',
'agents.card.created': 'Created {date}',
'agents.card.revision': 'Draft v{draft} · Published v{published} · Catalog rev {revision}',
'agents.card.copy_endpoint': 'Copy endpoint',
'agents.card.endpoint_help': 'Set this endpoint in the MCP client together with the API key.',
'agents.card.endpoint_help_community': 'Set this endpoint in the MCP client together with the API key.',
@@ -809,6 +943,7 @@ var TRANSLATIONS = {
'agents.drawer.optional': '(optional)',
'agents.drawer.required': 'required',
'agents.drawer.status': 'Status',
'agents.drawer.revision': 'Draft v{draft} · Published v{published} · Catalog rev {revision}',
'agents.drawer.endpoint': 'MCP endpoint',
'agents.drawer.slug_hint': 'Slug is used as part of the endpoint to identify the agent.',
'agents.drawer.placeholder.name': 'Customer Support',
@@ -867,12 +1002,17 @@ var TRANSLATIONS = {
'agents.toast.delete_message': '{name} was deleted.',
'agents.toast.delete_error_title': 'Delete failed',
'agents.toast.delete_error_message': 'Failed to delete agent',
'agents.toast.delete_forbidden_message': 'Published agents cannot be deleted. Archive or unpublish the agent first.',
'agents.toast.lifecycle_title': 'Agent lifecycle updated',
'agents.toast.lifecycle_publish_confirm': 'Publish {name}? The published MCP catalog will change.',
'agents.toast.lifecycle_unpublish_confirm': 'Unpublish {name}? Existing MCP clients will no longer see this agent catalog.',
'agents.toast.lifecycle_archive_confirm': 'Archive {name}? New publications and bindings will be blocked.',
'agents.toast.lifecycle_publish': '{name} was published.',
'agents.toast.lifecycle_unpublish': '{name} was returned to draft.',
'agents.toast.lifecycle_archive': '{name} was archived.',
'agents.toast.lifecycle_error_title': 'Lifecycle update failed',
'agents.toast.lifecycle_error_message': 'Failed to update agent lifecycle',
'agents.toast.stale_message': 'Agent changed in another request. Reload the drawer and retry.',
'agents.toast.endpoint_title': 'MCP endpoint copied',
// Demo content
@@ -889,9 +1029,24 @@ var TRANSLATIONS = {
'login.email_label': 'Email address',
'login.email_placeholder': 'you@acme.com',
'login.password_only': 'Sign in with email and password.',
'login.bootstrap.title': 'Create admin account',
'login.bootstrap.subtitle': 'Complete local first-run bootstrap',
'login.bootstrap.note': 'Enter the one-time token generated by crank-migrate and choose the first admin password.', // community-scope: allow=one-time-token
'login.bootstrap.token_label': 'Bootstrap token',
'login.bootstrap.token_placeholder': 'Paste one-time bootstrap token', // community-scope: allow=one-time-token
'login.bootstrap.submit': 'Create admin',
'login.loading': 'Signing in…',
'login.success': 'Signed in. Redirecting…',
'login.bootstrap.loading': 'Creating admin…',
'login.bootstrap.success': 'Admin account created. Redirecting…',
'login.error.required': 'Please enter your email and password.',
'login.bootstrap.error.required': 'Please enter the bootstrap token and password.',
'login.error.invalid': 'Invalid email or password. Please try again.',
'login.error.throttled': 'Too many attempts. Please wait and try again.',
'login.error.expired_session': 'Your session expired. Please sign in again.',
'login.error.generic': 'Unable to sign in right now. Please try again.',
'login.error.request_id': 'Request ID',
'login.error.trace_id': 'Trace ID',
},
ru: {
@@ -910,6 +1065,43 @@ var TRANSLATIONS = {
'nav.user_fallback': 'Crank',
'nav.workspace_fallback': 'workspace',
'onboarding.title': 'Начало работы',
'onboarding.subtitle': 'Возобновляемый путь к первому реальному вызову MCP-инструмента.',
'onboarding.loading': 'Проверяем подтверждённый прогресс…',
'onboarding.progress': 'Выполнено шагов: {count}/7',
'onboarding.error': 'Прогресс начала работы недоступен.',
'onboarding.retry': 'Повторить',
'onboarding.refresh': 'Обновить прогресс',
'onboarding.dismiss': 'Скрыть',
'onboarding.resume': 'Продолжить начало работы',
'onboarding.collapse': 'Свернуть чек-лист начала работы',
'onboarding.request_id': 'Request ID',
'onboarding.trace_id': 'Trace ID',
'onboarding.error_code': 'Код ошибки',
'onboarding.progress_label': 'Прогресс начала работы',
'onboarding.action_needed': 'Это следующий доступный шаг.',
'onboarding.regressed': 'Связанный объект изменился. Проверьте его и продолжите снова.',
'onboarding.completed': 'Первый публичный вызов MCP-инструмента выполнен.',
'onboarding.open_invocation': 'Открыть историю вызова',
'onboarding.tool': 'Инструмент',
'onboarding.timestamp': 'Время вызова',
'onboarding.deep_link_stale': 'Эта ссылка начала работы относится к изменённой операции или агенту. Сбросьте её и выберите актуальную опубликованную ревизию.',
'onboarding.reselect': 'Сбросить и выбрать заново',
'onboarding.step.operation': 'Создать операцию',
'onboarding.step.test': 'Протестировать операцию',
'onboarding.step.publish_operation': 'Опубликовать операцию',
'onboarding.step.agent': 'Создать и опубликовать агента',
'onboarding.step.key': 'Создать ключ MCP-клиента',
'onboarding.step.mcp_connection': 'Подключить MCP-клиент',
'onboarding.step.first_call': 'Выполнить первый вызов',
'onboarding.action.operation': 'Создать операцию',
'onboarding.action.test': 'Открыть тест',
'onboarding.action.publish_operation': 'Открыть публикацию',
'onboarding.action.agent': 'Создать агента',
'onboarding.action.key': 'Создать ключ',
'onboarding.action.mcp_connection': 'Показать подключение',
'onboarding.action.first_call': 'Открыть логи',
// Operations page
'ops.title': 'Операции',
'ops.subtitle': 'Каталог инструментов. Список созданных MCP инструментов на API эндпоинты.',
@@ -927,6 +1119,12 @@ var TRANSLATIONS = {
'ops.delete.error.message': 'Не удалось удалить операцию',
'ops.action.edit': 'Редактировать операцию',
'ops.action.delete':'Удалить операцию',
'ops.action.archive': 'Архивировать операцию',
'ops.archive.confirm': 'Архивировать операцию? Существующие опубликованные снимки агентов останутся доступны, но новые публикации и привязки будут запрещены.',
'ops.archive.success.title': 'Операция архивирована',
'ops.archive.success.message': 'Операция {name} архивирована.',
'ops.archive.error.title': 'Не удалось архивировать',
'ops.archive.error.message': 'Не удалось архивировать операцию',
'ops.stats.synced': 'Каталог синхронизирован с сервером',
'ops.stats.none': 'Операций пока нет',
'ops.stats.share': '{value}% от общего числа',
@@ -1027,11 +1225,16 @@ var TRANSLATIONS = {
'apikeys.modal.reveal_title': 'Скопируйте ключ сейчас.',
'apikeys.modal.reveal_body': 'Повторно он не будет показан.',
'apikeys.modal.copy': 'Скопировать',
'apikeys.modal.copy_config': 'Скопировать конфигурацию',
'apikeys.modal.connection_title': 'Конфигурация MCP-подключения',
'apikeys.modal.clipboard_warning': 'Системный буфер обмена находится вне контроля Crank. Очистите его после сохранения ключа.',
'apikeys.onboarding.key_lost': 'Ключ нельзя показать повторно. Создайте новый ключ и отзовите старый, если значение не было сохранено.',
'apikeys.modal.cancel': 'Отмена',
'apikeys.modal.create': 'Создать ключ',
'apikeys.modal.done': 'Готово — ключ скопирован',
'apikeys.status.active': 'Активен',
'apikeys.status.revoked': 'Отозван',
'apikeys.status.deleted': 'Удален',
'apikeys.last_used.never': 'Никогда',
'apikeys.empty.none': 'API-ключей пока нет',
'apikeys.empty.approval': 'Ключей подтверждения пока нет. Они нужны только агентам с инструментами, требующими подтверждения человеком.',
@@ -1057,12 +1260,18 @@ var TRANSLATIONS = {
'apikeys.toast.create_error_message': 'Не удалось создать ключ',
'apikeys.toast.copy_title': 'Ключ агента скопирован',
'apikeys.toast.copy_message': 'Сохраните исходный ключ в надежном месте. Повторно показать его нельзя.',
'apikeys.toast.copy_error_title': 'Не удалось записать в буфер обмена',
'apikeys.toast.copy_error_message': 'Одноразовое значение всё ещё показано. Скопируйте его вручную до закрытия страницы.',
'apikeys.toast.prefix_title': 'Префикс ключа скопирован',
'apikeys.action.copy_prefix': 'Скопировать префикс ключа',
'apikeys.action.revoke': 'Отозвать ключ',
'apikeys.action.delete': 'Удалить',
'apikeys.creating': 'Создание…',
'apikeys.approval.warning': 'Не передавайте этот ключ LLM или MCP-клиенту. Он нужен только внешнему интерфейсу, где человек подтверждает действие.',
'apikeys.ambiguous.title': 'Результат создания неизвестен.',
'apikeys.ambiguous.body': 'Метаданные ключей обновлены. Проверьте список перед осознанным созданием ещё одного ключа.',
'apikeys.ambiguous.retry': 'Обновить метаданные и разрешить новую попытку',
'apikeys.ambiguous.blocked': 'Проверьте метаданные перед повтором',
// Secrets page
'secrets.title': 'Секреты',
@@ -1148,6 +1357,16 @@ var TRANSLATIONS = {
'logs.live': 'Live',
'logs.paused': 'Пауза',
'logs.refresh': 'Обновить',
'logs.export': 'Экспорт CSV',
'logs.load_more': 'Загрузить ещё',
'logs.status_filter': 'Статус',
'logs.status.all': 'Все статусы',
'logs.status.ok': 'Успех',
'logs.status.error': 'Ошибка',
'logs.outcome_filter': 'Результат',
'logs.outcome.all': 'Все результаты',
'logs.filter.operation': 'ID операции',
'logs.filter.agent': 'ID агента',
'logs.range.30m': 'Последние 30 мин',
'logs.range.1h': 'Последний час',
'logs.range.6h': 'Последние 6 часов',
@@ -1173,6 +1392,10 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Автоматический опрос остановлен.',
'logs.refresh.title': 'Логи обновлены',
'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.',
'logs.export.done_title': 'Логи экспортированы',
'logs.export.done_body': 'Отфильтрованная история вызовов экспортирована в CSV.',
'logs.export.error_title': 'Не удалось экспортировать',
'logs.export.error_body': 'Не удалось экспортировать историю вызовов.',
'approvals.title': 'Подтверждения человеком',
'approvals.subtitle': 'Заявки, которые ожидают решения пользователя, и последние результаты.',
'approvals.refresh': 'Обновить',
@@ -1181,6 +1404,7 @@ var TRANSLATIONS = {
'approvals.loading': 'Загрузка заявок на подтверждение…',
'approvals.empty': 'Заявок на подтверждение пока нет.',
'approvals.error.load': 'Не удалось загрузить заявки на подтверждение',
'approvals.error.decision': 'Не удалось отправить решение по заявке',
'approvals.untitled': 'Заявка на подтверждение',
'approvals.operation': 'Операция',
'approvals.agent': 'Агент',
@@ -1188,6 +1412,13 @@ var TRANSLATIONS = {
'approvals.updated_at': 'Обновлено',
'approvals.request': 'Запрос',
'approvals.response': 'Результат',
'approvals.request_id': 'Request ID',
'approvals.trace_id': 'Trace ID',
'approvals.action.approve': 'Подтвердить',
'approvals.action.deny': 'Отклонить',
'approvals.action.busy': 'Отправка…',
'approvals.confirm.approve': 'Подтвердить эту заявку?',
'approvals.confirm.deny': 'Отклонить эту заявку?',
'approvals.status.pending': 'Ожидает',
'approvals.status.approved': 'Подтверждено',
'approvals.status.denied': 'Отклонено',
@@ -1220,6 +1451,19 @@ var TRANSLATIONS = {
'usage.chart.error': 'Ошибка',
'usage.chart.empty.title': 'Данных по использованию пока нет',
'usage.chart.empty.sub': 'Метрики появятся здесь после тестов или вызовов опубликованных инструментов за выбранный период.',
'usage.outcomes.title': 'Исходы',
'usage.outcomes.subtitle': 'Группировка по безопасному исходу выполнения.',
'usage.outcomes.empty.title': 'Разбивки по исходам пока нет',
'usage.outcomes.empty.sub': 'Группы исходов появятся после записи вызовов.',
'usage.outcomes.p50': 'p50',
'usage.outcomes.p95': 'p95',
'usage.outcomes.p99': 'p99',
'usage.outcome.success': 'Успех',
'usage.outcome.upstream': 'Upstream',
'usage.outcome.client': 'Клиент',
'usage.outcome.schema': 'Схема',
'usage.outcome.crank': 'Crank',
'usage.outcome.no_error_code': 'Без error code',
'usage.table.title': 'По операциям',
'usage.table.subtitle': 'Разбивка за период: {period}',
'usage.table.th.operation': 'Операция',
@@ -1241,7 +1485,9 @@ var TRANSLATIONS = {
'usage.export.empty.title': 'Данные по использованию не загружены',
'usage.export.empty.body': 'Сначала загрузите данные использования, а потом экспортируйте снимок CSV.',
'usage.export.done.title': 'Использование экспортировано',
'usage.export.done.body': 'Текущий снимок использования экспортирован в CSV.',
'usage.export.done.body': 'Отфильтрованный набор использования экспортирован в CSV.',
'usage.export.error.title': 'Не удалось экспортировать',
'usage.export.error.body': 'Не удалось экспортировать данные использования.',
'usage.chart.ok': 'Успешных: {count}',
'usage.chart.errors': 'Ошибок: {count}',
'usage.chart.week': 'Нед. {index}',
@@ -1295,6 +1541,7 @@ var TRANSLATIONS = {
'settings.security.change_password': 'Сменить пароль',
'settings.security.mismatch': 'Новый пароль и подтверждение не совпадают.',
'settings.security.saved': 'Пароль обновлен.',
'settings.security.saved_relogin': 'Пароль обновлен. Войдите снова.',
'settings.security.save_error': 'Не удалось изменить пароль',
'settings.capability.rest': 'REST / HTTP',
'settings.capability.standard': 'standard',
@@ -1619,6 +1866,7 @@ var TRANSLATIONS = {
'wizard.quality.blocked_title': 'Проверка нашла блокирующие ошибки',
'wizard.quality.blocked_body': 'Исправьте замечания уровня «Ошибка» перед публикацией.',
'wizard.quality.blocking_error': 'Исправьте блокирующие замечания качества перед публикацией.',
'wizard.quality.import_findings_hint': 'Эти замечания получены при импорте OpenAPI. Запустите проверку качества, чтобы пересчитать их для текущего черновика.',
'wizard.quality.severity_error': 'Ошибка',
'wizard.quality.severity_warning': 'Предупреждение',
'wizard.quality.severity_info': 'Информация',
@@ -1635,6 +1883,16 @@ var TRANSLATIONS = {
'wizard.yaml.loaded_body': 'YAML-конфигурация загружена.',
'wizard.publish.done': 'Операция опубликована',
'wizard.publish.done_body': 'Версия {version} опубликована и теперь может быть привязана к агентам.',
'wizard.publish.confirm': 'Опубликовать сохранённый черновик как новую неизменяемую версию?',
'wizard.stale.title': 'Черновик изменён в другом окне',
'wizard.stale.body': 'Ваши правки сохранены в этом браузере. Перезагрузите последнюю сохранённую версию перед повторной попыткой.',
'wizard.test.correlation': 'Request ID: {requestId}\nTrace ID: {traceId}',
'wizard.test.request_id': 'Request ID',
'wizard.test.trace_id': 'Trace ID',
'wizard.test.copy_request_id': 'Копировать Request ID',
'wizard.test.copy_trace_id': 'Копировать Trace ID',
'wizard.test.id_copied': 'Идентификатор поддержки скопирован',
'wizard.test.id_copied_body': 'Идентификатор скопирован без содержимого запроса или ответа.',
// Common buttons
'btn.save': 'Сохранить',
@@ -1677,6 +1935,7 @@ var TRANSLATIONS = {
'agents.card.keys': 'ключей',
'agents.card.calls_today': 'вызовов сегодня',
'agents.card.created': 'Создан {date}',
'agents.card.revision': 'Черновик v{draft} · Опубликована v{published} · Ревизия каталога {revision}',
'agents.card.copy_endpoint': 'Скопировать endpoint',
'agents.card.endpoint_help': 'Данный эндпоинт требуется указать на стороне MCP клиента вместе с API ключом.',
'agents.card.endpoint_help_community': 'Данный эндпоинт требуется указать на стороне MCP клиента вместе с API ключом.',
@@ -1701,6 +1960,7 @@ var TRANSLATIONS = {
'agents.drawer.optional': '(необязательно)',
'agents.drawer.required': 'обязательно',
'agents.drawer.status': 'Статус',
'agents.drawer.revision': 'Черновик v{draft} · Опубликована v{published} · Ревизия каталога {revision}',
'agents.drawer.endpoint': 'MCP endpoint',
'agents.drawer.slug_hint': 'Slug используется как часть endpoint-а для идентификации агента.',
'agents.drawer.placeholder.name': 'Customer Support',
@@ -1759,12 +2019,17 @@ var TRANSLATIONS = {
'agents.toast.delete_message': 'Агент {name} удален.',
'agents.toast.delete_error_title': 'Не удалось удалить',
'agents.toast.delete_error_message': 'Не удалось удалить агента',
'agents.toast.delete_forbidden_message': 'Опубликованных агентов нельзя удалить. Сначала архивируйте или снимите агента с публикации.',
'agents.toast.lifecycle_title': 'Жизненный цикл агента обновлен',
'agents.toast.lifecycle_publish_confirm': 'Опубликовать {name}? Опубликованный MCP-каталог изменится.',
'agents.toast.lifecycle_unpublish_confirm': 'Снять {name} с публикации? Текущие MCP-клиенты больше не увидят этот каталог агента.',
'agents.toast.lifecycle_archive_confirm': 'Архивировать {name}? Новые публикации и привязки будут заблокированы.',
'agents.toast.lifecycle_publish': '{name} опубликован.',
'agents.toast.lifecycle_unpublish': '{name} возвращен в черновик.',
'agents.toast.lifecycle_archive': '{name} архивирован.',
'agents.toast.lifecycle_error_title': 'Не удалось обновить жизненный цикл',
'agents.toast.lifecycle_error_message': 'Не удалось обновить состояние агента',
'agents.toast.stale_message': 'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
'agents.toast.endpoint_title': 'MCP endpoint скопирован',
// Demo content
@@ -1781,9 +2046,24 @@ var TRANSLATIONS = {
'login.email_label': 'Email адрес',
'login.email_placeholder': 'you@acme.com',
'login.password_only': 'Войдите с помощью email и пароля.',
'login.bootstrap.title': 'Создать администратора',
'login.bootstrap.subtitle': 'Завершите локальную первичную настройку',
'login.bootstrap.note': 'Введите одноразовый токен из crank-migrate и задайте первый пароль администратора.',
'login.bootstrap.token_label': 'Bootstrap token',
'login.bootstrap.token_placeholder': 'Вставьте одноразовый bootstrap token',
'login.bootstrap.submit': 'Создать администратора',
'login.loading': 'Входим…',
'login.success': 'Вход выполнен. Перенаправляем…',
'login.bootstrap.loading': 'Создаем администратора…',
'login.bootstrap.success': 'Администратор создан. Перенаправляем…',
'login.error.required': 'Введите email и пароль.',
'login.bootstrap.error.required': 'Введите bootstrap token и пароль.',
'login.error.invalid': 'Неверный email или пароль. Попробуйте еще раз.',
'login.error.throttled': 'Слишком много попыток. Подождите и попробуйте снова.',
'login.error.expired_session': 'Сессия истекла. Войдите снова.',
'login.error.generic': 'Сейчас не удается войти. Попробуйте еще раз.',
'login.error.request_id': 'Request ID',
'login.error.trace_id': 'Trace ID',
}
};
@@ -1793,6 +2073,36 @@ Object.assign(TRANSLATIONS.en, {
});
Object.assign(TRANSLATIONS.ru, {
'execution.error.authorization_denied': 'Выполнение операции запрещено.',
'execution.error.auth_profile_not_found': 'Профиль авторизации не найден.',
'execution.error.secret_not_found': 'Секрет авторизации не найден.',
'execution.error.secret_invalid': 'Секрет авторизации имеет неподходящий формат.',
'execution.error.input_schema_invalid': 'Входные параметры не прошли проверку схемы.',
'execution.error.input_mapping_invalid': 'Не удалось сопоставить входные параметры.',
'execution.error.prepared_request_invalid': 'Не удалось подготовить корректный API-запрос.',
'execution.error.execution_overloaded': 'Сервис временно перегружен.',
'execution.error.safety_store_unavailable': 'Обязательное хранилище безопасности недоступно.',
'execution.error.protocol_unsupported': 'Протокол операции не поддерживается.',
'execution.error.execution_mode_unsupported': 'Режим выполнения не поддерживается.',
'execution.error.adapter_configuration_invalid': 'Конфигурация адаптера некорректна.',
'execution.error.outbound_target_rejected': 'Целевой адрес отклонён политикой безопасности.',
'execution.error.upstream_auth_error': 'Внешний API отклонил авторизацию.',
'execution.error.upstream_not_found': 'Ресурс внешнего API не найден.',
'execution.error.upstream_rate_limited': 'Внешний API ограничил частоту запросов.',
'execution.error.upstream_server_error': 'Внешний API временно недоступен.',
'execution.error.upstream_status_error': 'Внешний API вернул ошибочный статус.',
'execution.error.upstream_timeout': 'Истекло время ожидания внешнего API.',
'execution.error.upstream_transport_error': 'Не удалось подключиться к внешнему API.',
'execution.error.upstream_response_too_large': 'Ответ внешнего API превышает лимит.',
'execution.error.output_mapping_invalid': 'Не удалось сопоставить ответ внешнего API.',
'execution.error.output_schema_invalid': 'Ответ не прошёл проверку схемы.',
'execution.error.persistence_unavailable': 'Обязательное сохранение результата недоступно.',
'execution.error.runtime_internal': 'Внутренняя ошибка выполнения.',
'execution.error.confirmation_required': 'Операция требует подтверждения.',
'execution.error.confirmation_invalid': 'Подтверждение недействительно или истекло.',
'execution.error.idempotency_in_progress': 'Операция с этим ключом уже выполняется.',
'execution.error.idempotency_conflict': 'Ключ идемпотентности использован с другими параметрами.',
'execution.error.idempotency_outcome_unknown': 'Результат предыдущего выполнения неизвестен; автоматический повтор запрещён.',
});
+73 -5
View File
@@ -3,6 +3,17 @@
return window.t ? t(key) : key;
}
function diagnosticSuffix(error) {
var parts = [];
if (error && error.requestId) {
parts.push(tKey('login.error.request_id') + ': ' + error.requestId);
}
if (error && error.traceId) {
parts.push(tKey('login.error.trace_id') + ': ' + error.traceId);
}
return parts.length ? ('\n' + parts.join('\n')) : '';
}
function showError(message) {
var errorElement = document.getElementById('login-error');
errorElement.classList.add('is-visible');
@@ -14,34 +25,91 @@
errorElement.classList.remove('is-visible');
}
function setBootstrapMode(enabled) {
var emailField = document.getElementById('login-email-field');
var tokenField = document.getElementById('login-bootstrap-token-field');
var heading = document.querySelector('.login-heading');
var subtitle = document.querySelector('.login-sub');
var note = document.querySelector('.login-note');
var submit = document.getElementById('login-submit');
var password = document.getElementById('password');
document.getElementById('login-form').dataset.bootstrap = enabled ? 'true' : 'false';
if (emailField) emailField.hidden = enabled;
if (tokenField) tokenField.hidden = !enabled;
if (heading) heading.textContent = tKey(enabled ? 'login.bootstrap.title' : 'login.title');
if (subtitle) subtitle.textContent = tKey(enabled ? 'login.bootstrap.subtitle' : 'login.subtitle');
if (note) note.textContent = tKey(enabled ? 'login.bootstrap.note' : 'login.password_only');
if (submit) submit.textContent = tKey(enabled ? 'login.bootstrap.submit' : 'login.submit');
if (password) password.setAttribute('autocomplete', enabled ? 'new-password' : 'current-password');
}
function initLoginPage() {
var inFlight = false;
window.CrankAuth.guardLoginPage().catch(function(error) {
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.report === 'function') {
window.CrankDiagnostics.report('guard-login-page', error, 'login');
}
});
setBootstrapMode(false);
window.CrankApi.getBootstrapStatus()
.then(function(status) {
setBootstrapMode(Boolean(status && status.bootstrap_required));
})
.catch(function(error) {
if (window.CrankDiagnostics && typeof window.CrankDiagnostics.report === 'function') {
window.CrankDiagnostics.report('bootstrap-status', error, 'login');
}
});
document.getElementById('login-form').addEventListener('submit', async function(event) {
event.preventDefault();
if (inFlight) {
return;
}
var form = event.currentTarget;
var isBootstrap = event.currentTarget.dataset.bootstrap === 'true';
var email = document.getElementById('email').value.trim();
var token = document.getElementById('bootstrap-token').value.trim();
var password = document.getElementById('password').value;
var submit = document.getElementById('login-submit');
if (!email || !password) {
showError(tKey('login.error.required'));
if ((!isBootstrap && !email) || (isBootstrap && !token) || !password) {
showError(tKey(isBootstrap ? 'login.bootstrap.error.required' : 'login.error.required'));
return;
}
hideError();
inFlight = true;
if (submit) {
submit.disabled = true;
submit.textContent = tKey(isBootstrap ? 'login.bootstrap.loading' : 'login.loading');
}
try {
await window.CrankAuth.login(email, password);
if (isBootstrap) {
await window.CrankAuth.completeBootstrap(token, password);
} else {
await window.CrankAuth.login(email, password);
}
showError(tKey(isBootstrap ? 'login.bootstrap.success' : 'login.success'));
} catch (error) {
if (error && error.status === 401) {
showError(tKey('login.error.invalid'));
showError(tKey('login.error.invalid') + diagnosticSuffix(error));
return;
}
showError(tKey('login.error.generic'));
if (error && error.status === 429) {
showError(tKey('login.error.throttled') + diagnosticSuffix(error));
return;
}
showError(tKey('login.error.generic') + diagnosticSuffix(error));
} finally {
inFlight = false;
if (submit && form.isConnected) {
submit.disabled = false;
submit.textContent = tKey(isBootstrap ? 'login.bootstrap.submit' : 'login.submit');
}
}
});
}
+297 -14
View File
@@ -3,9 +3,14 @@ document.addEventListener('DOMContentLoaded', function () {
logs: [],
details: {},
level: 'all',
status: 'all',
outcomeGroup: 'all',
operationId: '',
agentId: '',
search: '',
period: '7d',
openId: null,
nextCursor: null,
liveMode: true,
timer: null,
searchTimer: null,
@@ -16,6 +21,10 @@ document.addEventListener('DOMContentLoaded', function () {
approvals: [],
approvalsLoading: false,
approvalsError: '',
approvalActionBusy: {},
approvalsEpoch: 0,
logsEpoch: 0,
exporting: false,
};
var logList = document.getElementById('log-list');
@@ -23,9 +32,21 @@ document.addEventListener('DOMContentLoaded', function () {
var approvalRefreshBtn = document.getElementById('approval-refresh-btn');
var logSearch = document.getElementById('log-search');
var refreshBtn = document.getElementById('refresh-btn');
var exportLogsBtn = document.getElementById('export-logs-btn');
var loadMoreBtn = document.getElementById('load-more-logs-btn');
var timeRangeSel = document.getElementById('time-range');
var statusFilter = document.getElementById('status-filter');
var outcomeFilter = document.getElementById('outcome-filter');
var operationFilter = document.getElementById('operation-filter');
var agentFilter = document.getElementById('agent-filter');
var liveDot = document.querySelector('.live-dot');
var liveLabel = document.querySelector('.live-label');
var initialParams = new URLSearchParams(window.location.search);
var deepLinkLogId = /^[A-Za-z0-9_-]{1,128}$/.test(initialParams.get('log_id') || '') ? initialParams.get('log_id') : '';
var deepLinkAgentId = /^[A-Za-z0-9_-]{1,128}$/.test(initialParams.get('agent_id') || '') ? initialParams.get('agent_id') : '';
var deepLinkOperationId = /^[A-Za-z0-9_-]{1,128}$/.test(initialParams.get('operation_id') || '') ? initialParams.get('operation_id') : '';
state.agentId = deepLinkAgentId;
state.operationId = deepLinkOperationId;
function tKey(key) {
return window.t ? t(key) : key;
@@ -118,6 +139,7 @@ document.addEventListener('DOMContentLoaded', function () {
status: log.status,
statusCode: log.status_code,
durationMs: log.duration_ms,
operationVersion: log.operation_version,
toolName: log.tool_name,
message: log.message,
operationName: record.operation_name,
@@ -127,7 +149,10 @@ document.addEventListener('DOMContentLoaded', function () {
requestPreview: log.request_preview,
responsePreview: log.response_preview,
errorKind: log.error_kind,
executionStage: log.execution_stage,
executionErrorCode: log.execution_error_code,
requestId: log.request_id,
traceId: log.trace_id,
};
}
@@ -142,6 +167,8 @@ document.addEventListener('DOMContentLoaded', function () {
riskLevel: approval.risk_level,
requestPayload: approval.request_payload,
responsePayload: approval.response_payload,
requestId: approval.request_id,
traceId: approval.trace_id,
createdAt: approval.created_at,
expiresAt: approval.expires_at,
decidedAt: approval.decided_at,
@@ -206,6 +233,17 @@ document.addEventListener('DOMContentLoaded', function () {
: tKey('approvals.updated_at') + ': ' + formatDateTime(item.decidedAt || item.createdAt);
card.appendChild(timing);
if (item.requestId || item.traceId) {
var correlation = element('div', 'approval-correlation');
if (item.requestId) {
correlation.appendChild(element('span', 'approval-correlation-id', tKey('approvals.request_id') + ': ' + item.requestId));
}
if (item.traceId) {
correlation.appendChild(element('span', 'approval-correlation-id', tKey('approvals.trace_id') + ': ' + item.traceId));
}
card.appendChild(correlation);
}
var payloadGrid = element('div', 'approval-payload-grid');
var requestBlock = element('div', 'approval-payload');
requestBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.request')));
@@ -228,6 +266,24 @@ document.addEventListener('DOMContentLoaded', function () {
card.appendChild(element('div', 'approval-note', item.note));
}
if (item.status === 'pending') {
var actions = element('div', 'approval-actions');
var busy = Boolean(state.approvalActionBusy[item.id]);
var approve = element('button', 'btn btn-primary btn-sm approval-approve', busy ? tKey('approvals.action.busy') : tKey('approvals.action.approve'));
approve.type = 'button';
approve.disabled = busy;
approve.dataset.approvalId = item.id;
approve.dataset.action = 'approve';
var deny = element('button', 'btn btn-secondary btn-sm approval-deny', busy ? tKey('approvals.action.busy') : tKey('approvals.action.deny'));
deny.type = 'button';
deny.disabled = busy;
deny.dataset.approvalId = item.id;
deny.dataset.action = 'deny';
actions.appendChild(approve);
actions.appendChild(deny);
card.appendChild(actions);
}
fragment.appendChild(card);
});
@@ -256,10 +312,13 @@ document.addEventListener('DOMContentLoaded', function () {
if (!state.logs.length) {
renderEmpty(
tKey('logs.empty.title'),
state.search || state.level !== 'all'
state.search || state.level !== 'all' || state.status !== 'all' || state.outcomeGroup !== 'all' || state.operationId || state.agentId
? tKey('logs.empty.filtered')
: tKey('logs.empty.initial')
);
if (loadMoreBtn) {
loadMoreBtn.hidden = true;
}
return;
}
@@ -347,7 +406,7 @@ document.addEventListener('DOMContentLoaded', function () {
responsePre.textContent = formatJson(detail.responsePreview);
expanded.appendChild(responsePre);
if (detail.errorKind || detail.requestId) {
if (detail.errorKind || detail.requestId || detail.traceId || detail.executionStage || detail.executionErrorCode || detail.operationVersion) {
var metaLabel = document.createElement('div');
metaLabel.className = 'log-detail-label';
metaLabel.textContent = tKey('logs.detail.meta');
@@ -357,7 +416,11 @@ document.addEventListener('DOMContentLoaded', function () {
metaPre.className = 'log-detail-block';
metaPre.textContent = formatJson({
request_id: detail.requestId || null,
trace_id: detail.traceId || null,
operation_version: detail.operationVersion || null,
error_kind: detail.errorKind || null,
execution_stage: detail.executionStage || null,
execution_error_code: detail.executionErrorCode || null,
source: detail.source,
status: detail.status,
});
@@ -370,6 +433,10 @@ document.addEventListener('DOMContentLoaded', function () {
logList.innerHTML = '';
logList.appendChild(fragment);
if (loadMoreBtn) {
loadMoreBtn.hidden = !state.nextCursor;
loadMoreBtn.disabled = state.loading;
}
logList.querySelectorAll('.log-entry').forEach(function (row) {
row.addEventListener('click', async function () {
@@ -383,21 +450,60 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
function queryParams() {
function invalidateLogsRequest() {
state.logsEpoch += 1;
state.nextCursor = null;
}
function queryParams(options) {
options = options || {};
var params = {
period: state.period,
limit: 100,
};
if (options.includeLimit !== false) {
params.limit = 100;
}
if (state.level !== 'all') {
params.level = state.level;
}
if (state.search) {
params.search = state.search;
}
if (state.status !== 'all') {
params.status = state.status;
}
if (state.outcomeGroup !== 'all') {
params.outcome_group = state.outcomeGroup;
}
if (state.operationId) {
params.operation_id = state.operationId;
}
if (state.agentId) {
params.agent_id = state.agentId;
}
return params;
}
async function loadLogs() {
function bindTextFilter(input, key) {
if (!input) {
return;
}
input.value = state[key] || '';
input.addEventListener('input', function () {
state[key] = this.value.trim();
invalidateLogsRequest();
if (state.searchTimer) {
clearTimeout(state.searchTimer);
}
state.searchTimer = setTimeout(function () {
state.searchTimer = null;
loadLogs();
}, 250);
});
}
async function loadLogs(options) {
options = options || {};
if (!window.CrankApi) {
state.loadError = tKey('logs.error.api');
renderLogs();
@@ -411,21 +517,39 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var workspaceId = state.workspaceId;
var epoch = ++state.logsEpoch;
var append = Boolean(options.append && state.nextCursor);
var params = queryParams();
if (append) {
params.cursor = state.nextCursor;
}
state.loading = true;
state.loadError = '';
renderLogs();
try {
var response = await window.CrankApi.listLogs(state.workspaceId, queryParams());
state.logs = (response && response.items ? response.items : []).map(normalizeLog);
var response = await window.CrankApi.listLogs(workspaceId, params);
if (epoch !== state.logsEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
var loaded = (response && response.items ? response.items : []).map(normalizeLog);
state.logs = append ? state.logs.concat(loaded) : loaded;
state.nextCursor = response && response.next_cursor ? response.next_cursor : null;
if (state.openId && !state.logs.some(function (item) { return item.id === state.openId; })) {
state.openId = null;
}
} catch (error) {
if (epoch !== state.logsEpoch) {
return;
}
state.loadError = error.message || tKey('logs.error.load');
} finally {
state.loading = false;
renderLogs();
if (epoch === state.logsEpoch) {
state.loading = false;
renderLogs();
}
}
}
@@ -443,17 +567,54 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var epoch = ++state.approvalsEpoch;
var workspaceId = state.workspaceId;
state.approvalsLoading = true;
state.approvalsError = '';
renderApprovals();
try {
var response = await window.CrankApi.listApprovals(state.workspaceId, { limit: 20 });
var response = await window.CrankApi.listApprovals(workspaceId, { limit: 20 });
if (epoch !== state.approvalsEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
state.approvals = (response && response.items ? response.items : []).map(normalizeApproval);
} catch (error) {
if (epoch !== state.approvalsEpoch) {
return;
}
state.approvalsError = error.message || tKey('approvals.error.load');
} finally {
state.approvalsLoading = false;
if (epoch === state.approvalsEpoch) {
state.approvalsLoading = false;
renderApprovals();
}
}
}
async function decideApproval(approvalId, action) {
if (!window.CrankApi || !state.workspaceId || !approvalId) return;
if (state.approvalActionBusy[approvalId]) return;
var confirmKey = action === 'approve' ? 'approvals.confirm.approve' : 'approvals.confirm.deny';
if (!window.confirm(tKey(confirmKey))) return;
var workspaceId = state.workspaceId;
var epoch = ++state.approvalsEpoch;
state.approvalActionBusy[approvalId] = true;
state.approvalsError = '';
renderApprovals();
try {
if (action === 'approve') {
await window.CrankApi.approveApproval(workspaceId, approvalId, { approve: 'yes' });
} else {
await window.CrankApi.denyApproval(workspaceId, approvalId, { approve: 'no' });
}
if (workspaceId !== currentWorkspaceId() || epoch !== state.approvalsEpoch) return;
state.approvalActionBusy[approvalId] = false;
await loadApprovals();
} catch (error) {
if (workspaceId !== currentWorkspaceId() || epoch !== state.approvalsEpoch) return;
state.approvalsError = error.message || tKey('approvals.error.decision');
state.approvalActionBusy[approvalId] = false;
renderApprovals();
}
}
@@ -473,8 +634,13 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var workspaceId = state.workspaceId;
var epoch = state.logsEpoch;
try {
var record = await window.CrankApi.getLog(state.workspaceId, logId);
var record = await window.CrankApi.getLog(workspaceId, logId);
if (epoch !== state.logsEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
state.details[logId] = normalizeLog(record);
if (state.openId === logId) {
renderLogs();
@@ -483,6 +649,32 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
async function loadExactLog(logId) {
var workspaceId = currentWorkspaceId();
var epoch = ++state.logsEpoch;
state.workspaceId = workspaceId;
state.loading = true;
state.loadError = '';
renderLogs();
try {
var record = await window.CrankApi.getLog(workspaceId, logId);
if (epoch !== state.logsEpoch || workspaceId !== currentWorkspaceId()) return;
var detail = normalizeLog(record);
state.logs = [detail];
state.details[logId] = detail;
state.openId = logId;
state.nextCursor = null;
} catch (error) {
if (epoch !== state.logsEpoch) return;
state.loadError = error.message || tKey('logs.error.load');
} finally {
if (epoch === state.logsEpoch) {
state.loading = false;
renderLogs();
}
}
}
function setLiveState() {
if (liveDot) {
liveDot.classList.toggle('is-paused', !state.liveMode);
@@ -524,9 +716,52 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
async function exportLogsCsv() {
if (!window.CrankApi || state.exporting) {
return;
}
var workspaceId = currentWorkspaceId();
if (!workspaceId) {
if (window.CrankUi) {
window.CrankUi.error(tKey('logs.error.workspace'), tKey('logs.export.error_title'));
}
return;
}
state.exporting = true;
if (exportLogsBtn) {
exportLogsBtn.disabled = true;
}
try {
var csv = await window.CrankApi.exportLogsCsv(workspaceId, queryParams({ includeLimit: false }));
if (workspaceId !== currentWorkspaceId()) {
return;
}
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'crank-invocation-history.csv';
link.click();
URL.revokeObjectURL(url);
if (window.CrankUi) {
window.CrankUi.success(tKey('logs.export.done_body'), tKey('logs.export.done_title'));
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('logs.export.error_body'), tKey('logs.export.error_title'));
}
} finally {
state.exporting = false;
if (exportLogsBtn) {
exportLogsBtn.disabled = false;
}
}
}
document.querySelectorAll('.filter-chip[data-level]').forEach(function (button) {
button.addEventListener('click', function () {
state.level = this.getAttribute('data-level');
invalidateLogsRequest();
document.querySelectorAll('.filter-chip[data-level]').forEach(function (item) {
item.classList.remove('active');
});
@@ -538,6 +773,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (logSearch) {
logSearch.addEventListener('input', function () {
state.search = this.value.trim();
invalidateLogsRequest();
if (state.searchTimer) {
clearTimeout(state.searchTimer);
}
@@ -548,8 +784,30 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (statusFilter) {
statusFilter.value = state.status;
statusFilter.addEventListener('change', function () {
state.status = this.value || 'all';
invalidateLogsRequest();
loadLogs();
});
}
if (outcomeFilter) {
outcomeFilter.value = state.outcomeGroup;
outcomeFilter.addEventListener('change', function () {
state.outcomeGroup = this.value || 'all';
invalidateLogsRequest();
loadLogs();
});
}
bindTextFilter(operationFilter, 'operationId');
bindTextFilter(agentFilter, 'agentId');
if (refreshBtn) {
refreshBtn.addEventListener('click', function () {
invalidateLogsRequest();
loadLogs().then(function () {
if (!state.loadError && window.CrankUi) {
window.CrankUi.info(tKey('logs.refresh.body'), tKey('logs.refresh.title'));
@@ -558,6 +816,16 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (exportLogsBtn) {
exportLogsBtn.addEventListener('click', exportLogsCsv);
}
if (loadMoreBtn) {
loadMoreBtn.addEventListener('click', function () {
loadLogs({ append: true });
});
}
if (approvalRefreshBtn) {
approvalRefreshBtn.addEventListener('click', function () {
loadApprovals().then(function () {
@@ -568,10 +836,19 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (approvalList) {
approvalList.addEventListener('click', function (event) {
var button = event.target.closest('button[data-approval-id][data-action]');
if (!button) return;
decideApproval(button.dataset.approvalId, button.dataset.action);
});
}
if (timeRangeSel) {
timeRangeSel.value = state.period;
timeRangeSel.addEventListener('change', function () {
state.period = this.value;
invalidateLogsRequest();
loadLogs();
});
}
@@ -585,8 +862,10 @@ document.addEventListener('DOMContentLoaded', function () {
}
window.addEventListener('crank:workspacechange', function () {
state.logsEpoch += 1;
state.details = {};
state.openId = null;
state.nextCursor = null;
refreshOperationalData();
});
@@ -612,8 +891,12 @@ document.addEventListener('DOMContentLoaded', function () {
startPolling();
if (window.whenWorkspacesReady) {
window.whenWorkspacesReady().finally(refreshOperationalData);
window.whenWorkspacesReady().finally(function() {
if (deepLinkLogId) return Promise.all([loadExactLog(deepLinkLogId), loadApprovals()]);
return refreshOperationalData();
});
} else {
refreshOperationalData();
if (deepLinkLogId) Promise.all([loadExactLog(deepLinkLogId), loadApprovals()]);
else refreshOperationalData();
}
});
+488
View File
@@ -0,0 +1,488 @@
(function() {
var STEP_ORDER = ['operation', 'test', 'publish_operation', 'agent', 'key', 'mcp_connection', 'first_call'];
var state = {
epoch: 0,
workspaceId: null,
snapshot: null,
loading: false,
error: null,
open: false,
dismissed: false,
controller: null,
refreshPromise: null,
refreshQueued: false,
startedRequested: false,
};
var channel = null;
var trigger = null;
var panel = null;
var heading = null;
var subtitle = null;
var collapseButton = null;
var lastFocused = null;
function tKey(key) { return window.t ? window.t(key) : key; }
function currentWorkspace() { return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; }
function preferenceKey() { return 'crank_onboarding_ui:' + (state.workspaceId || 'none'); }
function safePreference() {
try { return JSON.parse(localStorage.getItem(preferenceKey())) || {}; } catch (_error) { return {}; }
}
function writePreference(value) {
try { localStorage.setItem(preferenceKey(), JSON.stringify(value)); } catch (_error) {}
}
function updatePreference(value) {
writePreference(Object.assign({}, safePreference(), value));
}
function injectStylesheet() {
if (document.querySelector('link[data-crank-onboarding-css]')) return;
var link = document.createElement('link');
link.rel = 'stylesheet';
link.href = (window.APP_BASE || '/') + 'css/onboarding.css';
link.dataset.crankOnboardingCss = 'true';
document.head.appendChild(link);
}
function button(label, testId, handler, className) {
var node = document.createElement('button');
node.type = 'button';
node.textContent = label;
if (testId) node.dataset.testid = testId;
node.className = className || 'onboarding-icon-button';
node.addEventListener('click', handler);
return node;
}
function normalizeStep(raw) {
var id = raw.id || raw.kind;
if (id === 'publish') id = 'publish_operation';
if (id === 'connection') id = 'mcp_connection';
var completed = raw.completed === true || raw.status === 'complete';
return {
id: id,
completed: completed,
status: raw.status || (completed ? 'complete' : 'pending'),
actionCode: raw.action_code || raw.action || '',
reasonCode: raw.reason_code || '',
};
}
function normalizedSteps() {
var raw = state.snapshot && Array.isArray(state.snapshot.steps) ? state.snapshot.steps : [];
var mapped = raw.map(normalizeStep);
return STEP_ORDER.map(function(id) {
return mapped.find(function(item) { return item.id === id; }) || { id: id, completed: false, status: 'pending', actionCode: '', reasonCode: '' };
});
}
function completedCount() { return normalizedSteps().filter(function(step) { return step.completed; }).length; }
function currentStep(steps) {
return steps.find(function(step) { return !step.completed && step.status === 'current'; })
|| steps.find(function(step) { return !step.completed; }) || null;
}
function stepLabel(id) { return tKey('onboarding.step.' + id); }
function actionLabel(id) { return tKey('onboarding.action.' + id); }
function deepLink(step) {
var snapshot = state.snapshot || {};
var returnTo = encodeURIComponent(window.location.pathname + window.location.search);
if (step.id === 'operation') return '/wizard/?onboarding=1&return=' + returnTo;
if (step.id === 'test' || step.id === 'publish_operation') {
if (!snapshot.operation_id) return '/wizard/?onboarding=1&return=' + returnTo;
return '/wizard/?mode=edit&operationId=' + encodeURIComponent(snapshot.operation_id)
+ '&onboarding=1&step=5&return=' + returnTo;
}
if (step.id === 'agent') {
return '/agents?onboarding=1&action=create&operationId=' + encodeURIComponent(snapshot.operation_id || '')
+ '&operationVersion=' + encodeURIComponent(snapshot.operation_version || '') + '&return=' + returnTo;
}
if (step.id === 'key' || step.id === 'mcp_connection') {
return '/api-keys?onboarding=1&action=create&agentId=' + encodeURIComponent(snapshot.agent_id || '')
+ '&agentRevision=' + encodeURIComponent(snapshot.catalog_revision || '') + '&return=' + returnTo;
}
if (step.id === 'first_call' && snapshot.first_call && snapshot.first_call.log_id) {
return '/logs?log_id=' + encodeURIComponent(snapshot.first_call.log_id);
}
return '/logs?agent_id=' + encodeURIComponent(snapshot.agent_id || '');
}
function navigate(step) { window.location.href = deepLink(step); }
function recordStarted() {
if (state.startedRequested || !state.snapshot || state.snapshot.completed) return;
state.startedRequested = true;
recordPresentation('started', { stable: true }).then(function(response) {
if (!response) state.startedRequested = false;
});
}
function setOpen(open) {
state.open = open;
panel.hidden = !open;
trigger.setAttribute('aria-expanded', String(open));
if (open) {
lastFocused = document.activeElement;
render();
var focusTarget = panel.querySelector('[aria-current="step"] .onboarding-step-action') || panel.querySelector('button');
if (focusTarget) focusTarget.focus();
recordStarted();
} else if (lastFocused && typeof lastFocused.focus === 'function') {
lastFocused.focus();
}
}
function render() {
if (!trigger || !panel) return;
var count = completedCount();
trigger.textContent = state.dismissed
? tKey('onboarding.resume')
: tKey('onboarding.title') + ' · ' + count + '/7';
trigger.setAttribute('aria-label', state.dismissed ? tKey('onboarding.resume') : trigger.textContent);
if (heading) heading.textContent = tKey('onboarding.title');
if (subtitle) subtitle.textContent = tKey('onboarding.subtitle');
if (collapseButton) collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
var body = panel.querySelector('.onboarding-body');
var hadActionFocus = state.open
&& document.activeElement
&& body.contains(document.activeElement)
&& document.activeElement.classList.contains('onboarding-step-action');
body.replaceChildren();
var status = document.createElement('div');
status.className = 'onboarding-status';
status.setAttribute('role', 'status');
status.setAttribute('aria-live', 'polite');
status.id = 'crank-onboarding-progress';
status.textContent = state.loading ? tKey('onboarding.loading') : tKey('onboarding.progress').replace('{count}', String(count));
body.appendChild(status);
if (state.error) {
var error = document.createElement('div');
error.className = 'onboarding-error';
error.setAttribute('role', 'alert');
var message = document.createElement('div');
message.textContent = tKey('onboarding.error');
error.appendChild(message);
var errorCode = state.error.code
|| (state.error.payload && state.error.payload.error && (state.error.payload.error.code || state.error.payload.error.error_code))
|| (state.error.payload && (state.error.payload.code || state.error.payload.error_code));
var support = [
errorCode ? tKey('onboarding.error_code') + ': ' + errorCode : '',
state.error.requestId ? tKey('onboarding.request_id') + ': ' + state.error.requestId : '',
state.error.traceId ? tKey('onboarding.trace_id') + ': ' + state.error.traceId : '',
].filter(Boolean);
if (support.length) {
var supportIds = document.createElement('div');
supportIds.className = 'onboarding-error-ids';
supportIds.textContent = support.join(' · ');
error.appendChild(supportIds);
}
error.appendChild(button(tKey('onboarding.retry'), 'onboarding-retry', refresh, 'onboarding-step-action'));
body.appendChild(error);
if (state.focusError && state.open) {
state.focusError = false;
var retry = error.querySelector('[data-testid="onboarding-retry"]');
if (retry) retry.focus();
}
return;
}
if (!state.snapshot) return;
var steps = normalizedSteps();
var active = currentStep(steps);
if (state.snapshot.completed || !active) {
var complete = document.createElement('div');
complete.className = 'onboarding-completion';
complete.dataset.testid = 'onboarding-completion';
complete.textContent = tKey('onboarding.completed');
var firstCall = state.snapshot.first_call;
if (firstCall) {
var evidence = document.createElement('dl');
evidence.className = 'onboarding-first-call-evidence';
[
[tKey('onboarding.tool'), firstCall.tool_name],
[tKey('onboarding.timestamp'), firstCall.occurred_at],
[tKey('onboarding.request_id'), firstCall.request_id],
[tKey('onboarding.trace_id'), firstCall.trace_id],
].forEach(function(entry) {
if (!entry[1]) return;
var term = document.createElement('dt');
term.textContent = entry[0];
var value = document.createElement('dd');
value.textContent = entry[1];
evidence.appendChild(term);
evidence.appendChild(value);
});
complete.appendChild(evidence);
if (firstCall.log_id) {
var link = document.createElement('a');
link.href = '/logs?log_id=' + encodeURIComponent(firstCall.log_id);
link.textContent = tKey('onboarding.open_invocation');
complete.appendChild(link);
}
}
body.appendChild(complete);
} else {
var list = document.createElement('ol');
list.className = 'onboarding-list';
list.setAttribute('aria-label', tKey('onboarding.progress_label'));
list.setAttribute('aria-describedby', 'crank-onboarding-progress');
steps.forEach(function(step, index) {
var item = document.createElement('li');
item.className = 'onboarding-step' + (step.completed ? ' is-complete' : '') + (step.status === 'regressed' ? ' is-regressed' : '');
if (step === active) item.setAttribute('aria-current', 'step');
var marker = document.createElement('span');
marker.className = 'onboarding-step-marker';
marker.textContent = step.completed ? '✓' : String(index + 1);
marker.setAttribute('aria-hidden', 'true');
var content = document.createElement('div');
var title = document.createElement('div');
title.className = 'onboarding-step-title';
title.textContent = stepLabel(step.id);
content.appendChild(title);
if (step === active) {
var reason = document.createElement('div');
reason.className = 'onboarding-step-reason';
reason.textContent = tKey(step.status === 'regressed' ? 'onboarding.regressed' : 'onboarding.action_needed');
content.appendChild(reason);
content.appendChild(button(actionLabel(step.id), null, function() { navigate(step); }, 'onboarding-step-action'));
}
item.appendChild(marker);
item.appendChild(content);
list.appendChild(item);
});
body.appendChild(list);
}
var footer = document.createElement('div');
footer.className = 'onboarding-footer';
footer.appendChild(button(tKey('onboarding.refresh'), 'onboarding-refresh', refresh, 'onboarding-link-button'));
footer.appendChild(button(tKey('onboarding.dismiss'), 'onboarding-dismiss', dismiss, 'onboarding-link-button'));
body.appendChild(footer);
if (hadActionFocus) {
var replacementAction = panel.querySelector('[aria-current="step"] .onboarding-step-action');
if (replacementAction) replacementAction.focus();
}
}
function refresh() {
var workspace = currentWorkspace();
var workspaceId = workspace ? workspace.id : null;
if (state.refreshPromise && workspaceId === state.workspaceId) {
state.refreshQueued = true;
return state.refreshPromise;
}
if (workspaceId !== state.workspaceId) state.refreshQueued = false;
var promise = performRefresh(workspaceId);
state.refreshPromise = promise;
promise.finally(function() {
if (state.refreshPromise !== promise) return;
state.refreshPromise = null;
if (state.refreshQueued) {
state.refreshQueued = false;
refresh();
}
});
return promise;
}
async function performRefresh(workspaceId) {
var workspaceChanged = workspaceId !== state.workspaceId;
var epoch = ++state.epoch;
if (state.controller) state.controller.abort();
state.controller = typeof AbortController === 'function' ? new AbortController() : null;
state.workspaceId = workspaceId;
if (workspaceChanged) {
state.dismissed = Boolean(safePreference().dismissed);
state.startedRequested = false;
if (state.open) setOpen(false);
}
state.loading = true;
state.error = null;
state.focusError = false;
render();
if (!workspaceId || !window.CrankApi || typeof window.CrankApi.getOnboarding !== 'function') {
state.loading = false;
state.error = new Error(tKey('onboarding.error'));
render();
return;
}
try {
var snapshot = await window.CrankApi.getOnboarding(workspaceId);
if (epoch !== state.epoch || workspaceId !== (currentWorkspace() && currentWorkspace().id)) return;
state.snapshot = snapshot;
var preference = safePreference();
if (window.location.pathname === '/'
&& !preference.opened
&& !preference.dismissed
&& !snapshot.completed) {
updatePreference({ opened: true });
setOpen(true);
}
renderDeepLinkRecovery(snapshot);
} catch (error) {
if (epoch !== state.epoch) return;
state.error = error;
state.focusError = state.open;
} finally {
if (epoch === state.epoch) {
state.loading = false;
render();
}
}
}
async function recordPresentation(eventName, options) {
if (!state.snapshot || !state.workspaceId || !window.CrankApi.recordOnboardingEvent) return;
var expectedRevision = Number(state.snapshot.revision || 0);
var expectedEpoch = state.epoch;
var expectedWorkspaceId = state.workspaceId;
var key = options && options.stable
? ['ui', eventName, state.workspaceId, 'v1'].join(':')
: ['ui', eventName, state.workspaceId, expectedRevision].join(':');
try {
var response = await window.CrankApi.recordOnboardingEvent(state.workspaceId, {
event: eventName,
idempotency_key: key,
expected_revision: expectedRevision,
}, options && options.keepalive ? { keepalive: true } : null);
var currentRevision = Number(state.snapshot && state.snapshot.revision || 0);
if (response
&& expectedEpoch === state.epoch
&& expectedWorkspaceId === state.workspaceId
&& expectedWorkspaceId === (currentWorkspace() && currentWorkspace().id)
&& currentRevision === expectedRevision) {
state.snapshot = response;
render();
}
return response;
} catch (error) {
if (error.status === 409) await refresh();
return null;
}
}
async function dismiss() {
updatePreference({ dismissed: true, opened: true });
state.dismissed = true;
setOpen(false);
render();
await recordPresentation('dismissed');
await recordPresentation('abandoned', { stable: true });
}
function resume() {
updatePreference({ dismissed: false, opened: true });
state.dismissed = false;
setOpen(true);
recordPresentation('resumed');
}
function renderDeepLinkRecovery(snapshot) {
var params = new URLSearchParams(window.location.search);
if (params.get('onboarding') !== '1') return;
var stale = false;
if (window.location.pathname === '/agents') {
var expectedOperation = params.get('operationId') || '';
var expectedVersion = Number(params.get('operationVersion') || 0);
stale = Boolean(expectedOperation) && (
snapshot.operation_id !== expectedOperation
|| (expectedVersion && snapshot.operation_version !== expectedVersion)
);
} else if (window.location.pathname === '/api-keys') {
var expectedAgent = params.get('agentId') || '';
var expectedRevision = Number(params.get('agentRevision') || 0);
stale = Boolean(expectedAgent) && (
snapshot.agent_id !== expectedAgent
|| (expectedRevision && snapshot.catalog_revision !== expectedRevision)
);
}
var existing = document.getElementById('onboarding-deep-link-stale');
if (!stale) {
if (existing) existing.remove();
return;
}
if (existing) return;
var recovery = document.createElement('div');
recovery.id = 'onboarding-deep-link-stale';
recovery.className = 'onboarding-deep-link-stale';
recovery.setAttribute('role', 'alert');
recovery.dataset.testid = 'onboarding-deep-link-stale';
var message = document.createElement('div');
message.textContent = tKey('onboarding.deep_link_stale');
recovery.appendChild(message);
recovery.appendChild(button(tKey('onboarding.reselect'), 'onboarding-reselect', async function() {
var resetButton = this;
resetButton.disabled = true;
try {
if (window.CrankApi && typeof window.CrankApi.resetOnboardingSelection === 'function') {
await window.CrankApi.resetOnboardingSelection(state.workspaceId, Number(snapshot.revision || 0));
}
var clean = new URLSearchParams(window.location.search);
['onboarding', 'action', 'operationId', 'operationVersion', 'agentId', 'agentRevision', 'return'].forEach(function(key) {
clean.delete(key);
});
window.location.replace(window.location.pathname + (clean.toString() ? '?' + clean.toString() : ''));
} catch (error) {
resetButton.disabled = false;
state.error = error;
if (!state.open) setOpen(true);
render();
}
}, 'onboarding-step-action'));
document.body.appendChild(recovery);
}
function mount() {
if (document.getElementById('crank-onboarding-root')) return;
injectStylesheet();
var root = document.createElement('div');
root.id = 'crank-onboarding-root';
trigger = button(tKey('onboarding.title'), 'onboarding-trigger', function() {
if (state.dismissed) {
resume();
return;
}
setOpen(!state.open);
}, 'onboarding-trigger');
trigger.setAttribute('aria-controls', 'crank-onboarding-panel');
trigger.setAttribute('aria-expanded', 'false');
panel = document.createElement('aside');
panel.id = 'crank-onboarding-panel';
panel.className = 'onboarding-panel';
panel.dataset.testid = 'onboarding-checklist';
panel.setAttribute('aria-labelledby', 'crank-onboarding-title');
panel.hidden = true;
var head = document.createElement('div');
head.className = 'onboarding-head';
var headingWrap = document.createElement('div');
heading = document.createElement('h2');
heading.id = 'crank-onboarding-title';
heading.className = 'onboarding-title';
heading.textContent = tKey('onboarding.title');
subtitle = document.createElement('p');
subtitle.className = 'onboarding-subtitle';
subtitle.textContent = tKey('onboarding.subtitle');
headingWrap.appendChild(heading);
headingWrap.appendChild(subtitle);
var actions = document.createElement('div');
actions.className = 'onboarding-head-actions';
collapseButton = button('', 'onboarding-collapse', function() { setOpen(false); }, 'onboarding-icon-button');
collapseButton.setAttribute('aria-label', tKey('onboarding.collapse'));
actions.appendChild(collapseButton);
head.appendChild(headingWrap);
head.appendChild(actions);
var body = document.createElement('div');
body.className = 'onboarding-body';
panel.appendChild(head);
panel.appendChild(body);
root.appendChild(trigger);
root.appendChild(panel);
document.body.appendChild(root);
refresh();
}
function signalRefresh() {
if (channel) channel.postMessage({ type: 'refresh' });
try { localStorage.setItem('crank_onboarding_signal', String(Date.now())); } catch (_error) {}
}
window.CrankOnboarding = { refresh: refresh, signalRefresh: signalRefresh };
document.addEventListener('DOMContentLoaded', async function() {
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
mount();
if (typeof BroadcastChannel === 'function') {
channel = new BroadcastChannel('crank-onboarding');
channel.addEventListener('message', function(event) {
if (event && event.data && event.data.type !== 'refresh') return;
refresh();
});
}
});
window.addEventListener('crank:workspacechange', function() {
state.snapshot = null;
refresh();
});
window.addEventListener('crank:sessionchange', refresh);
window.addEventListener('crank:langchange', function() { render(); });
window.addEventListener('storage', function(event) { if (event.key === 'crank_onboarding_signal') refresh(); });
document.addEventListener('visibilitychange', function() { if (!document.hidden) refresh(); });
document.addEventListener('keydown', function(event) { if (event.key === 'Escape' && state.open) setOpen(false); });
}());
+89 -15
View File
@@ -13,6 +13,10 @@ function initSecretsPage() {
error: '',
modalMode: 'create',
modalSecretId: null,
modalSubmitting: false,
deleting: {},
generation: 0,
modalGeneration: 0,
};
var modal = document.getElementById('secret-modal');
@@ -107,6 +111,10 @@ function initSecretsPage() {
}
function openModal(mode, secret) {
if (state.modalSubmitting) {
return;
}
state.modalGeneration += 1;
state.modalMode = mode;
state.modalSecretId = secret ? secret.id : null;
resetModalFields();
@@ -143,8 +151,16 @@ function initSecretsPage() {
function closeModal() {
modal.classList.remove('open');
state.modalGeneration += 1;
state.modalSubmitting = false;
state.modalMode = 'create';
state.modalSecretId = null;
resetModalFields();
modalName.disabled = false;
modalKind.disabled = false;
modalSubmit.disabled = state.modalSubmitting;
modalSubmit.textContent = tKey('secrets.modal.create_action');
renderSecrets();
}
function updateKindFields() {
@@ -290,6 +306,7 @@ function initSecretsPage() {
rotateButton.textContent = tKey('secrets.action.rotate');
rotateButton.setAttribute('data-testid', 'secret-rotate-action');
rotateButton.setAttribute('data-secret-id', secret.id);
rotateButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
rotateButton.addEventListener('click', function () {
openModal('rotate', secret);
});
@@ -301,6 +318,7 @@ function initSecretsPage() {
deleteButton.textContent = tKey('secrets.action.delete');
deleteButton.setAttribute('data-testid', 'secret-delete-action');
deleteButton.setAttribute('data-secret-id', secret.id);
deleteButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
deleteButton.addEventListener('click', async function () {
await deleteSecret(secret);
});
@@ -378,6 +396,7 @@ function initSecretsPage() {
rotateButton.className = 'btn-secondary';
rotateButton.type = 'button';
rotateButton.textContent = tKey('secrets.action.rotate');
rotateButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
rotateButton.addEventListener('click', function() {
openModal('rotate', secret);
});
@@ -387,6 +406,7 @@ function initSecretsPage() {
deleteButton.className = 'btn-secondary';
deleteButton.type = 'button';
deleteButton.textContent = tKey('secrets.action.delete');
deleteButton.disabled = Boolean(state.deleting[secret.id]) || state.modalSubmitting;
deleteButton.addEventListener('click', async function() {
await deleteSecret(secret);
});
@@ -412,12 +432,14 @@ function initSecretsPage() {
}
async function load() {
state.workspaceId = currentWorkspaceId();
var workspaceId = currentWorkspaceId();
var generation = ++state.generation;
state.workspaceId = workspaceId;
state.loading = true;
state.error = '';
renderSecrets();
if (!state.workspaceId) {
if (!workspaceId) {
state.secrets = [];
state.profiles = [];
recomputeDerivedState();
@@ -429,49 +451,79 @@ function initSecretsPage() {
try {
var results = await Promise.all([
window.CrankApi.listSecrets(state.workspaceId),
window.CrankApi.listAuthProfiles(state.workspaceId),
window.CrankApi.listSecrets(workspaceId),
window.CrankApi.listAuthProfiles(workspaceId),
]);
if (generation !== state.generation || workspaceId !== currentWorkspaceId()) {
return;
}
state.secrets = (results[0] && results[0].items) || [];
state.profiles = (results[1] && results[1].items) || [];
recomputeDerivedState();
} catch (error) {
if (generation !== state.generation || workspaceId !== currentWorkspaceId()) {
return;
}
state.error = error.message || tKey('secrets.error.load');
state.secrets = [];
state.profiles = [];
recomputeDerivedState();
} finally {
if (generation !== state.generation || workspaceId !== currentWorkspaceId()) {
return;
}
state.loading = false;
renderSecrets();
}
}
async function deleteSecret(secret) {
if (state.deleting[secret.id]) {
return;
}
if (!confirm(tfKey('secrets.confirm.delete', { name: secret.name }))) {
return;
}
var workspaceId = state.workspaceId;
state.deleting[secret.id] = true;
renderSecrets();
try {
await window.CrankApi.deleteSecret(state.workspaceId, secret.id);
await load();
if (window.CrankUi) {
await window.CrankApi.deleteSecret(workspaceId, secret.id);
if (workspaceId === currentWorkspaceId()) {
await load();
}
if (window.CrankUi && workspaceId === currentWorkspaceId()) {
window.CrankUi.success(
tfKey('secrets.toast.delete_message', { name: secret.name }),
tKey('secrets.toast.delete_title')
);
}
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && workspaceId === currentWorkspaceId()) {
window.CrankUi.error(
error.message || tKey('secrets.toast.delete_error_message'),
tKey('secrets.toast.delete_error_title')
);
}
} finally {
delete state.deleting[secret.id];
if (workspaceId === currentWorkspaceId()) {
renderSecrets();
}
}
}
async function submitModal() {
if (state.modalSubmitting) {
return;
}
var kind = modalKind.value;
var value = buildSecretValue();
var workspaceId = state.workspaceId;
var secretId = state.modalSecretId;
var submissionGeneration = ++state.modalGeneration;
var submissionMode = state.modalMode;
state.modalSubmitting = true;
modalSubmit.disabled = true;
modalSubmit.textContent = state.modalMode === 'rotate'
? tKey('secrets.modal.rotating')
@@ -479,7 +531,16 @@ function initSecretsPage() {
try {
if (state.modalMode === 'rotate') {
await window.CrankApi.rotateSecret(state.workspaceId, state.modalSecretId, { value: value });
await window.CrankApi.rotateSecret(workspaceId, secretId, { value: value });
if (
submissionGeneration !== state.modalGeneration
|| workspaceId !== currentWorkspaceId()
|| state.modalSecretId !== secretId
|| submissionMode !== state.modalMode
|| !modal.classList.contains('open')
) {
return;
}
if (window.CrankUi) {
window.CrankUi.success(
tKey('secrets.toast.rotate_message'),
@@ -491,11 +552,19 @@ function initSecretsPage() {
if (!name) {
throw new Error(tKey('secrets.validation.name_required'));
}
await window.CrankApi.createSecret(state.workspaceId, {
await window.CrankApi.createSecret(workspaceId, {
name: name,
kind: kind,
value: value,
});
if (
submissionGeneration !== state.modalGeneration
|| workspaceId !== currentWorkspaceId()
|| submissionMode !== state.modalMode
|| !modal.classList.contains('open')
) {
return;
}
if (window.CrankUi) {
window.CrankUi.success(
tKey('secrets.toast.create_message'),
@@ -506,17 +575,20 @@ function initSecretsPage() {
closeModal();
await load();
} catch (error) {
if (window.CrankUi) {
if (window.CrankUi && submissionGeneration === state.modalGeneration) {
window.CrankUi.error(
error.message || tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_message' : 'secrets.toast.create_error_message'),
tKey(state.modalMode === 'rotate' ? 'secrets.toast.rotate_error_title' : 'secrets.toast.create_error_title')
);
}
} finally {
modalSubmit.disabled = false;
modalSubmit.textContent = state.modalMode === 'rotate'
? tKey('secrets.modal.rotate_action')
: tKey('secrets.modal.create_action');
if (submissionGeneration === state.modalGeneration) {
state.modalSubmitting = false;
modalSubmit.disabled = false;
modalSubmit.textContent = state.modalMode === 'rotate'
? tKey('secrets.modal.rotate_action')
: tKey('secrets.modal.create_action');
}
}
}
@@ -539,6 +611,8 @@ function initSecretsPage() {
renderSecrets();
});
window.addEventListener('crank:workspacechange', function () {
closeModal();
state.deleting = {};
void load();
});
+14 -2
View File
@@ -209,10 +209,22 @@ function bindPasswordSave() {
document.getElementById('security-current-password').value = '';
document.getElementById('security-new-password').value = '';
document.getElementById('security-confirm-password').value = '';
setStatus('settings-password-status', tKey('settings.security.saved'), false);
setStatus('settings-password-status', tKey('settings.security.saved_relogin'), false);
button.textContent = tKey('settings.profile.saved');
setTimeout(function() {
if (window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
}, 600);
} catch (error) {
setStatus('settings-password-status', error.message || tKey('settings.security.save_error'), true);
var diagnostics = [];
if (error && error.requestId) diagnostics.push('Request ID: ' + error.requestId);
if (error && error.traceId) diagnostics.push('Trace ID: ' + error.traceId);
setStatus(
'settings-password-status',
tKey('settings.security.save_error') + (diagnostics.length ? ('\n' + diagnostics.join('\n')) : ''),
true
);
button.textContent = original;
} finally {
setTimeout(function() {
+110 -32
View File
@@ -11,6 +11,8 @@ document.addEventListener('DOMContentLoaded', function () {
},
loading: false,
loadError: '',
usageEpoch: 0,
exporting: false,
};
var periodSelect = document.getElementById('period');
@@ -20,6 +22,7 @@ document.addEventListener('DOMContentLoaded', function () {
var cardList = document.getElementById('usage-card-list');
var chartTemplate = document.getElementById('tmpl-chart-bar');
var rowTemplate = document.getElementById('tmpl-usage-row');
var outcomeList = document.getElementById('usage-outcome-list');
var subtitle = document.querySelector('.section-card-subtitle');
var statCards = document.querySelectorAll('.stats-grid .stat-card');
@@ -73,6 +76,12 @@ document.addEventListener('DOMContentLoaded', function () {
return 'REST';
}
function outcomeLabel(outcome) {
var key = 'usage.outcome.' + outcome;
var translated = tKey(key);
return translated === key ? outcome : translated;
}
function localizedUsageOperation(operation) {
if (!window.localizeDemoOperation) {
return operation;
@@ -439,6 +448,43 @@ document.addEventListener('DOMContentLoaded', function () {
return item;
}
function renderOutcomes() {
if (!outcomeList) {
return;
}
outcomeList.innerHTML = '';
var outcomes = state.usage ? (state.usage.outcomes || []) : [];
if (!outcomes.length) {
outcomeList.appendChild(buildEmptyState(
tKey('usage.outcomes.empty.title'),
tKey('usage.outcomes.empty.sub'),
true
));
return;
}
outcomes.forEach(function (outcome) {
var card = element('div', 'resource-card');
var header = element('div', 'resource-card-header');
var headerMain = element('div');
headerMain.appendChild(element('div', 'resource-card-title', outcomeLabel(outcome.group)));
headerMain.appendChild(element(
'div',
'resource-card-subtitle',
outcome.execution_error_code || tKey('usage.outcome.no_error_code')
));
header.appendChild(headerMain);
header.appendChild(element('span', 'badge', formatCount(outcome.calls_total)));
card.appendChild(header);
var metaGrid = element('div', 'resource-meta-grid');
metaGrid.appendChild(buildUsageMetaItem('usage.table.th.calls', formatCount(outcome.calls_total)));
metaGrid.appendChild(buildUsageMetaItem('usage.outcomes.p50', formatMs(outcome.p50_ms)));
metaGrid.appendChild(buildUsageMetaItem('usage.outcomes.p95', formatMs(outcome.p95_ms)));
metaGrid.appendChild(buildUsageMetaItem('usage.outcomes.p99', formatMs(outcome.p99_ms)));
card.appendChild(metaGrid);
outcomeList.appendChild(card);
});
}
function renderUsage() {
if (state.loading && !state.usage) {
renderEmpty(tKey('usage.loading.title'), tKey('usage.loading.sub'));
@@ -452,6 +498,7 @@ document.addEventListener('DOMContentLoaded', function () {
renderStats();
renderChart();
renderOutcomes();
renderTable();
if (subtitle) {
subtitle.textContent = tfKey('usage.table.subtitle', { period: periodLabel(state.period) });
@@ -472,24 +519,46 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var workspaceId = state.workspaceId;
var epoch = ++state.usageEpoch;
state.loading = true;
state.loadError = '';
renderUsage();
try {
state.usage = await window.CrankApi.getUsageOverview(state.workspaceId, { period: state.period });
var usage = await window.CrankApi.getUsageOverview(workspaceId, { period: state.period });
if (epoch !== state.usageEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
state.usage = usage;
recomputeDerivedState();
} catch (error) {
if (epoch !== state.usageEpoch) {
return;
}
state.loadError = error.message || tKey('usage.error.load');
state.usage = null;
recomputeDerivedState();
} finally {
state.loading = false;
renderUsage();
if (epoch === state.usageEpoch) {
state.loading = false;
renderUsage();
}
}
}
function exportCsv() {
function csvCell(value) {
var text = String(value === null || value === undefined ? '' : value);
if (/^[=+\-@\t\r\n]/.test(text)) {
text = "'" + text;
}
return '"' + text.replace(/"/g, '""') + '"';
}
async function exportCsv() {
if (!window.CrankApi || state.exporting) {
return;
}
if (!state.usage) {
if (window.CrankUi) {
window.CrankUi.info(tKey('usage.export.empty.body'), tKey('usage.export.empty.title'));
@@ -497,33 +566,39 @@ document.addEventListener('DOMContentLoaded', function () {
return;
}
var rows = [tKey('usage.csv.header')];
state.usage.operations.forEach(function (operation) {
var localizedOperation = localizedUsageOperation(operation);
var errorRate = operation.calls_total === 0
? 0
: ((operation.calls_error / operation.calls_total) * 100);
rows.push([
'"' + (localizedOperation.operation_display_name || operation.operation_display_name) + '"',
'"' + protocolLabel(operation.protocol) + '"',
operation.calls_total,
operation.calls_error,
errorRate.toFixed(2),
operation.p50_ms,
operation.p95_ms,
operation.p99_ms,
].join(','));
});
var blob = new Blob([rows.join('\r\n')], { type: 'text/csv' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'crank-usage-' + state.period + '.csv';
link.click();
URL.revokeObjectURL(url);
if (window.CrankUi) {
window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title'));
var workspaceId = currentWorkspaceId();
if (!workspaceId) {
return;
}
var epoch = state.usageEpoch;
state.exporting = true;
if (exportBtn) {
exportBtn.disabled = true;
}
try {
var csv = await window.CrankApi.exportUsageCsv(workspaceId, { period: state.period });
if (epoch !== state.usageEpoch || workspaceId !== currentWorkspaceId()) {
return;
}
var blob = new Blob([csv], { type: 'text/csv;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = 'crank-usage-' + state.period + '.csv';
link.click();
URL.revokeObjectURL(url);
if (window.CrankUi) {
window.CrankUi.success(tKey('usage.export.done.body'), tKey('usage.export.done.title'));
}
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('usage.export.error.body'), tKey('usage.export.error.title'));
}
} finally {
state.exporting = false;
if (exportBtn) {
exportBtn.disabled = false;
}
}
}
@@ -539,7 +614,10 @@ document.addEventListener('DOMContentLoaded', function () {
exportBtn.addEventListener('click', exportCsv);
}
window.addEventListener('crank:workspacechange', loadUsage);
window.addEventListener('crank:workspacechange', function () {
state.usageEpoch += 1;
loadUsage();
});
if (window.whenWorkspacesReady) {
window.whenWorkspacesReady().finally(loadUsage);
+111 -10
View File
@@ -1,3 +1,25 @@
var wizardOperationLoadGeneration = 0;
var wizardLifecycleActionBusy = false;
var wizardTestConfirmationToken = null;
function operationErrorCode(error) {
return error && error.payload && error.payload.error
? error.payload.error.code
: '';
}
function showStaleOperationError(error) {
if (operationErrorCode(error) !== 'operation_stale_version'
&& operationErrorCode(error) !== 'operation_precondition_required') {
return false;
}
showWizardLiveStatus(tKey('wizard.stale.title'), tKey('wizard.stale.body'), true);
if (window.CrankUi) {
window.CrankUi.error(tKey('wizard.stale.body'), tKey('wizard.stale.title'));
}
return true;
}
function buildToolDescription() {
var snapshot = wizardCurrentVersion && wizardCurrentVersion.snapshot
? wizardCurrentVersion.snapshot
@@ -98,6 +120,7 @@ async function saveOperation(stayOnPage) {
try {
await persistCurrentDraft(stayOnPage);
} catch (error) {
if (showStaleOperationError(error)) return;
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('wizard.error.save'), tKey('wizard.error.save_title'));
}
@@ -106,14 +129,23 @@ async function saveOperation(stayOnPage) {
async function loadOperationForEdit() {
if (!wizardWorkspaceId || !wizardEditId) return;
var detail = await window.CrankApi.getOperation(wizardWorkspaceId, wizardEditId);
var generation = ++wizardOperationLoadGeneration;
var requestedWorkspaceId = wizardWorkspaceId;
var requestedOperationId = wizardEditId;
var detail = await window.CrankApi.getOperation(requestedWorkspaceId, requestedOperationId);
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
wizardProtocol = detail.protocol || 'rest';
await loadWizardPanels([3]);
var draftVersion = await window.CrankApi.getOperationVersion(
wizardWorkspaceId,
wizardEditId,
requestedWorkspaceId,
requestedOperationId,
detail.draft_version_ref.version
);
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
wizardCurrentOperation = detail;
wizardCurrentVersion = draftVersion;
prefillWizardFromEdit(detail, draftVersion);
@@ -150,6 +182,8 @@ function bindWizardLiveActions() {
bindLiveAction('wizard-run-test', tKey('wizard.busy.test'), runWizardTest);
bindLiveAction('wizard-run-quality', tKey('wizard.busy.quality'), analyzeWizardQuality);
bindClick('wizard-copy-test-response', copyTestResponseToOutputSample);
bindClick('wizard-copy-request-id', function() { copyCorrelationId('wizard-test-request-id'); });
bindClick('wizard-copy-trace-id', function() { copyCorrelationId('wizard-test-trace-id'); });
bindClick('wizard-copy-agent-preview', copyAgentFacingPreview);
bindLiveAction('wizard-export-yaml', tKey('wizard.busy.export_yaml'), exportWizardYaml);
bindLiveAction('wizard-import-yaml', tKey('wizard.busy.import_yaml'), importWizardYaml);
@@ -180,6 +214,7 @@ function bindClick(id, handler) {
function bindLiveAction(id, busyLabel, handler) {
var element = document.getElementById(id);
if (!element) return;
element.dataset.lifecycleAction = 'true';
element.addEventListener('click', function(event) {
event.preventDefault();
runWizardLiveAction(element, busyLabel, handler);
@@ -233,11 +268,17 @@ function bindApprovalPolicyControls() {
}
async function runWizardLiveAction(button, busyLabel, handler) {
if (!button || button.dataset.busy === 'true') {
if (!button || button.dataset.busy === 'true' || wizardLifecycleActionBusy) {
return;
}
var originalLabel = button.textContent;
wizardLifecycleActionBusy = true;
var lifecycleButtons = Array.from(document.querySelectorAll('[data-lifecycle-action="true"]'));
lifecycleButtons.forEach(function(element) {
element.disabled = true;
element.setAttribute('aria-busy', 'true');
});
button.dataset.busy = 'true';
button.disabled = true;
button.classList.add('is-busy');
@@ -246,6 +287,7 @@ async function runWizardLiveAction(button, busyLabel, handler) {
try {
await handler();
} catch (error) {
if (showStaleOperationError(error)) return;
showWizardLiveStatus(
tKey('wizard.live.failed_title'),
error && error.message ? error.message : tKey('wizard.live.failed_body'),
@@ -258,8 +300,12 @@ async function runWizardLiveAction(button, busyLabel, handler) {
);
}
} finally {
wizardLifecycleActionBusy = false;
lifecycleButtons.forEach(function(element) {
element.disabled = false;
element.removeAttribute('aria-busy');
});
button.dataset.busy = 'false';
button.disabled = false;
button.classList.remove('is-busy');
button.textContent = originalLabel;
}
@@ -515,6 +561,15 @@ function copyAgentFacingPreview() {
}
}
function copyCorrelationId(elementId) {
var element = document.getElementById(elementId);
var value = element ? element.textContent : '';
if (!value || !navigator.clipboard || !navigator.clipboard.writeText) return;
navigator.clipboard.writeText(value).then(function() {
showWizardLiveStatus(tKey('wizard.test.id_copied'), tKey('wizard.test.id_copied_body'));
});
}
function severityLabel(severity) {
if (severity === 'error') return tKey('wizard.quality.severity_error');
if (severity === 'warning') return tKey('wizard.quality.severity_warning');
@@ -637,7 +692,7 @@ function renderImportQualityFindings(versionDocument) {
var empty = document.getElementById('wizard-quality-empty');
if (empty) {
empty.textContent = 'Рекомендации из OpenAPI import. Запустите проверку качества, чтобы пересчитать их по текущему черновику.';
empty.textContent = tKey('wizard.quality.import_findings_hint');
empty.hidden = false;
}
}
@@ -679,7 +734,12 @@ async function importWizardYaml() {
showWizardLiveStatus(tKey('wizard.yaml.none'), tKey('wizard.yaml.none_body'), true);
return;
}
var imported = await window.CrankApi.importOperation(wizardWorkspaceId, yamlDocument, 'upsert');
var imported = await window.CrankApi.importOperation(
wizardWorkspaceId,
yamlDocument,
'upsert',
wizardEditId
);
if (Array.isArray(imported.warnings) && imported.warnings.length > 0) {
sessionStorage.setItem('crank_import_guidance', JSON.stringify(imported.warnings));
}
@@ -688,6 +748,7 @@ async function importWizardYaml() {
}
async function publishWizardOperation() {
if (!confirm(tKey('wizard.publish.confirm'))) return;
var report = await analyzeWizardQuality();
if (report.blocking) {
throw new Error(tKey('wizard.quality.blocking_error'));
@@ -703,6 +764,7 @@ async function publishWizardOperation() {
tKey('wizard.publish.done'),
tfKey('wizard.publish.done_body', { version: published.published_version })
);
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
}
function handleImportYamlFileSelection(event) {
@@ -724,6 +786,17 @@ function describeWizardTestResult(result) {
};
}
function localizeWizardTestErrors(errors) {
return (Array.isArray(errors) ? errors : []).map(function(error) {
if (!error || !error.code) return error;
var key = 'execution.error.' + error.code;
var localized = tKey(key);
return Object.assign({}, error, {
message: localized === key ? error.message : localized,
});
});
}
async function uploadInputSampleFromWizard() {
await persistCurrentDraft(true);
await window.CrankApi.uploadInputSample(
@@ -759,21 +832,49 @@ async function generateDraftFromWizard() {
}
async function runWizardTest() {
var generation = wizardOperationLoadGeneration;
var requestedWorkspaceId = wizardWorkspaceId;
var requestedOperationId = wizardEditId;
await persistCurrentDraft(true);
var result = await window.CrankApi.runOperationTest(wizardWorkspaceId, wizardEditId, {
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
var result = await window.CrankApi.runOperationTest(requestedWorkspaceId, requestedOperationId, {
version: currentDraftVersion(),
input: parseStructuredText(textValue('wizard-test-input')),
confirmation_token: wizardTestConfirmationToken,
locale: localStorage.getItem('crank_lang') || 'en',
});
if (generation !== wizardOperationLoadGeneration
|| requestedWorkspaceId !== wizardWorkspaceId
|| requestedOperationId !== wizardEditId) return;
var confirmationError = Array.isArray(result.errors)
? result.errors.find(function(error) { return error && error.code === 'confirmation_required'; })
: null;
wizardTestConfirmationToken = confirmationError && confirmationError.context
? confirmationError.context.confirmation_token || null
: null;
wizardTestResponsePreview = result.response_preview;
setTextareaValue('wizard-test-request-preview', result.request_preview);
setTextareaValue('wizard-test-response-preview', result.response_preview);
setTextareaValue('wizard-test-errors', result.errors && result.errors.length ? result.errors : []);
setTextareaValue('wizard-test-errors', localizeWizardTestErrors(result.errors));
var status = describeWizardTestResult(result);
var requestId = document.getElementById('wizard-test-request-id');
var traceId = document.getElementById('wizard-test-trace-id');
var correlationRoot = document.getElementById('wizard-test-correlation');
if (requestId) requestId.textContent = result.request_id || '';
if (traceId) traceId.textContent = result.trace_id || '';
if (correlationRoot) correlationRoot.hidden = !(result.request_id && result.trace_id);
var correlation = tfKey('wizard.test.correlation', {
requestId: result.request_id || '—',
traceId: result.trace_id || '—'
});
showWizardLiveStatus(
status.title,
status.body,
status.body + '\n' + correlation,
status.isError
);
if (window.CrankOnboarding) window.CrankOnboarding.signalRefresh();
}
function copyTestResponseToOutputSample() {
-2
View File
@@ -213,8 +213,6 @@ function parseExecutionConfig(text) {
retry_policy: retry && retry.max_attempts ? { max_attempts: Number(retry.max_attempts) } : null,
auth_profile_ref: authProfileRef,
headers: headers,
protocol_options: null,
streaming: null,
};
return config;
+13 -6
View File
@@ -35,6 +35,9 @@ var selectedUpstreamId = window.selectedUpstreamId;
var editingUpstreamId = window.editingUpstreamId;
async function initWizardPage() {
var initialParams = new URLSearchParams(window.location.search);
var returnContext = initialParams.get('return') || '';
if (!/^\/[A-Za-z0-9/_?&=.%~-]{0,512}$/.test(returnContext)) returnContext = '';
renderSidebarBrand('create');
document.querySelector('.btn-continue').addEventListener('click', function() {
currentStep = window.currentStep || 1;
@@ -57,14 +60,14 @@ async function initWizardPage() {
var backToCatalog = document.getElementById('back-to-catalog');
if (backToCatalog) {
backToCatalog.addEventListener('click', function() {
window.location.href = (window.CrankRoutes && window.CrankRoutes.home) || '/';
window.location.href = returnContext || ((window.CrankRoutes && window.CrankRoutes.home) || '/');
});
}
var closeBtn = document.querySelector('.progress-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
window.location.href = (window.CrankRoutes && window.CrankRoutes.home) || '/';
window.location.href = returnContext || ((window.CrankRoutes && window.CrankRoutes.home) || '/');
});
}
@@ -132,10 +135,11 @@ async function initWizardPage() {
});
}
var params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'edit' && params.get('operationId')) {
var params = initialParams;
var requestedOperationId = params.get('operationId') || '';
if (params.get('mode') === 'edit' && /^[A-Za-z0-9_-]{1,128}$/.test(requestedOperationId)) {
wizardMode = 'edit';
wizardEditId = params.get('operationId');
wizardEditId = requestedOperationId;
window.wizardMode = wizardMode;
window.wizardEditId = wizardEditId;
document.title = 'Crank — ' + tKey('wizard.progress.edit');
@@ -144,7 +148,10 @@ async function initWizardPage() {
}
updateWizardProtocolVisibility();
_doGoToStep(1);
var requestedStep = Number(params.get('step') || 1);
_doGoToStep(Number.isInteger(requestedStep) && requestedStep >= 1 && requestedStep <= TOTAL_STEPS ? requestedStep : 1);
window.CrankWizardReady = true;
document.dispatchEvent(new CustomEvent('crank:wizard-ready'));
}
function renderEditionCapabilityHints(capabilities) {
+1
View File
@@ -29,6 +29,7 @@ const BUNDLES = {
'js/api.js',
'js/ui-feedback.js',
'js/auth.js',
'js/onboarding.js',
],
footer: 'window.CrankAuth.guardProtectedPage();\n',
},
@@ -0,0 +1,43 @@
const http = require('http');
const PORT = Number(process.env.CRANK_E2E_STREAM_FIXTURE_PORT || 3310);
function send(response, status, body) {
const payload = JSON.stringify(body);
response.writeHead(status, {
'Content-Type': 'application/json; charset=utf-8',
'Content-Length': Buffer.byteLength(payload),
'Cache-Control': 'no-store',
});
response.end(payload);
}
const server = http.createServer((request, response) => {
const url = new URL(request.url, `http://127.0.0.1:${PORT}`);
if (request.method !== 'GET') {
send(response, 405, { error: 'method_not_allowed' });
return;
}
if (url.pathname === '/health') {
send(response, 200, { status: 'ok' });
return;
}
if (url.pathname === '/rates') {
send(response, 200, {
amount: 1,
base: url.searchParams.get('base') || 'USD',
date: '2026-08-24',
rates: { EUR: 0.91 },
});
return;
}
if (url.pathname === '/upstream-error') {
send(response, 503, { error: 'fixture_upstream_unavailable' });
return;
}
send(response, 404, { error: 'fixture_not_found' });
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`Playwright HTTP fixture listening on http://127.0.0.1:${PORT}`);
});
+45 -6
View File
@@ -59,12 +59,17 @@ cleanup() {
kill "$(cat "$TMP_DIR/ui-server.pid")" >/dev/null 2>&1 || true
rm -f "$TMP_DIR/ui-server.pid"
fi
if [[ -f "$TMP_DIR/http-fixture.pid" ]]; then
kill "$(cat "$TMP_DIR/http-fixture.pid")" >/dev/null 2>&1 || true
rm -f "$TMP_DIR/http-fixture.pid"
fi
if [[ "$USE_EXTERNAL_POSTGRES" != "1" ]]; then
docker rm -f "$POSTGRES_CONTAINER" >/dev/null 2>&1 || true
fi
kill_port_processes "$UI_PORT"
kill_port_processes "$ADMIN_PORT"
kill_port_processes "$MCP_PORT"
kill_port_processes "$STREAM_FIXTURE_PORT"
}
trap cleanup EXIT
@@ -118,27 +123,62 @@ export CRANK_STORAGE_ROOT="$TMP_DIR/storage"
export CRANK_ADMIN_BIND="0.0.0.0:$ADMIN_PORT"
export CRANK_MCP_BIND="0.0.0.0:$MCP_PORT"
export CRANK_MCP_REFRESH_MS="1000"
export CRANK_OUTBOUND_ALLOWED_HOSTS="127.0.0.1"
export CRANK_LOG_LEVEL="info"
export CRANK_SESSION_SECRET="e2e-session-secret"
export CRANK_PASSWORD_PEPPER="e2e-password-pepper"
export CRANK_MASTER_KEY="0000000000000000000000000000000000000000000000000000000000000000"
export CRANK_SESSION_TTL_HOURS="24"
export CRANK_BOOTSTRAP_ADMIN_EMAIL="$ADMIN_EMAIL"
export CRANK_BOOTSTRAP_ADMIN_PASSWORD="$ADMIN_PASSWORD"
export CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME="Crank E2E"
export CRANK_DEMO_SEED="true"
export CRANK_BASE_URL="http://127.0.0.1:$UI_PORT"
mkdir -p "$CRANK_STORAGE_ROOT"
(
cd "$ROOT_DIR/apps/ui"
exec node scripts/playwright-http-fixture.js >"$LOG_DIR/http-fixture.log" 2>&1
) &
echo $! > "$TMP_DIR/http-fixture.pid"
until curl -fsS "http://127.0.0.1:$STREAM_FIXTURE_PORT/health" >/dev/null 2>&1; do
sleep 1
done
(
cd "$ROOT_DIR"
cargo run -p admin-api --bin crank-migrate -- apply >"$LOG_DIR/migrate.log" 2>&1
)
BOOTSTRAP_JSON="$(
cd "$ROOT_DIR"
cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-create \
--email "$ADMIN_EMAIL" \
--display-name "Crank E2E" \
>"$LOG_DIR/bootstrap-create.log" 2>&1
tail -n 1 "$LOG_DIR/bootstrap-create.log"
)"
BOOTSTRAP_TOKEN="$("$PYTHON_BIN" - <<'PY' "$BOOTSTRAP_JSON"
import json, sys
print(json.loads(sys.argv[1])["bootstrap_token"])
PY
)"
printf '%s' "$BOOTSTRAP_TOKEN" >"$TMP_DIR/bootstrap-token.txt"
printf '%s' "$ADMIN_PASSWORD" >"$TMP_DIR/admin-password.txt"
printf '%s' "$CRANK_PASSWORD_PEPPER" >"$TMP_DIR/password-pepper.txt"
(
cd "$ROOT_DIR"
exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS \
cargo run -p admin-api --bin admin-api >"$LOG_DIR/admin-api.log" 2>&1
cargo run -p admin-api --bin crank-migrate -- admin-auth bootstrap-complete \
--token-file "$TMP_DIR/bootstrap-token.txt" \
--password-file "$TMP_DIR/admin-password.txt" \
--password-pepper-file "$TMP_DIR/password-pepper.txt" \
>"$LOG_DIR/bootstrap-complete.log" 2>&1
)
(
cd "$ROOT_DIR"
exec env -u CRANK_MCP_BIND -u CRANK_MCP_REFRESH_MS CRANK_DEMO_SEED=true \
cargo run -p admin-api --bin admin-api >>"$LOG_DIR/admin-api.log" 2>&1
) &
echo $! > "$TMP_DIR/admin-api.pid"
@@ -150,8 +190,7 @@ done
cd "$ROOT_DIR"
exec env -u CRANK_ADMIN_BIND -u CRANK_STORAGE_ROOT -u CRANK_SESSION_SECRET \
-u CRANK_PASSWORD_PEPPER -u CRANK_SESSION_TTL_HOURS \
-u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_PASSWORD \
-u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \
-u CRANK_BOOTSTRAP_ADMIN_EMAIL -u CRANK_BOOTSTRAP_ADMIN_DISPLAY_NAME -u CRANK_DEMO_SEED \
cargo run -p mcp-server >"$LOG_DIR/mcp-server.log" 2>&1
) &
echo $! > "$TMP_DIR/mcp-server.pid"
+39 -7
View File
@@ -7,7 +7,9 @@ const { URL } = require('url');
const ROOT_DIR = path.resolve(__dirname, '..', 'dist');
const UI_PORT = Number(process.env.CRANK_E2E_UI_PORT || 3300);
const ADMIN_PORT = Number(process.env.CRANK_E2E_ADMIN_PORT || 3301);
const MCP_PORT = Number(process.env.CRANK_E2E_MCP_PORT || 3302);
const ADMIN_BASE = new URL(`http://127.0.0.1:${ADMIN_PORT}`);
const MCP_BASE = new URL(`http://127.0.0.1:${MCP_PORT}`);
const MIME_TYPES = {
'.css': 'text/css; charset=utf-8',
@@ -91,6 +93,9 @@ function serveFile(filePath, response) {
}
fs.stat(normalized, (error, stats) => {
if (response.destroyed || response.writableEnded) {
return;
}
if (error || !stats.isFile()) {
sendNotFound(response);
return;
@@ -103,19 +108,32 @@ function serveFile(filePath, response) {
'Cache-Control': 'no-store',
});
pipeline(fs.createReadStream(normalized), response, () => {});
if (response.destroyed || response.writableEnded) {
return;
}
var fileStream = fs.createReadStream(normalized);
fileStream.on('error', function() {
if (!response.destroyed && !response.writableEnded) response.destroy();
});
response.on('close', function() {
if (!fileStream.destroyed) fileStream.destroy();
});
try {
fileStream.pipe(response);
} catch (_error) {
fileStream.destroy();
}
});
}
function proxyRequest(request, response) {
const target = new URL(request.url, ADMIN_BASE);
function proxyRequest(request, response, base, targetPath) {
const target = new URL(targetPath || request.url, base);
const proxy = http.request(
target,
{
method: request.method,
headers: {
...request.headers,
host: `127.0.0.1:${ADMIN_PORT}`,
},
},
(proxyResponse) => {
@@ -130,16 +148,25 @@ function proxyRequest(request, response) {
response.on('close', function() {
if (!proxyResponse.destroyed) proxyResponse.destroy();
});
proxyResponse.pipe(response);
try {
proxyResponse.pipe(response);
} catch (_error) {
proxyResponse.destroy();
}
},
);
proxy.on('error', () => {
if (response.destroyed || response.writableEnded) return;
response.writeHead(502, { 'Content-Type': 'text/plain; charset=utf-8' });
response.end('Bad Gateway');
});
pipeline(request, proxy, () => {});
try {
pipeline(request, proxy, () => {});
} catch (_error) {
proxy.destroy();
}
}
const server = http.createServer((request, response) => {
@@ -189,7 +216,12 @@ const server = http.createServer((request, response) => {
}
if (urlPath.startsWith('/api/auth/') || urlPath.startsWith('/api/admin/')) {
proxyRequest(request, response);
proxyRequest(request, response, ADMIN_BASE);
return;
}
if (urlPath.startsWith('/mcp/')) {
proxyRequest(request, response, MCP_BASE, request.url.slice('/mcp'.length));
return;
}
+42 -1
View File
@@ -11,6 +11,9 @@ test('agents page shows demo cards and edit drawer opens', async ({ page }) => {
await expect(page.locator('.agent-card-date').first()).toContainText(
localized('Set this endpoint', 'Данный эндпоинт')
);
await expect(page.locator('.agent-card-date').filter({
hasText: localized('Catalog rev', 'Ревизия каталога'),
}).first()).toBeVisible();
await page.getByRole('button', { name: localized('New agent', 'Новый агент') }).click();
await expect(page.locator('.drawer-title')).toHaveText(localized('New agent', 'Новый агент'));
await expect(page.locator('.drawer-subtitle')).toContainText(
@@ -40,7 +43,45 @@ test('agent drawer configures on-demand tool discovery and catalog sections', as
await expect(group.locator('input').nth(1)).toHaveValue('finance');
await expect(page.locator('.tool-search-preview')).toBeVisible();
await page.locator('.tool-group-chip').first().click();
await page.locator('.tool-search-preview input').fill('currency rate');
await page.locator('.tool-search-preview input').fill('frankfurter_latest_rate');
await page.locator('.tool-search-preview .btn-primary-sm').click();
await expect(page.locator('.tool-search-result').first()).toBeVisible();
});
test('agent lifecycle stale conflict shows localized recovery message', async ({ page }) => {
await login(page);
await page.goto('/agents');
await expect(page.locator('.agent-card').first()).toBeVisible();
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/unpublish$/, async (route) => {
await route.fulfill({
status: 409,
contentType: 'application/json',
headers: {
'x-request-id': '01a00000-0000-7000-8000-000000000001',
'x-trace-id': '01a00000000070008000000000000001',
},
body: JSON.stringify({
error: {
code: 'conflict',
message: 'agent changed',
context: {
error_code: 'agent_stale_revision',
},
},
}),
});
});
page.once('dialog', async (dialog) => {
expect(dialog.message()).toMatch(localized('Unpublish', 'Снять'));
await dialog.accept();
});
await page.getByRole('button', { name: localized('Unpublish', 'Снять с публикации') }).first().click();
await expect(page.locator('.toast-error')).toContainText(
localized(
'Agent changed in another request. Reload the drawer and retry.',
'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
),
);
});
+262 -1
View File
@@ -1,7 +1,8 @@
const { test, expect } = require('@playwright/test');
const { createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
const { browserJson, createAgent, getCurrentWorkspace, login, localized, uniqueName } = require('./helpers');
test('api keys page opens create key flow', async ({ page }) => {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write']);
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
@@ -23,10 +24,21 @@ test('api keys page opens create key flow', async ({ page }) => {
await page.locator('#btn-create-key').click();
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('.modal-title')).toHaveText(localized('Create MCP client key', 'Создать ключ MCP-клиента'));
await expect(page.locator('[data-scope="read"]')).toBeChecked();
await expect(page.locator('[data-scope="write"]')).toBeChecked();
await page.locator('#new-key-name').fill(`playwright-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-reveal-body')).toContainText(localized('Copy this key now', 'Скопируйте этот ключ сейчас'));
await expect(page.locator('#reveal-key-value')).toContainText('crk_');
const config = page.getByTestId('onboarding-connection-config');
await expect(config).toBeVisible();
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
await expect(page.locator('#reveal-key-value')).toHaveText('');
await expect(config).toBeHidden();
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
await page.locator('#modal-done-btn').click();
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
await expect(page.locator('#reveal-key-value')).toHaveText('');
await page.locator('#key-kind-approval').click();
await expect(page.locator('#key-kind-hint')).toContainText(
@@ -43,4 +55,253 @@ test('api keys page opens create key flow', async ({ page }) => {
await page.locator('#new-key-name').fill(`playwright-approval-${Date.now()}`);
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toContainText('crk_appr_');
await expect(page.locator('#onboarding-connection-config')).toBeHidden();
const approvalRawKey = await page.locator('#reveal-key-value').textContent();
await page.goto('/agents');
await page.goBack();
await expect(page.locator('#reveal-key-value')).not.toContainText(approvalRawKey || 'crk_appr_');
});
test('failed clipboard write preserves one-time key material', async ({ page }) => { // community-scope: allow=one-time-token
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_clipboard_agent'),
display_name: 'Playwright Clipboard Agent',
description: 'Agent for clipboard failure recovery.',
instructions: {},
tool_selection_policy: {},
});
await page.addInitScript(() => {
Object.defineProperty(navigator, 'clipboard', {
configurable: true,
value: { writeText: () => Promise.reject(new Error('clipboard denied')) },
});
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('clipboard_failure'));
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#reveal-key-value')).toHaveText(/^crk_/);
const rawKey = await page.locator('#reveal-key-value').textContent();
expect(rawKey).toMatch(/^crk_/);
await page.locator('#copy-key-btn').click();
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
await expect(page.locator('#modal-reveal-body')).toContainText(
localized('Copy this key now', 'Скопируйте ключ сейчас')
);
const config = page.getByTestId('onboarding-connection-config');
await expect(config).toBeVisible();
await config.getByRole('button', { name: localized('Copy configuration', 'Скопировать конфигурацию') }).first().click();
await expect(page.locator('#reveal-key-value')).toHaveText(rawKey);
await expect(config).toBeVisible();
await expect(page.locator('#onboarding-connection-clients')).not.toBeEmpty();
});
test('ambiguous create failure reconciles metadata before deliberate retry', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
await createAgent(page, workspace.id, {
slug: uniqueName('playwright_ambiguous_agent'),
display_name: 'Playwright Ambiguous Agent',
description: 'Agent for ambiguous key creation recovery.',
instructions: {},
tool_selection_policy: {},
});
let failCreate = true;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
if (route.request().method() === 'POST' && failCreate) {
failCreate = false;
await route.fulfill({
status: 503,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'key_create_outcome_unknown', message: 'unknown' } }),
});
return;
}
await route.continue();
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ambiguous_key'));
await page.locator('#modal-confirm-btn').click();
await expect(page.getByTestId('ambiguous-create-warning')).toBeVisible();
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
await page.locator('#ambiguous-create-retry-btn').click();
await expect(page.getByTestId('ambiguous-create-warning')).toBeHidden();
await expect(page.locator('#modal-confirm-btn')).toBeEnabled();
});
test('stale create response reconciles metadata without changing a newer modal generation', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('playwright_keys_stale_agent'),
display_name: 'Playwright Keys Stale Agent',
description: 'Agent for API key stale response test.',
instructions: {},
tool_selection_policy: {},
});
let releaseFirstCreate;
let releaseSecondCreate;
const firstCreateReleased = new Promise((resolve) => {
releaseFirstCreate = resolve;
});
const secondCreateReleased = new Promise((resolve) => {
releaseSecondCreate = resolve;
});
let createCount = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
const requestNumber = ++createCount;
await (requestNumber === 1 ? firstCreateReleased : secondCreateReleased);
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
secret: requestNumber === 1
? 'crk_ui_stale_canary_should_not_render'
: 'crk_ui_current_canary_may_render',
api_key: {
api_key: {
id: requestNumber === 1 ? 'pk_ui_stale' : 'pk_ui_current',
workspace_id: workspace.id,
agent_id: agent.agent_id,
key_kind: 'mcp_client',
name: requestNumber === 1 ? 'ui-stale-key' : 'ui-current-key',
prefix: requestNumber === 1 ? 'crk_ui_stale' : 'crk_ui_current',
scopes: ['read'],
status: 'active',
created_at: '2026-08-15T00:00:00Z',
last_used_at: null,
expires_at: null,
allowed_origins: [],
},
},
}),
});
});
await page.goto('/api-keys');
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ui_stale_key'));
await page.locator('#modal-confirm-btn').click();
await expect(page.locator('#modal-confirm-btn')).toBeDisabled();
await page.locator('#modal-cancel-btn').click();
await expect(page.locator('#modal-create')).not.toHaveClass(/open/);
await page.locator('#btn-create-key').click();
await page.locator('#new-key-name').fill(uniqueName('ui_current_key'));
await page.locator('#modal-confirm-btn').click();
const confirm = page.locator('#modal-confirm-btn');
await expect(confirm).toBeDisabled();
await expect(confirm).toContainText(localized('Creating', 'Создание'));
releaseFirstCreate();
await expect(page.getByTestId('onboarding-key-lost')).toBeVisible();
await expect(confirm).toBeDisabled();
await expect(confirm).toContainText(localized('Creating', 'Создание'));
releaseSecondCreate();
await expect(page.locator('#modal-reveal-body')).toBeVisible();
await expect(page.locator('#reveal-key-value')).not.toContainText('crk_ui_stale_canary_should_not_render');
await expect(page.locator('#reveal-key-value')).toContainText('crk_ui_current_canary_may_render');
});
test('duplicate key revoke and delete clicks send one request while the first request is pending', async ({ page }) => {
await page.addInitScript(() => {
window.confirm = () => true;
});
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('playwright_key_mutation_agent'),
display_name: 'Playwright Key Mutation Agent',
description: 'Agent for duplicate key mutation guards.',
instructions: {},
tool_selection_policy: {},
});
const revokeName = uniqueName('revoke_once');
const deleteName = uniqueName('delete_once');
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
{ name: revokeName, key_kind: 'mcp_client', scopes: ['read'] },
);
const revokedKey = await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys`,
{ name: deleteName, key_kind: 'mcp_client', scopes: ['read'] },
);
const revokedKeyId = revokedKey.api_key.api_key.id;
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/agents/${encodeURIComponent(agent.agent_id)}/platform-api-keys/${encodeURIComponent(revokedKeyId)}/revoke`,
{},
[204],
);
let releaseRevoke;
const revokeReleased = new Promise((resolve) => {
releaseRevoke = resolve;
});
let revokeRequests = 0;
let releaseDelete;
const deleteReleased = new Promise((resolve) => {
releaseDelete = resolve;
});
let deleteRequests = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/agents\/[^/]+\/platform-api-keys\/[^/]+(?:\/revoke)?$/, async (route) => {
const method = route.request().method();
if (method === 'POST' && /\/revoke$/.test(route.request().url())) {
revokeRequests += 1;
await revokeReleased;
await route.fulfill({ status: 204 });
return;
}
if (method === 'DELETE') {
deleteRequests += 1;
await deleteReleased;
await route.fulfill({ status: 204 });
return;
}
await route.continue();
});
await page.goto('/api-keys');
await page.locator('#agent-select').selectOption(agent.agent_id);
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
await page.evaluate((name) => {
const row = Array.from(document.querySelectorAll('#keys-tbody tr'))
.find((candidate) => candidate.textContent.includes(name));
const button = row.querySelector('[title="Revoke key"]');
button.click();
button.click();
}, revokeName);
await expect.poll(() => revokeRequests).toBe(1);
releaseRevoke();
await expect(page.locator('#keys-tbody')).toContainText(revokeName);
const deleteRow = page.locator('#keys-tbody tr').filter({ hasText: deleteName }).first();
const deleteButton = deleteRow.locator('[title="Delete"]');
await expect(deleteRow).toBeVisible();
await expect(deleteButton).toBeEnabled();
await deleteButton.evaluate((button) => {
button.click();
button.click();
});
await expect.poll(() => deleteRequests).toBe(1);
releaseDelete();
await expect(page.locator('#keys-tbody')).toContainText(deleteName);
});
+90
View File
@@ -0,0 +1,90 @@
const { test, expect } = require('@playwright/test');
const { login, localized } = require('./helpers');
test('logs approval panel renders safe pending approval metadata', async ({ page }) => {
await login(page);
let approveCalled = false;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals(?:\?.*)?$/, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
items: [
{
approval: {
id: 'approval_ui_safe_summary',
agent_id: 'agent_sales',
operation_id: 'op_charge_customer',
operation_version: 3,
status: 'pending',
risk_level: 'dangerous',
request_id: 'req_approval_ui',
trace_id: '0af7651916cd43dd8448eb211c80319c',
request_payload: {
email: 'customer@example.com',
api_key: '[REDACTED]',
},
response_payload: null,
created_at: '2026-08-22T10:00:00Z',
expires_at: '2026-08-22T10:05:00Z',
decided_at: null,
decision_note: null,
},
},
],
}),
});
});
await page.route(/\/api\/admin\/workspaces\/[^/]+\/approvals\/approval_ui_safe_summary\/approve$/, async (route) => {
approveCalled = true;
const body = JSON.parse(route.request().postData() || '{}');
expect(body).toEqual({ approve: 'yes' });
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
approval: {
id: 'approval_ui_safe_summary',
agent_id: 'agent_sales',
operation_id: 'op_charge_customer',
operation_version: 3,
status: 'approved',
risk_level: 'dangerous',
request_id: 'req_approval_ui',
trace_id: '0af7651916cd43dd8448eb211c80319c',
request_payload: { email: 'customer@example.com', api_key: '[REDACTED]' },
response_payload: { approve: 'yes' },
created_at: '2026-08-22T10:00:00Z',
expires_at: '2026-08-22T10:05:00Z',
decided_at: '2026-08-22T10:01:00Z',
decision_note: null,
},
}),
});
});
page.on('dialog', async (dialog) => {
expect(dialog.message()).toMatch(localized(
'Approve this pending confirmation request?',
'Подтвердить эту заявку?',
));
await dialog.accept();
});
await page.goto('/logs');
await expect(page.locator('.approval-panel-title')).toHaveText(
localized('Human confirmations', 'Подтверждения человеком'),
);
await expect(page.locator('#approval-list')).toContainText('approval_ui_safe_summary');
await expect(page.locator('#approval-list')).toContainText('op_charge_customer v3');
await expect(page.locator('#approval-list')).toContainText('agent_sales');
await expect(page.locator('#approval-list')).toContainText('customer@example.com');
await expect(page.locator('#approval-list')).toContainText('[REDACTED]');
await expect(page.locator('#approval-list')).toContainText('req_approval_ui');
await expect(page.locator('#approval-list')).toContainText('0af7651916cd43dd8448eb211c80319c');
await expect(page.locator('.approval-approve')).toHaveText(localized('Approve', 'Подтвердить'));
await expect(page.locator('.approval-deny')).toHaveText(localized('Deny', 'Отклонить'));
await expect(page.locator('#approval-list')).not.toContainText('SECRET_APPROVAL_CANARY');
await page.locator('.approval-approve').click();
await expect.poll(() => approveCalled).toBe(true);
});
+19 -5
View File
@@ -28,6 +28,14 @@ async function browserJson(page, method, urlPath, body, okStatuses = [200]) {
request.headers['Content-Type'] = 'application/json';
request.body = JSON.stringify(body);
}
if (!['GET', 'HEAD', 'OPTIONS'].includes(method.toUpperCase())) {
let csrfToken = window.CrankAuth && window.CrankAuth.getCsrfToken();
if (!csrfToken && window.CrankApi && window.CrankApi.refreshSessionCsrf) {
const csrf = await window.CrankApi.refreshSessionCsrf();
csrfToken = csrf && csrf.csrf_token;
}
if (csrfToken) request.headers['X-CSRF-Token'] = csrfToken;
}
const result = await fetch(urlPath, request);
const text = await result.text();
@@ -66,11 +74,17 @@ async function getCurrentWorkspace(page) {
}
async function createAgent(page, workspaceId, payload) {
return browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspaceId)}/agents`,
payload,
return page.evaluate(
async ({ workspaceId, payload }) => {
var session = await window.CrankApi.getSession();
if (!session.csrf_token) {
var csrf = await window.CrankApi.refreshSessionCsrf();
session.csrf_token = csrf.csrf_token;
}
window.CrankAuth.replaceSession(session);
return window.CrankApi.createAgent(workspaceId, payload);
},
{ workspaceId, payload },
);
}
+110
View File
@@ -1,6 +1,43 @@
const { test, expect } = require('@playwright/test');
const { ADMIN_EMAIL, ADMIN_PASSWORD, localized } = require('./helpers');
const SESSION_FIXTURE = {
user: {
id: 'user-test',
email: ADMIN_EMAIL,
display_name: 'Crank Owner',
},
memberships: [
{
role: 'owner',
workspace: {
id: 'workspace-test',
slug: 'default',
name: 'Default Workspace',
},
},
],
current_workspace_id: 'workspace-test',
csrf_token: 'csrf_test_token',
};
async function stubLoginPageSession(page, bootstrapRequired) {
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { message: 'unauthorized' } }),
});
});
await page.route('**/api/auth/bootstrap/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ bootstrap_required: bootstrapRequired }),
});
});
}
test('login page rejects invalid credentials', async ({ page }) => {
await page.goto('/login');
await page.locator('#email').fill(ADMIN_EMAIL);
@@ -10,6 +47,29 @@ test('login page rejects invalid credentials', async ({ page }) => {
});
test('login page signs in and redirects to operations', async ({ page }) => {
let loggedIn = false;
await page.route('**/api/auth/session', async (route) => {
await route.fulfill({
status: loggedIn ? 200 : 401,
contentType: 'application/json',
body: JSON.stringify(loggedIn ? SESSION_FIXTURE : { error: { message: 'unauthorized' } }),
});
});
await page.route('**/api/auth/bootstrap/status', async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ bootstrap_required: false }),
});
});
await page.route('**/api/auth/login', async (route) => {
loggedIn = true;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SESSION_FIXTURE),
});
});
await page.goto('/login');
await page.locator('#email').fill(ADMIN_EMAIL);
await page.locator('#password').fill(ADMIN_PASSWORD);
@@ -17,3 +77,53 @@ test('login page signs in and redirects to operations', async ({ page }) => {
await expect(page).toHaveURL(/\/$/);
await expect(page.locator('.page-title, .page-heading').first()).toHaveText(localized('Operations', 'Операции'));
});
test('login page switches to bootstrap mode and submits one-time token', async ({ page }) => { // community-scope: allow=one-time-token
await stubLoginPageSession(page, true);
let completePayload = null;
await page.route('**/api/auth/bootstrap/complete', async (route) => {
completePayload = route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(SESSION_FIXTURE),
});
});
await page.goto('/login');
await expect(page.locator('#login-form')).toHaveAttribute('data-bootstrap', 'true');
await expect(page.locator('#login-email-field')).toBeHidden();
await expect(page.locator('#login-bootstrap-token-field')).toBeVisible();
await page.locator('#bootstrap-token').fill('boot_test_one_time_token'); // community-scope: allow=one-time-token
await page.locator('#password').fill('new-admin-password');
await page.locator('.btn-signin').click();
await expect.poll(() => completePayload).toMatchObject({
token: 'boot_test_one_time_token', // community-scope: allow=one-time-token
password: 'new-admin-password',
});
});
test('CrankApi attaches csrf token to unsafe auth requests', async ({ page }) => {
await stubLoginPageSession(page, false);
let csrfHeader = null;
await page.route('**/api/auth/password', async (route) => {
csrfHeader = route.request().headers()['x-csrf-token'] || null;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({ ok: true }),
});
});
await page.goto('/login');
await page.evaluate((session) => {
window.CrankAuth.replaceSession(session);
}, SESSION_FIXTURE);
await page.evaluate(() => window.CrankApi.changePassword({
current_password: 'old-password',
new_password: 'new-password',
}));
await expect.poll(() => csrfHeader).toBe('csrf_test_token');
});
+6
View File
@@ -6,11 +6,17 @@ test('logs and usage pages show seeded data', async ({ page }) => {
await page.goto('/logs');
await expect(page.locator('.page-title')).toHaveText(localized('Logs', 'Логи'));
await expect(page.locator('#status-filter')).toBeVisible();
await expect(page.locator('#export-logs-btn')).toBeVisible();
await expect(page.locator('#log-list')).toBeVisible();
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
await page.locator('#status-filter').selectOption('ok');
await expect(page.locator('#log-list').locator('.empty-state, .log-entry, .log-row, .log-item').first()).toBeVisible();
await page.goto('/usage');
await expect(page.locator('.page-title')).toHaveText(localized('Usage', 'Использование'));
await expect(page.locator('#chart-bars .chart-col')).toHaveCount(7);
await expect(page.locator('#usage-outcome-list')).toBeVisible();
await expect(page.locator('#usage-outcome-list').locator('.empty-state, .resource-card').first()).toBeVisible();
await expect(page.locator('#usage-tbody tr')).toHaveCount(1);
});
+637
View File
@@ -0,0 +1,637 @@
const { test, expect } = require('@playwright/test');
const {
browserJson,
createAgent,
getCurrentWorkspace,
login,
uniqueName,
} = require('./helpers');
function onboardingSnapshot(workspaceId, overrides = {}) {
return {
schema_version: 1,
workspace_id: workspaceId,
revision: 1,
status: 'in_progress',
dismissed: false,
eligible_since: '2026-08-23T08:00:00Z',
started_at: '2026-08-23T08:01:00Z',
completed_at: null,
steps: [
{ kind: 'operation', status: 'complete', action: 'open_operation' },
{ kind: 'test', status: 'complete', action: 'open_operation' },
{ kind: 'publish', status: 'complete', action: 'open_operation' },
{ kind: 'agent', status: 'current', action: 'create_agent' },
{ kind: 'key', status: 'pending', action: 'create_key' },
{ kind: 'connection', status: 'pending', action: 'show_connection' },
{ kind: 'first_call', status: 'pending', action: 'open_logs' },
],
operation: {
id: 'op_onboarding_contract',
published_version: 1,
last_test: {
status: 'ok',
request_id: 'req_onboarding_test',
trace_id: '0123456789abcdef0123456789abcdef',
occurred_at: '2026-08-23T08:02:00Z',
},
},
agent: null,
key: null,
connection: { status: 'pending', reason_code: 'key_required' },
first_call: null,
...overrides,
};
}
async function routeOnboarding(page, responder) {
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding(?:\/events)?(?:\?.*)?$/, responder);
}
async function parseMcpResponse(response) {
const text = await response.text();
const dataLine = text.split(/\r?\n/).find((line) => line.startsWith('data:'));
return JSON.parse(dataLine ? dataLine.slice(5).trim() : text);
}
async function mcpPost(request, endpoint, key, payload, sessionId) {
const headers = {
Authorization: `Bearer ${key}`,
Accept: 'application/json, text/event-stream',
'Content-Type': 'application/json',
};
if (sessionId) {
headers['MCP-Session-Id'] = sessionId;
headers['MCP-Protocol-Version'] = '2025-06-18';
}
return request.post(endpoint, { headers, data: payload });
}
test('optional Getting Started checklist is server-derived, resumable, bilingual and accessible', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let snapshotRequests = 0;
const presentationEvents = [];
await routeOnboarding(page, async (route) => {
snapshotRequests += 1;
if (route.request().method() === 'POST') {
presentationEvents.push((await route.request().postDataJSON()).event);
}
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id)),
});
});
await page.goto('/');
const trigger = page.getByTestId('onboarding-trigger');
await expect(trigger).toContainText(/Getting Started|Начало работы/i);
const checklist = page.getByTestId('onboarding-checklist');
if (!await checklist.isVisible()) await trigger.click();
await expect(checklist).toBeVisible();
await expect.poll(() => presentationEvents).toContain('started');
await expect(checklist.locator('ol > li')).toHaveCount(7);
await expect(checklist.locator('[aria-current="step"]')).toContainText(/Agent|Агент/i);
await expect(checklist.getByRole('status')).toContainText(/3\s*\/\s*7/);
await expect(checklist.getByRole('button', { name: /Create agent|Создать агента/i })).toBeFocused();
await page.getByTestId('onboarding-collapse').click();
await expect(checklist).toBeHidden();
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toBeVisible();
expect(snapshotRequests).toBeGreaterThanOrEqual(2);
await page.getByTestId('onboarding-trigger').click();
await page.getByTestId('onboarding-dismiss').click();
await expect(checklist).toBeHidden();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
await expect.poll(() => presentationEvents).toEqual(expect.arrayContaining(['dismissed', 'abandoned']));
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Resume Getting Started|Продолжить начало работы/i);
await page.getByTestId('onboarding-trigger').click();
await expect(checklist).toBeVisible();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/Getting Started|Начало работы/i);
await page.evaluate(() => localStorage.setItem('crank_lang', 'ru'));
await page.reload();
await expect(page.getByTestId('onboarding-trigger')).toContainText('Начало работы');
await page.getByTestId('onboarding-trigger').click();
await expect(page.getByTestId('onboarding-checklist')).toContainText(/агент/i);
await page.evaluate(() => window.setLang('en'));
await expect(page.getByTestId('onboarding-checklist').locator('.onboarding-title')).toHaveText('Getting Started');
await expect(page.getByTestId('onboarding-collapse')).toHaveAttribute('aria-label', /Collapse Getting Started/i);
await page.keyboard.press('Escape');
await expect(page.getByTestId('onboarding-checklist')).toBeHidden();
await expect(page.getByTestId('onboarding-trigger')).toBeFocused();
await page.emulateMedia({ reducedMotion: 'reduce' });
await page.getByTestId('onboarding-trigger').click();
await expect(page.getByTestId('onboarding-checklist')).toHaveCSS('scroll-behavior', 'auto');
});
test('onboarding error exposes safe support correlation and retries without a client completion bypass', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let fail = false;
let calls = 0;
await routeOnboarding(page, async (route) => {
calls += 1;
if (fail) {
await route.fulfill({
status: 503,
contentType: 'application/json',
headers: {
'x-request-id': 'req_onboarding_retry',
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: 'onboarding_unavailable', message: 'unavailable' } }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 2 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
fail = true;
await page.getByTestId('onboarding-refresh').click();
const alert = page.getByRole('alert');
await expect(alert).toContainText('onboarding_unavailable');
await expect(alert).toContainText('req_onboarding_retry');
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
await expect(page.getByTestId('onboarding-retry')).toBeFocused();
fail = false;
await page.getByTestId('onboarding-retry').click();
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
expect(calls).toBeGreaterThanOrEqual(2);
});
test('validation, upstream and compatibility errors recover from the last authoritative onboarding step', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const failures = [
{ status: 422, code: 'onboarding_validation_failed' },
{ status: 502, code: 'onboarding_upstream_unavailable' },
{ status: 426, code: 'onboarding_client_incompatible' },
];
let failureIndex = -1;
await routeOnboarding(page, async (route) => {
if (failureIndex >= 0) {
const failure = failures[failureIndex];
await route.fulfill({
status: failure.status,
contentType: 'application/json',
headers: {
'x-request-id': `req_onboarding_${failure.status}`,
'x-trace-id': '0123456789abcdef0123456789abcdef',
},
body: JSON.stringify({ error: { code: failure.code, message: 'safe failure' } }),
});
failureIndex = -1;
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 3 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
for (let index = 0; index < failures.length; index += 1) {
failureIndex = index;
await page.getByTestId('onboarding-refresh').click();
const alert = page.getByRole('alert');
await expect(alert).toContainText(failures[index].code);
await expect(alert).toContainText(`req_onboarding_${failures[index].status}`);
await expect(alert).toContainText('0123456789abcdef0123456789abcdef');
await page.getByTestId('onboarding-retry').click();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
await expect(page.getByTestId('onboarding-checklist').locator('ol > li')).toHaveCount(7);
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
}
});
test('authentication recovery returns to an incomplete authoritative onboarding projection', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let rejectOnce = false;
await routeOnboarding(page, async (route) => {
if (rejectOnce) {
rejectOnce = false;
await route.fulfill({
status: 401,
contentType: 'application/json',
body: JSON.stringify({ error: { code: 'onboarding_auth_required', message: 'sign in again' } }),
});
return;
}
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 4 })) });
});
await page.goto('/');
await page.getByTestId('onboarding-trigger').click();
rejectOnce = true;
await page.getByTestId('onboarding-refresh').click();
await expect(page).toHaveURL(/\/login/);
await page.context().clearCookies();
await login(page);
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
await expect(page.getByTestId('onboarding-completion')).toHaveCount(0);
});
test('a BroadcastChannel refresh updates a second open tab from server progress', async ({ page, context }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
var revision = 1;
const respond = async (route) => route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision,
steps: revision === 1 ? onboardingSnapshot(workspace.id).steps : onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
})),
});
await routeOnboarding(page, respond);
await page.goto('/');
await expect(page.getByTestId('onboarding-trigger')).toContainText(/3\s*\/\s*7/);
const other = await context.newPage();
await routeOnboarding(other, respond);
await other.goto('/');
await expect(other.locator('.page-title, .page-heading').first()).toBeVisible();
revision = 2;
await other.evaluate(() => window.CrankOnboarding.signalRefresh());
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await other.close();
});
test('bursty browser refresh signals coalesce into one request plus one trailing refresh', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let requestNumber = 0;
let releaseRefresh;
const refreshReleased = new Promise((resolve) => { releaseRefresh = resolve; });
await routeOnboarding(page, async (route) => {
if (route.request().method() !== 'GET') {
await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })) });
return;
}
requestNumber += 1;
if (requestNumber === 1) {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 1 })),
});
return;
}
if (requestNumber === 2) await refreshReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision: 2,
steps: onboardingSnapshot(workspace.id).steps.map((step) => ({ ...step, status: 'complete' })),
status: 'complete',
completed_at: '2026-08-23T08:10:00Z',
first_call: {
log_id: 'log_newest',
tool_name: 'frankfurter_latest_rate',
request_id: 'req_newest',
trace_id: 'abcdefabcdefabcdefabcdefabcdefab',
occurred_at: '2026-08-23T08:10:00Z',
},
})),
});
});
await page.goto('/');
if (!await page.getByTestId('onboarding-checklist').isVisible()) {
await page.getByTestId('onboarding-trigger').click();
}
await page.evaluate(() => {
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
window.CrankOnboarding.refresh();
});
await expect.poll(() => requestNumber).toBe(2);
releaseRefresh();
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await expect(page.getByTestId('onboarding-completion')).toContainText('req_newest');
await expect(page.getByTestId('onboarding-completion')).toContainText('frankfurter_latest_rate');
await expect(page.getByTestId('onboarding-completion')).toContainText('2026-08-23T08:10:00Z');
await expect.poll(() => requestNumber).toBe(3);
});
test('stale onboarding deep link requires server reset before explicit reselection', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
let resetPayload = null;
await routeOnboarding(page, async (route) => {
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, {
revision: 17,
operation_id: 'op_current',
operation_version: 4,
agent_id: 'agent_current',
catalog_revision: 9,
})),
});
});
await page.route(/\/api\/admin\/workspaces\/[^/]+\/onboarding\/reset-selection$/, async (route) => {
resetPayload = await route.request().postDataJSON();
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify(onboardingSnapshot(workspace.id, { revision: 18 })),
});
});
await page.goto('/agents?onboarding=1&action=create&operationId=op_stale&operationVersion=3');
const recovery = page.getByTestId('onboarding-deep-link-stale');
await expect(recovery).toBeVisible();
await expect(recovery).toContainText(/changed Operation|изменённой операции/i);
await page.getByTestId('onboarding-reselect').click();
await expect.poll(() => resetPayload).toEqual({ expected_revision: 17 });
await expect(page).toHaveURL('/agents');
});
test('one-time key connection config is never recoverable after the reveal is lost', async ({ page }) => { // community-scope: allow=one-time-token
await login(page);
const workspace = await getCurrentWorkspace(page);
const agent = await createAgent(page, workspace.id, {
slug: uniqueName('onboarding_lost_key'),
display_name: 'Onboarding lost key Agent',
description: 'Proves one-time onboarding key handling.', // community-scope: allow=one-time-token
instructions: {},
tool_selection_policy: { mode: 'direct', groups: [], search: { max_results: 8 } },
});
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(agent.agent_id)}`);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
await expect(page.locator('#agent-select')).toHaveValue(agent.agent_id);
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await page.locator('#new-key-name').fill(uniqueName('onboarding_client'));
await page.locator('#modal-confirm-btn').click();
const revealKey = page.locator('#reveal-key-value');
await expect(revealKey).toHaveText(/^crk_/);
const rawKey = await revealKey.textContent();
await expect(page.getByTestId('onboarding-connection-config')).toContainText('/mcp/v1/');
await expect(page.getByTestId('onboarding-clipboard-warning')).toBeVisible();
const leakedBeforeNavigation = await page.evaluate((secret) => ({
local: Object.values(localStorage).some((value) => String(value).includes(secret)),
session: Object.values(sessionStorage).some((value) => String(value).includes(secret)),
url: location.href.includes(secret),
}), rawKey);
expect(leakedBeforeNavigation).toEqual({ local: false, session: false, url: false });
await page.goto('/agents');
await page.goBack();
await expect(page.locator('body')).not.toContainText(rawKey);
await expect(page.getByTestId('onboarding-key-lost')).toContainText(/create|rotate|созда|ротац/i);
await expect(page.getByTestId('onboarding-connection-config')).toBeHidden();
await expect(page.locator('#onboarding-connection-clients')).toBeEmpty();
});
test('first value is proven only by a real tools/call through the public MCP endpoint', async ({ page, request }) => {
test.setTimeout(90_000);
await login(page);
const workspace = await getCurrentWorkspace(page);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
const suffix = uniqueName('mcp').replace(/_/g, '-');
const operationName = uniqueName('onboarding_real_rest');
const agentSlug = `onboarding-real-${suffix}`.toLowerCase().replace(/[^a-z0-9-]/g, '-');
// Exercise the actual wizard controls and lifecycle buttons. This is not an
// API provisioning shortcut: every mutation below is initiated by visible UI.
await page.goto('/wizard/?onboarding=1');
await page.waitForFunction(() => window.CrankWizardReady === true && window.currentStep === 1);
await page.locator('[data-testid="wizard-protocol-rest"]').click();
await page.locator('#btn-continue').click();
await expect(page.locator('#upstream-new-trigger')).toBeVisible();
await page.locator('#upstream-new-trigger').click();
await page.locator('#new-upstream-name').fill(`onboarding-${suffix}`);
await page.locator('#new-upstream-url').fill('http://127.0.0.1:3310');
await page.locator('#new-upstream-static-headers').fill('{"Accept":"application/json"}');
await page.locator('[data-wizard-action="save-upstream"]').click();
await expect(page.locator('#upstream-preview-url')).toHaveText('http://127.0.0.1:3310');
await page.locator('#endpoint-path').fill('/rates');
await page.locator('#btn-continue').click();
await page.locator('.method-card[data-method="GET"]').click();
await page.locator('#btn-continue').click();
await page.locator('#tool-name').fill(operationName);
await page.locator('#tool-display-name').fill('Deterministic onboarding REST Operation');
await page.locator('#tool-title').fill('Deterministic onboarding REST Operation');
await page.locator('#tool-description').fill(
'A local deterministic REST target exercised through the complete onboarding UI flow.',
);
await page.locator('#tool-input-schema').fill(JSON.stringify({
type: 'object',
properties: {
base: { type: 'string', description: 'Base currency.' },
quote: { type: 'string', description: 'Quote currency.' },
},
required: ['base', 'quote'],
}));
await page.locator('#tool-output-schema').fill(JSON.stringify({
type: 'object',
properties: { base: { type: 'string', description: 'Returned base currency.' } },
required: ['base'],
}));
await page.locator('#btn-continue').click();
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-input-mapping') }).locator('summary').click();
await page.locator('#tool-input-mapping').fill(JSON.stringify({
'query.base': '$.input.base',
'query.quote': '$.input.quote',
}));
await page.locator('details.advanced-mapping-details').filter({ has: page.locator('#tool-output-mapping') }).locator('summary').click();
await page.locator('#tool-output-mapping').fill(JSON.stringify({
base: '$.response.body.base',
}));
await page.evaluate(() => window.CrankWizardMapping.renderFromEditors());
await page.locator('#tool-exec-config').fill('{"timeout_ms":2000,"headers":{}}');
await page.locator('#wizard-test-input').fill('{"base":"USD","quote":"EUR"}');
const createOperationResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/api\/admin\/workspaces\/[^/]+\/operations$/.test(response.url())
));
await page.locator('.btn-save-draft').click();
const createdOperation = await (await createOperationResponse).json();
const operationId = createdOperation.operation_id;
await expect(page).toHaveURL(new RegExp(`operationId=${operationId}`));
const testRunResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& response.url().includes(`/operations/${operationId}/test-runs`)
));
await page.locator('#wizard-run-test').click();
const testRun = await (await testRunResponse).json();
expect(testRun.ok).toBe(true);
await expect(page.locator('#wizard-test-request-id')).not.toHaveText('');
page.once('dialog', (dialog) => dialog.accept());
const publishOperationResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& response.url().includes(`/operations/${operationId}/publish`)
));
await page.locator('#wizard-publish-operation').click();
const published = await (await publishOperationResponse).json();
expect(published.published_version).toBe(1);
// Continue through the actual onboarding Agent drawer. The deep link selects
// the exact published Operation/version; the visible Create action publishes it.
await page.goto(`/agents?onboarding=1&action=create&operationId=${encodeURIComponent(operationId)}&operationVersion=1`);
await expect(page.locator('.drawer')).toHaveClass(/open/);
const identityInputs = page.locator('.drawer-section').first().locator('input.form-input');
await identityInputs.nth(0).fill('Onboarding real public MCP Agent');
await identityInputs.nth(1).fill(agentSlug);
await page.locator('.drawer-section').first().locator('textarea').fill(
'Real public MCP onboarding acceptance flow.',
);
await expect(page.locator('.ops-picker-item.selected')).toContainText(operationName);
const createAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/api\/admin\/workspaces\/[^/]+\/agents$/.test(response.url())
));
const bindAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST' && /\/agents\/[^/]+\/bindings$/.test(response.url())
));
const publishAgentResponse = page.waitForResponse((response) => (
response.request().method() === 'POST' && /\/agents\/[^/]+\/publish$/.test(response.url())
));
await page.locator('.drawer-footer .btn-primary-sm').click();
const createdResponse = await createAgentResponse;
const bindingResponse = await bindAgentResponse;
expect(bindingResponse.ok(), await bindingResponse.text()).toBe(true);
const agentPublishResponse = await publishAgentResponse;
expect(agentPublishResponse.ok(), await agentPublishResponse.text()).toBe(true);
const createdAgent = await createdResponse.json();
const provisioned = {
agentId: createdAgent.agent_id,
operationId,
operationVersion: 1,
toolName: operationName,
};
await expect(page.locator('.drawer')).not.toHaveClass(/open/);
// The key is likewise created through the visible onboarding key flow.
await page.goto(`/api-keys?onboarding=1&action=create&agentId=${encodeURIComponent(provisioned.agentId)}`);
await expect(page.locator('#agent-select')).toHaveValue(provisioned.agentId);
await expect(page.locator('#modal-create')).toHaveClass(/open/);
await expect(page.locator('[data-scope="read"]')).toBeChecked();
await expect(page.locator('[data-scope="write"]')).toBeChecked();
await page.locator('#new-key-name').fill(uniqueName('onboarding_real_key'));
const keyResponsePromise = page.waitForResponse((response) => (
response.request().method() === 'POST'
&& /\/platform-api-keys$/.test(response.url())
));
await page.locator('#modal-confirm-btn').click();
const keyResponse = await keyResponsePromise;
expect(keyResponse.ok()).toBe(true);
const keyBody = await keyResponse.json();
provisioned.key = await page.locator('#reveal-key-value').textContent();
provisioned.keyId = keyBody.api_key.id || keyBody.api_key.api_key.id;
provisioned.endpoint = await page.locator('#onboarding-connection-clients > code').textContent();
expect(provisioned.key).toMatch(/^crk_/);
await expect(page.getByTestId('onboarding-connection-config')).toContainText(provisioned.endpoint);
expect(provisioned.endpoint).toContain('/mcp/v1/');
const initialize = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 1, method: 'initialize', params: {
protocolVersion: '2025-06-18',
capabilities: {},
clientInfo: { name: 'crank-onboarding-playwright', version: '1.0.0' },
},
});
const initializeBody = await parseMcpResponse(initialize);
expect(initialize.ok(), JSON.stringify(initializeBody)).toBe(true);
expect(initializeBody.result.protocolVersion).toBe('2025-06-18');
const sessionId = initialize.headers()['mcp-session-id'];
expect(sessionId).toBeTruthy();
const initialized = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', method: 'notifications/initialized', params: {},
}, sessionId);
expect([200, 202]).toContain(initialized.status());
const listed = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 2, method: 'tools/list', params: {},
}, sessionId);
expect(listed.ok()).toBe(true);
const listedBody = await parseMcpResponse(listed);
expect(listedBody.result.tools.map((tool) => tool.name)).toContain(provisioned.toolName);
const called = await mcpPost(request, provisioned.endpoint, provisioned.key, {
jsonrpc: '2.0', id: 3, method: 'tools/call', params: {
name: provisioned.toolName,
arguments: { base: 'USD', quote: 'EUR' },
},
}, sessionId);
expect(called.ok()).toBe(true);
const calledBody = await parseMcpResponse(called);
expect(calledBody.error).toBeUndefined();
expect(calledBody.result.isError).not.toBe(true);
// This is deliberately the real authoritative endpoint. The test never posts a
// browser-only "complete" flag and never calls a test-only onboarding bypass.
const progress = await browserJson(
page,
'GET',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/onboarding`,
);
expect(progress.status).toBe('complete');
expect(progress.agent_id).toBe(provisioned.agentId);
expect(progress.platform_api_key_id).toBe(provisioned.keyId);
expect(progress.operation_id).toBe(provisioned.operationId);
expect(progress.operation_version).toBe(provisioned.operationVersion);
expect(progress.first_call.agent_id).toBe(provisioned.agentId);
expect(progress.first_call.key_id).toBe(provisioned.keyId);
expect(progress.first_call.operation_id).toBe(provisioned.operationId);
expect(progress.first_call.operation_version).toBe(provisioned.operationVersion);
expect(progress.first_call.tool_name).toBe(provisioned.toolName);
expect(progress.first_call.request_id).toBeTruthy();
expect(progress.first_call.trace_id).toMatch(/^[0-9a-f]{32}$/);
await page.goto('/');
await expect(page.getByTestId('onboarding-trigger')).toContainText(/7\s*\/\s*7/);
await page.getByTestId('onboarding-trigger').click();
const completion = page.getByTestId('onboarding-completion');
await expect(completion).toContainText(provisioned.toolName);
await expect(completion).toContainText(progress.first_call.occurred_at);
await expect(completion).toContainText(progress.first_call.request_id);
await expect(completion).toContainText(progress.first_call.trace_id);
await page.goto(`/logs?log_id=${encodeURIComponent(progress.first_call.log_id)}`);
await expect(page.locator(`[data-id="${progress.first_call.log_id}"]`)).toBeVisible();
await expect(page.locator(`[data-exp="${progress.first_call.log_id}"]`)).toContainText(progress.first_call.trace_id);
await page.waitForFunction(() => Boolean(window.CrankAuth && window.CrankAuth.getCsrfToken()));
const revokeEvidence = await page.evaluate(async ({ workspaceId, agentId, keyId }) => {
const before = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
await window.CrankApi.revokeAgentPlatformApiKey(workspaceId, agentId, keyId);
const after = await window.CrankApi.listAgentPlatformApiKeys(workspaceId, agentId);
const onboarding = await window.CrankApi.getOnboarding(workspaceId);
return {
beforeCount: before.items.length,
afterCount: after.items.length,
revoked: after.items.find((item) => (item.api_key || item).id === keyId),
onboarding,
};
}, { workspaceId: workspace.id, agentId: provisioned.agentId, keyId: provisioned.keyId });
expect(revokeEvidence.afterCount).toBe(revokeEvidence.beforeCount);
expect((revokeEvidence.revoked.api_key || revokeEvidence.revoked).status).toBe('revoked');
expect(revokeEvidence.onboarding.status).not.toBe('complete');
const keyStep = revokeEvidence.onboarding.steps.find((step) => step.id === 'key');
expect(keyStep.completed).toBe(false);
expect(keyStep.status).toBe('regressed');
});
@@ -0,0 +1,190 @@
const { test, expect } = require('@playwright/test');
const { getCurrentWorkspace, login, uniqueName } = require('./helpers');
test('Operation lifecycle keeps published versions immutable and exposes safe correlation', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const name = uniqueName('immutable_lifecycle');
const evidence = await page.evaluate(async ({ workspaceId, operationName }) => {
const schema = (field, description) => ({
type: 'object',
description,
required: true,
nullable: false,
fields: {
[field]: {
type: 'string',
description: `${field} value used by the lifecycle acceptance test`,
required: true,
nullable: false,
},
},
});
const payload = {
name: operationName,
display_name: 'Immutable lifecycle acceptance Operation',
category: 'quality',
protocol: 'rest',
security_level: 'standard',
target: {
kind: 'rest',
base_url: 'https://httpbin.org',
method: 'GET',
path_template: '/anything',
static_headers: {},
},
input_schema: schema('input', 'Lifecycle input contract'),
output_schema: schema('output', 'Lifecycle output contract'),
input_mapping: {
rules: [{
source: '$.mcp.input',
target: '$.request.query.input',
required: true,
}],
},
output_mapping: {
rules: [{
source: '$.response.body.output',
target: '$.output.output',
required: true,
}],
},
execution_config: { timeout_ms: 1000, headers: {} },
tool_description: {
title: 'Immutable lifecycle acceptance Operation',
description: 'Exercises immutable Draft, Published Version, YAML, archive and correlation behavior.',
tags: ['quality', 'lifecycle'],
examples: [],
},
wizard_state: null,
};
const created = await window.CrankApi.createOperation(workspaceId, payload);
const operationId = created.operation_id;
await window.CrankApi.getOperation(workspaceId, operationId);
let testResult;
try {
testResult = await window.CrankApi.runOperationTest(workspaceId, operationId, {
version: 1,
input: { input: 'acceptance' },
});
} catch (error) {
testResult = {
tested_version: 1,
request_id: error.requestId,
trace_id: error.traceId,
rejected_safely: true,
};
}
const published = await window.CrankApi.publishOperation(workspaceId, operationId, 1);
const publishedV1 = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
await window.CrankApi.getOperation(workspaceId, operationId);
const changedPayload = JSON.parse(JSON.stringify(payload));
changedPayload.display_name = 'Changed Draft after publication';
changedPayload.tool_description.title = 'Changed Draft after publication';
const changed = await window.CrankApi.updateOperation(workspaceId, operationId, changedPayload);
const publishedV1AfterEdit = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
const yamlV1 = await window.CrankApi.exportOperation(workspaceId, operationId, { version: 1 });
await window.CrankApi.getOperation(workspaceId, operationId);
const imported = await window.CrankApi.importOperation(
workspaceId,
yamlV1,
'upsert',
operationId,
);
await window.CrankApi.getOperation(workspaceId, operationId);
const archived = await window.CrankApi.archiveOperation(workspaceId, operationId);
const publishedV1AfterArchive = await window.CrankApi.getOperationVersion(workspaceId, operationId, 1);
return {
operationId,
testResult,
published,
changed,
imported,
archived,
originalSnapshot: publishedV1.snapshot,
afterEditSnapshot: publishedV1AfterEdit.snapshot,
afterArchiveSnapshot: publishedV1AfterArchive.snapshot,
yamlV1,
};
}, { workspaceId: workspace.id, operationName: name });
expect(evidence.testResult.tested_version).toBe(1);
expect(evidence.testResult.request_id).toMatch(/^[!-~]{1,128}$/);
expect(evidence.testResult.trace_id).toMatch(/^[0-9a-f]{32}$/);
expect(evidence.published.published_version).toBe(1);
expect(evidence.changed.version).toBe(2);
expect(evidence.imported.version).toBe(3);
expect(evidence.archived.status).toBe('archived');
expect(evidence.afterEditSnapshot).toEqual(evidence.originalSnapshot);
expect(evidence.afterArchiveSnapshot).toEqual(evidence.originalSnapshot);
expect(evidence.yamlV1).toContain("format_version: '2'");
for (const forbidden of ['wizard_state:', 'created_at:', 'published_at:', 'operation_id:']) {
expect(evidence.yamlV1).not.toContain(forbidden);
}
});
for (const locale of ['ru', 'en']) {
test(`catalog lifecycle actions are state-aware in ${locale}`, async ({ page }) => {
await login(page);
await page.evaluate((language) => localStorage.setItem('crank_lang', language), locale);
const workspace = await getCurrentWorkspace(page);
const name = uniqueName(`catalog_lifecycle_${locale}`);
const operation = await page.evaluate(async ({ workspaceId, operationName, language }) => {
const schema = {
type: 'object',
description: 'Catalog lifecycle UI contract',
required: true,
nullable: false,
fields: {},
};
const created = await window.CrankApi.createOperation(workspaceId, {
name: operationName,
display_name: `Catalog lifecycle ${language}`,
category: 'quality',
protocol: 'rest',
security_level: 'standard',
target: { kind: 'rest', base_url: 'https://example.test', method: 'GET', path_template: '/', static_headers: {} },
input_schema: schema,
output_schema: schema,
input_mapping: { rules: [] },
output_mapping: { rules: [] },
execution_config: { timeout_ms: 1000, headers: {} },
tool_description: {
title: `Catalog lifecycle ${language}`,
description: 'A sufficiently detailed description for lifecycle UI acceptance.',
tags: ['quality'],
examples: [],
},
wizard_state: null,
});
await window.CrankApi.getOperation(workspaceId, created.operation_id);
await window.CrankApi.publishOperation(workspaceId, created.operation_id, 1);
return created;
}, { workspaceId: workspace.id, operationName: name, language: locale });
await page.goto('/');
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
const row = page.locator('tbody tr').filter({ hasText: name });
await expect(row).toHaveCount(1);
await expect(row.locator('.row-btn-delete')).toBeHidden();
await expect(row.locator('.row-btn-edit')).toBeVisible();
await row.locator('.row-btn-edit').click();
await expect(page).toHaveURL(new RegExp(`/wizard/\\?mode=edit&operationId=${operation.operation_id}`));
await expect(page.locator('.wizard-shell')).toBeVisible();
await expect(page.locator('#back-to-catalog')).toBeVisible();
await page.locator('#back-to-catalog').click();
await expect(page).toHaveURL(/\/$/);
await page.getByPlaceholder(locale === 'ru' ? 'Поиск операций' : 'Search operations').fill(name);
const refreshedRow = page.locator('tbody tr').filter({ hasText: name });
page.once('dialog', (dialog) => dialog.accept());
await refreshedRow.locator('[data-testid="operation-archive"]').click();
await expect(refreshedRow).toContainText(locale === 'ru' ? 'Неактивные' : 'Inactive');
await expect(refreshedRow.locator('.row-btn-delete')).toBeHidden();
});
}
+2 -1
View File
@@ -6,7 +6,8 @@ test('operations page shows demo catalog and filter works', async ({ page }) =>
await expect(page.locator('.page-heading')).toHaveText(localized('Operations', 'Операции'));
await expect(page.locator('.ws-switcher-trigger')).toBeVisible();
await expect(page.locator('#ws-dropdown')).toHaveCount(0);
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.getByText(localized('Loading operations', 'Загрузка операций'))).toBeHidden();
expect(await page.locator('tbody tr').count()).toBeGreaterThanOrEqual(1);
await page.getByPlaceholder(localized('Search operations', 'Поиск операций')).fill('frankfurter');
await expect(page.locator('tbody tr')).toHaveCount(1);
await expect(page.locator('tbody tr').first()).toContainText(/frankfurter_latest_rate/i);
+131
View File
@@ -84,3 +84,134 @@ test('generic json secret modal stays inside compact viewport', async ({ page })
await expect(page.locator('[data-testid="secret-name-input"]')).toBeVisible();
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeVisible();
});
test('secret modal clears sensitive values and locks duplicate submit while saving', async ({ page }) => {
await login(page);
await page.goto('/secrets');
await page.locator('[data-testid="secret-create-button"]').click();
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-close');
await page.locator('#secret-modal-cancel-btn').click();
await page.locator('[data-testid="secret-create-button"]').click();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue('');
await expect(page.locator('[data-testid="secret-value-input"]')).toHaveValue('');
let releaseCreate;
const createReleased = new Promise((resolve) => {
releaseCreate = resolve;
});
let createPosts = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets$/, async (route) => {
if (route.request().method() !== 'POST') {
await route.continue();
return;
}
createPosts += 1;
await createReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: 'secret_ui_guard',
workspace_id: 'ws_test',
name: 'ui_secret_guard',
kind: 'token',
status: 'active',
current_version: 1,
created_at: '2026-08-15T00:00:00Z',
updated_at: '2026-08-15T00:00:00Z',
last_used_at: null,
}),
});
});
await page.locator('[data-testid="secret-name-input"]').fill(uniqueName('ui_secret_guard'));
await page.locator('[data-testid="secret-value-input"]').fill('ui-secret-canary-submit');
const submit = page.locator('[data-testid="secret-submit-button"]');
await submit.click();
await expect(submit).toBeDisabled();
await submit.click({ force: true });
expect(createPosts).toBe(1);
releaseCreate();
await expect(page.locator('[data-testid="secret-create-modal"]')).toBeHidden();
expect(createPosts).toBe(1);
});
test('stale secret rotation cannot close or report success for a newer modal context', async ({ page }) => {
await login(page);
const workspace = await getCurrentWorkspace(page);
const firstName = uniqueName('rotate_stale_first');
const secondName = uniqueName('rotate_stale_second');
const firstSecret = await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
{ name: firstName, kind: 'token', value: 'rotate-stale-first-value' },
);
await browserJson(
page,
'POST',
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/secrets`,
{ name: secondName, kind: 'token', value: 'rotate-stale-second-value' },
);
let releaseRotate;
const rotateReleased = new Promise((resolve) => {
releaseRotate = resolve;
});
let secretListRequests = 0;
await page.route(/\/api\/admin\/workspaces\/[^/]+\/secrets(?:\/[^/]+\/rotate)?$/, async (route) => {
const request = route.request();
if (request.method() === 'GET') {
secretListRequests += 1;
await route.continue();
return;
}
if (request.method() === 'POST' && /\/rotate$/.test(request.url())) {
await rotateReleased;
await route.fulfill({
status: 200,
contentType: 'application/json',
body: JSON.stringify({
id: firstSecret.id,
workspace_id: workspace.id,
name: firstName,
kind: 'token',
status: 'active',
current_version: 2,
created_at: '2026-08-15T00:00:00Z',
updated_at: '2026-08-15T00:00:00Z',
last_used_at: null,
}),
});
return;
}
await route.continue();
});
await page.goto('/secrets');
const firstRow = page.locator('#secrets-tbody tr').filter({ hasText: firstName }).first();
const secondRow = page.locator('#secrets-tbody tr').filter({ hasText: secondName }).first();
await expect(firstRow).toBeVisible();
await expect(secondRow).toBeVisible();
const listRequestsBeforeRelease = secretListRequests;
await firstRow.getByTestId('secret-rotate-action').click();
await page.locator('[data-testid="secret-value-input"]').fill('rotate-stale-first-new-value');
await page.locator('[data-testid="secret-submit-button"]').click();
await expect(page.locator('[data-testid="secret-submit-button"]')).toBeDisabled();
await page.locator('#secret-modal-cancel-btn').click();
await expect(page.getByTestId('secret-rotate-modal')).toBeHidden();
await secondRow.getByTestId('secret-rotate-action').click();
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
releaseRotate();
await page.waitForTimeout(150);
await expect(page.getByTestId('secret-rotate-modal')).toBeVisible();
await expect(page.locator('[data-testid="secret-name-input"]')).toHaveValue(secondName);
await expect(page.locator('.toast-success')).toHaveCount(0);
expect(secretListRequests).toBe(listRequestsBeforeRelease);
});
+2 -9
View File
@@ -164,7 +164,6 @@ test('wizard builds visual request mappings from JSON sample and path params', a
await page.evaluate(() => window.CrankWizardShell.goToStep(2));
await expect(page.locator('#step-panel-2')).toBeVisible();
await page.locator('#endpoint-path').fill('/rates/{date}');
await page.evaluate(() => window.CrankWizardShell.goToStep(3));
await expect(page.locator('#step-panel-3-rest')).toBeVisible();
await page.locator('.method-card[data-method="GET"]').click();
@@ -356,8 +355,6 @@ test('wizard edit mode hydrates fields from operation version snapshot', async (
retry_policy: { max_attempts: 2 },
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
},
tool_description: {
title: 'Получить историю курсов за месяц',
@@ -571,8 +568,6 @@ test('wizard shows agent-facing MCP preview from current draft fields', async ({
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
@@ -655,6 +650,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
`/api/admin/workspaces/${encodeURIComponent(workspace.id)}/operations/${operationId}`,
async (route) => {
if (route.request().method() === 'PATCH') {
expect(route.request().headers()['if-match']).toBe('"operation-etag-v3"');
updatePayload = route.request().postDataJSON();
await route.fulfill({
contentType: 'application/json',
@@ -671,6 +667,7 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
await route.fulfill({
contentType: 'application/json',
headers: { ETag: '"operation-etag-v3"' },
body: JSON.stringify({
id: operationId,
workspace_id: workspace.id,
@@ -793,8 +790,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
risk_level: 'financial',
@@ -877,8 +872,6 @@ test('wizard edit mode preserves explicit request mapping targets on save', asyn
retry_policy: null,
auth_profile_ref: null,
headers: {},
protocol_options: null,
streaming: null,
approval_policy: {
required: true,
mode: 'custom',