feat: complete Epic 1 production foundation
This commit is contained in:
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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));
|
||||
},
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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() {
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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}`);
|
||||
});
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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.',
|
||||
'Агент изменился в другом запросе. Перезагрузите форму и повторите действие.',
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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',
|
||||
|
||||
Reference in New Issue
Block a user