feat: connect wizard live flow to admin api

This commit is contained in:
a.tolmachev
2026-03-31 02:11:55 +03:00
parent 04bfc84c19
commit dcc4401043
11 changed files with 736 additions and 118 deletions
+1 -2
View File
@@ -4,7 +4,7 @@
### `feat/wizard-live-flow`
Status: pending
Status: completed
DoD:
- `apps/ui/html/wizard/index.html` и `apps/ui/js/wizard.js` работают без локальных draft override-костылей
@@ -24,4 +24,3 @@ DoD:
- `feat/agent-lifecycle-polish`
- `feat/platform-key-usage`
- `feat/demo-assets`
- `feat/demo-assets`
+1 -1
View File
@@ -15,7 +15,7 @@
<script src="../../js/api.js"></script>
<script src="../../js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
<script src="../../js/vendor/js-yaml/js-yaml.min.js"></script>
</head>
<body>
+1 -1
View File
@@ -50,7 +50,7 @@
</svg>
</div>
<div class="protocol-card-name">GraphQL</div>
<div class="protocol-card-tagline">Query and mutate a GraphQL schema. Supports queries, mutations, and subscriptions.</div>
<div class="protocol-card-tagline">Query and mutate a GraphQL schema. Supports queries and mutations over one fixed operation.</div>
<div class="protocol-card-tags">
<span class="protocol-tag">Query</span>
<span class="protocol-tag">Mutation</span>
+5 -5
View File
@@ -16,7 +16,7 @@
</button>
<button class="grpc-source-btn" data-source="reflection" onclick="selectGrpcSource('reflection')">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg>
Server reflection
Descriptor set
</button>
<button class="grpc-source-btn" data-source="manual" onclick="selectGrpcSource('manual')">
<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>
@@ -106,8 +106,8 @@
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2M3.1 3.1l1.4 1.4M11.5 11.5l1.4 1.4M3.1 12.9l1.4-1.4M11.5 4.5l1.4-1.4"/></svg>
</div>
<div>
<div class="config-card-title">Server reflection</div>
<div class="config-card-subtitle">Discover services from the upstream gRPC server at runtime</div>
<div class="config-card-title">Descriptor-driven discovery</div>
<div class="config-card-subtitle">Use the saved draft and a descriptor set to discover services through the backend</div>
</div>
</div>
<div class="config-card-body" style="gap: 14px; padding: 16px;">
@@ -123,7 +123,7 @@
<div id="grpc-reflect-idle">
<button class="btn-primary-sm" onclick="startReflection()">
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" style="margin-right:4px"><circle cx="8" cy="8" r="2.5"/><path d="M8 1v2M8 13v2M1 8h2M13 8h2"/></svg>
Discover methods
Open live descriptor tools
</button>
</div>
<!-- Loading -->
@@ -137,7 +137,7 @@
<div id="grpc-reflect-error" style="display:none">
<div class="grpc-reflect-error-row">
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="var(--error,#f87171)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="7"/><path d="M8 5v3M8 10.5v.5"/></svg>
<span id="grpc-reflect-error-text">Reflection not available on this server.</span>
<span id="grpc-reflect-error-text">Live descriptor discovery is handled in step 5.</span>
<button class="proto-file-change" onclick="startReflection()">Retry</button>
</div>
</div>
+170 -2
View File
@@ -87,15 +87,183 @@ tls:
</div>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label">Live validation and publishing</span>
<div class="section-divider-line"></div>
</div>
<div id="wizard-live-status" class="info-callout" style="margin-bottom: 20px; display:none;">
<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"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title" id="wizard-live-status-title">Draft actions</div>
<div class="info-callout-text" id="wizard-live-status-text"></div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<rect x="2" y="3" width="12" height="10" rx="1.5"/>
<path d="M5 7h6M5 10h6"/>
</svg>
</div>
<div>
<div class="config-card-title">Samples and draft generation</div>
<div class="config-card-subtitle">Persist input/output samples, then generate schemas and mappings from real payloads.</div>
</div>
</div>
<div class="config-card-body" style="gap: 16px;">
<div class="form-row">
<div class="form-group">
<label class="form-label">Input sample</label>
<textarea id="wizard-input-sample" class="form-textarea code-textarea" rows="9" spellcheck="false">{
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com"
}</textarea>
</div>
<div class="form-group">
<label class="form-label">Output sample</label>
<textarea id="wizard-output-sample" class="form-textarea code-textarea" rows="9" spellcheck="false">{
"id": "lead_123",
"status": "created"
}</textarea>
</div>
</div>
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
<button id="wizard-upload-input-sample" class="btn-primary-sm" type="button">Save input sample</button>
<button id="wizard-upload-output-sample" class="btn-ghost-sm" type="button">Save output sample</button>
<button id="wizard-generate-draft" class="btn-ghost-sm" type="button">Generate draft from samples</button>
</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 8h12M10 4l4 4-4 4"/>
</svg>
</div>
<div>
<div class="config-card-title">Test run</div>
<div class="config-card-subtitle">Run the current draft against the upstream before publishing it.</div>
</div>
</div>
<div class="config-card-body" style="gap: 16px;">
<div class="form-group">
<label class="form-label">Test input payload</label>
<textarea id="wizard-test-input" class="form-textarea code-textarea" rows="8" spellcheck="false">{
"first_name": "Ada",
"last_name": "Lovelace",
"email": "ada@example.com"
}</textarea>
</div>
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
<button id="wizard-run-test" class="btn-primary-sm" type="button">Run test</button>
<button id="wizard-copy-test-response" class="btn-ghost-sm" type="button">Use response as output sample</button>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label">Request preview</label>
<textarea id="wizard-test-request-preview" class="form-textarea code-textarea" rows="8" spellcheck="false" readonly></textarea>
</div>
<div class="form-group">
<label class="form-label">Response preview</label>
<textarea id="wizard-test-response-preview" class="form-textarea code-textarea" rows="8" spellcheck="false" readonly></textarea>
</div>
</div>
<div class="form-group">
<label class="form-label">Errors</label>
<textarea id="wizard-test-errors" class="form-textarea code-textarea" rows="4" spellcheck="false" readonly></textarea>
</div>
</div>
</div>
<div id="wizard-grpc-live-tools" class="config-card" style="margin-bottom: 20px; display:none;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 2H4a1 1 0 00-1 1v10a1 1 0 001 1h8a1 1 0 001-1V6z"/>
<path d="M9 2v4h4"/>
</svg>
</div>
<div>
<div class="config-card-title">gRPC descriptor set</div>
<div class="config-card-subtitle">Upload a compiled descriptor set and discover unary methods from the saved draft.</div>
</div>
</div>
<div class="config-card-body" style="gap: 16px;">
<div style="display:flex; gap: 8px; align-items:center; flex-wrap: wrap;">
<input id="wizard-descriptor-set-input" type="file" accept=".bin,.pb,.desc,.protoset,application/octet-stream" style="display:none">
<button id="wizard-select-descriptor-set" class="btn-ghost-sm" type="button">Choose descriptor set</button>
<button id="wizard-upload-descriptor-set" class="btn-primary-sm" type="button">Upload and discover services</button>
<span id="wizard-descriptor-set-name" style="color: var(--text-muted); font-size: 12px;">No descriptor set selected</span>
</div>
<div id="wizard-descriptor-services-summary" class="form-hint"></div>
<div id="wizard-descriptor-services-list" style="display:grid; gap: 10px;"></div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M8 2v12M3 7l5-5 5 5"/>
</svg>
</div>
<div>
<div class="config-card-title">YAML import and export</div>
<div class="config-card-subtitle">Move operation drafts between environments without leaving the wizard.</div>
</div>
</div>
<div class="config-card-body" style="gap: 16px;">
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
<button id="wizard-export-yaml" class="btn-ghost-sm" type="button">Export YAML</button>
<button id="wizard-import-yaml-file-trigger" class="btn-ghost-sm" type="button">Load YAML file</button>
<button id="wizard-import-yaml" class="btn-primary-sm" type="button">Import YAML</button>
<input id="wizard-import-yaml-file" type="file" accept=".yaml,.yml,text/yaml" style="display:none">
</div>
<div class="form-group">
<label class="form-label">YAML document</label>
<textarea id="wizard-import-yaml-text" class="form-textarea code-textarea" rows="10" spellcheck="false" placeholder="Paste exported YAML here to create or upsert an operation."></textarea>
</div>
</div>
</div>
<div class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 8l3 3 7-7"/>
</svg>
</div>
<div>
<div class="config-card-title">Publish</div>
<div class="config-card-subtitle">Save the current draft and expose it to published agents.</div>
</div>
</div>
<div class="config-card-body" style="gap: 12px;">
<div class="form-hint">Publishing uses the current draft version from this wizard session.</div>
<div style="display:flex; gap: 8px; flex-wrap: wrap;">
<button id="wizard-publish-operation" class="btn-primary-sm" type="button">Publish operation</button>
</div>
</div>
</div>
<div class="info-callout" style="margin-bottom: 24px;">
<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"/>
</svg>
<div class="info-callout-body">
<div class="info-callout-title">Operation will be saved as a draft</div>
<div class="info-callout-title">Operation stays editable in this wizard</div>
<div class="info-callout-text">
Clicking "Create operation" saves the contract in draft status. You can run tests, inspect metadata and publish from the operation workspace. Drafts are never exposed to MCP clients.
Saving creates or updates the current draft in place. Use the live actions above to upload samples, test the draft, export or import YAML, and publish when the upstream contract is ready.
</div>
</div>
</div>
+83
View File
@@ -47,6 +47,41 @@
return payload;
}
async function requestText(path, options) {
var response = await fetch(path, Object.assign({
credentials: 'same-origin',
headers: headers(),
}, options || {}));
var text = await response.text();
var payload = null;
if (text) {
try {
payload = JSON.parse(text);
} catch (_error) {}
}
if (!response.ok) {
if (response.status === 401 && window.CrankAuth && typeof window.CrankAuth.handleUnauthorized === 'function') {
window.CrankAuth.handleUnauthorized();
}
var message = payload && payload.error
? payload.error.message
: payload && payload.message
? payload.message
: text
? text
: ('HTTP ' + response.status);
var error = new Error(message);
error.status = response.status;
error.payload = payload;
throw error;
}
return text;
}
function get(path) {
return request(API_BASE + path);
}
@@ -155,9 +190,23 @@
archiveOperation: function(workspaceId, operationId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/archive', {});
},
publishOperation: function(workspaceId, operationId, version) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/publish', {
version: version,
});
},
createOperationVersion: function(workspaceId, operationId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions', payload);
},
runOperationTest: function(workspaceId, operationId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/test-runs', payload);
},
uploadInputSample: function(workspaceId, operationId, sample) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/samples/input-json', sample);
},
uploadOutputSample: function(workspaceId, operationId, sample) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/samples/output-json', sample);
},
uploadProtoFile: function(workspaceId, operationId, content, fileName) {
return postBytes(
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/descriptors/proto',
@@ -165,6 +214,40 @@
fileName
);
},
uploadDescriptorSet: function(workspaceId, operationId, content, fileName) {
return postBytes(
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/descriptors/descriptor-set',
content,
fileName
);
},
listGrpcServices: function(workspaceId, operationId, version) {
return get(
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/grpc/services'
+ query({ version: version })
);
},
generateDraft: function(workspaceId, operationId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/drafts/generate', payload || {});
},
exportOperation: function(workspaceId, operationId, params) {
return requestText(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/export' + query(params),
{
headers: headers({ 'Accept': 'application/yaml' }),
}
);
},
importOperation: function(workspaceId, yamlDocument, mode) {
return request(
API_BASE + '/workspaces/' + encodeURIComponent(workspaceId) + '/operations/import' + query({ mode: mode }),
{
method: 'POST',
headers: headers({ 'Content-Type': 'application/yaml' }),
body: yamlDocument,
}
);
},
listAgents: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/agents');
},
File diff suppressed because one or more lines are too long
+434 -89
View File
@@ -7,6 +7,9 @@ var wizardWorkspaceId = null;
var wizardCurrentOperation = null;
var wizardCurrentVersion = null;
var wizardProtoUpload = null;
var wizardDescriptorSetUpload = null;
var wizardTestResponsePreview = null;
var grpcDescriptorServices = [];
/* ── Dynamic step loading ── */
function _stepFile(n) {
@@ -210,6 +213,7 @@ document.addEventListener('DOMContentLoaded', async function() {
await loadWizardPanels([1, 2, 3, 4, 5]);
bindProtocolCards();
bindWizardLiveActions();
var params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'edit' && params.get('operationId')) {
@@ -219,6 +223,7 @@ document.addEventListener('DOMContentLoaded', async function() {
await loadOperationForEdit();
}
updateWizardProtocolVisibility();
_doGoToStep(1);
});
@@ -824,30 +829,11 @@ var REFLECTION_MOCK = {
};
function startReflection() {
var idle = document.getElementById('grpc-reflect-idle');
var loading = document.getElementById('grpc-reflect-loading');
var error = document.getElementById('grpc-reflect-error');
var view = document.getElementById('reflect-parsed-view');
if (idle) idle.style.display = 'none';
if (loading) loading.style.display = '';
if (error) error.style.display = 'none';
if (view) view.style.display = 'none';
document.getElementById('grpc-method-detail').style.display = 'none';
var statusEl = document.getElementById('grpc-reflect-status-text');
var steps = ['Connecting…', 'Negotiating TLS…', 'Sending reflection request…', 'Parsing service descriptors…'];
var i = 0;
var iv = setInterval(function() {
if (statusEl && i < steps.length) { statusEl.textContent = steps[i++]; }
}, 380);
setTimeout(function() {
clearInterval(iv);
if (loading) loading.style.display = 'none';
if (idle) idle.style.display = '';
renderReflectionResult(REFLECTION_MOCK);
}, 1700);
goToStep(5);
showWizardLiveStatus(
'gRPC discovery moved to live descriptor flow',
'Server reflection is not wired in this deployment. Save the draft, then use the descriptor-set tools in Step 5 to upload a real descriptor set and discover unary methods from the backend.'
);
}
@@ -1191,71 +1177,7 @@ function collectWizardPayload() {
async function saveOperation(stayOnPage) {
try {
var payload = collectWizardPayload();
var response;
if (!wizardWorkspaceId) {
throw new Error('No workspace selected');
}
if (wizardMode === 'edit' && wizardEditId) {
response = await window.CrankApi.updateOperation(wizardWorkspaceId, wizardEditId, {
display_name: payload.display_name,
category: payload.category,
target: payload.target,
input_schema: payload.input_schema,
output_schema: payload.output_schema,
input_mapping: payload.input_mapping,
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
});
} else {
response = await window.CrankApi.createOperation(wizardWorkspaceId, payload);
wizardEditId = response.operation_id;
wizardMode = 'edit';
history.replaceState({}, '', window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(wizardEditId));
setEditModePresentation();
var toolNameInput = document.getElementById('tool-name');
if (toolNameInput) toolNameInput.disabled = true;
}
if (wizardProtocol === 'grpc' && wizardProtoUpload && wizardEditId) {
var descriptor = await window.CrankApi.uploadProtoFile(
wizardWorkspaceId,
wizardEditId,
new TextEncoder().encode(wizardProtoUpload.content),
wizardProtoUpload.fileName
);
payload.target.descriptor_ref = descriptor.descriptor_id;
wizardCurrentVersion = wizardCurrentVersion || {};
wizardCurrentVersion.target = payload.target;
await window.CrankApi.updateOperation(wizardWorkspaceId, wizardEditId, {
display_name: payload.display_name,
category: payload.category,
target: payload.target,
input_schema: payload.input_schema,
output_schema: payload.output_schema,
input_mapping: payload.input_mapping,
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
});
}
if (stayOnPage) {
var saveDraftBtn = document.querySelector('.btn-save-draft');
if (saveDraftBtn) {
var label = saveDraftBtn.textContent;
saveDraftBtn.textContent = 'Saved ✓';
setTimeout(function() {
saveDraftBtn.textContent = label;
}, 1400);
}
return;
}
window.location.href = (window.APP_BASE || '') + 'index.html';
await persistCurrentDraft(stayOnPage);
} catch (error) {
alert(error.message || 'Failed to save operation');
}
@@ -1294,6 +1216,7 @@ function selectProtocol(protocol) {
});
var s3name = document.getElementById('sidebar-step-3-name');
if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || 'Protocol config';
updateWizardProtocolVisibility();
}
function prefillUpstream(baseUrl, headers) {
@@ -1423,4 +1346,426 @@ function prefillWizardFromEdit(detail, versionDocument) {
var toolNameInput = document.getElementById('tool-name');
if (toolNameInput) toolNameInput.disabled = true;
updateWizardProtocolVisibility();
}
function bindWizardLiveActions() {
bindClick('wizard-upload-input-sample', uploadInputSampleFromWizard);
bindClick('wizard-upload-output-sample', uploadOutputSampleFromWizard);
bindClick('wizard-generate-draft', generateDraftFromWizard);
bindClick('wizard-run-test', runWizardTest);
bindClick('wizard-copy-test-response', copyTestResponseToOutputSample);
bindClick('wizard-export-yaml', exportWizardYaml);
bindClick('wizard-import-yaml', importWizardYaml);
bindClick('wizard-publish-operation', publishWizardOperation);
bindClick('wizard-select-descriptor-set', function() {
var input = document.getElementById('wizard-descriptor-set-input');
if (input) input.click();
});
bindClick('wizard-upload-descriptor-set', uploadDescriptorSetAndDiscover);
bindClick('wizard-import-yaml-file-trigger', function() {
var input = document.getElementById('wizard-import-yaml-file');
if (input) input.click();
});
var descriptorInput = document.getElementById('wizard-descriptor-set-input');
if (descriptorInput) descriptorInput.addEventListener('change', handleDescriptorSetSelection);
var yamlInput = document.getElementById('wizard-import-yaml-file');
if (yamlInput) yamlInput.addEventListener('change', handleImportYamlFileSelection);
}
function bindClick(id, handler) {
var element = document.getElementById(id);
if (!element) return;
element.addEventListener('click', function(event) {
event.preventDefault();
handler();
});
}
function updateWizardProtocolVisibility() {
var grpcTools = document.getElementById('wizard-grpc-live-tools');
if (grpcTools) grpcTools.style.display = wizardProtocol === 'grpc' ? '' : 'none';
}
function showWizardLiveStatus(title, text, isError) {
var root = document.getElementById('wizard-live-status');
var titleEl = document.getElementById('wizard-live-status-title');
var textEl = document.getElementById('wizard-live-status-text');
if (!root || !titleEl || !textEl) return;
titleEl.textContent = title;
textEl.textContent = text;
root.style.display = '';
root.style.borderColor = isError ? 'rgba(248, 113, 113, 0.35)' : '';
}
function currentDraftVersion() {
if (wizardCurrentOperation && wizardCurrentOperation.draft_version_ref) {
return wizardCurrentOperation.draft_version_ref.version;
}
if (wizardCurrentOperation && wizardCurrentOperation.current_draft_version) {
return wizardCurrentOperation.current_draft_version;
}
if (wizardCurrentVersion && wizardCurrentVersion.version) {
return wizardCurrentVersion.version;
}
return 1;
}
function updateOperationPayload(payload) {
return {
display_name: payload.display_name,
category: payload.category,
target: payload.target,
input_schema: payload.input_schema,
output_schema: payload.output_schema,
input_mapping: payload.input_mapping,
output_mapping: payload.output_mapping,
execution_config: payload.execution_config,
tool_description: payload.tool_description,
};
}
async function refreshCurrentOperationState() {
if (!wizardWorkspaceId || !wizardEditId) return null;
var detail = await window.CrankApi.getOperation(wizardWorkspaceId, wizardEditId);
var versionNumber = detail.draft_version_ref
? detail.draft_version_ref.version
: detail.current_draft_version;
var versionDocument = await window.CrankApi.getOperationVersion(
wizardWorkspaceId,
wizardEditId,
versionNumber
);
wizardCurrentOperation = detail;
wizardCurrentVersion = versionDocument;
updateWizardProtocolVisibility();
return {
detail: detail,
version: versionDocument,
};
}
async function uploadPendingGrpcArtifacts(payload) {
if (wizardProtocol !== 'grpc' || !wizardEditId) return;
var descriptorResponse = null;
if (wizardProtoUpload) {
descriptorResponse = await window.CrankApi.uploadProtoFile(
wizardWorkspaceId,
wizardEditId,
new TextEncoder().encode(wizardProtoUpload.content),
wizardProtoUpload.fileName
);
wizardProtoUpload = null;
}
if (wizardDescriptorSetUpload) {
descriptorResponse = await window.CrankApi.uploadDescriptorSet(
wizardWorkspaceId,
wizardEditId,
wizardDescriptorSetUpload.bytes,
wizardDescriptorSetUpload.fileName
);
wizardDescriptorSetUpload = null;
var descriptorName = document.getElementById('wizard-descriptor-set-name');
if (descriptorName) descriptorName.textContent = 'Descriptor set uploaded';
}
if (!descriptorResponse) return;
payload.target.descriptor_ref = descriptorResponse.descriptor_id;
payload.target.descriptor_set_b64 = '';
await window.CrankApi.updateOperation(
wizardWorkspaceId,
wizardEditId,
updateOperationPayload(payload)
);
}
async function persistCurrentDraft(stayOnPage) {
if (!wizardWorkspaceId) {
throw new Error('No workspace selected');
}
var payload = collectWizardPayload();
var response;
var createdNow = false;
if (wizardMode === 'edit' && wizardEditId) {
response = await window.CrankApi.updateOperation(
wizardWorkspaceId,
wizardEditId,
updateOperationPayload(payload)
);
} else {
response = await window.CrankApi.createOperation(wizardWorkspaceId, payload);
createdNow = true;
wizardEditId = response.operation_id;
wizardMode = 'edit';
history.replaceState(
{},
'',
window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(wizardEditId)
);
setEditModePresentation();
_doGoToStep(currentStep);
var toolNameInput = document.getElementById('tool-name');
if (toolNameInput) toolNameInput.disabled = true;
}
await uploadPendingGrpcArtifacts(payload);
await refreshCurrentOperationState();
if (stayOnPage) {
var saveDraftBtn = document.querySelector('.btn-save-draft');
if (saveDraftBtn) {
var label = saveDraftBtn.textContent;
saveDraftBtn.textContent = 'Saved ✓';
setTimeout(function() {
saveDraftBtn.textContent = label;
}, 1400);
}
}
showWizardLiveStatus(
createdNow ? 'Draft created' : 'Draft updated',
'The current draft is saved on the backend and ready for live actions in this wizard.'
);
return response;
}
function safeStringify(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value, null, 2);
}
function setTextareaValue(id, value) {
var element = document.getElementById(id);
if (!element) return;
element.value = safeStringify(value);
}
async function uploadInputSampleFromWizard() {
await persistCurrentDraft(true);
await window.CrankApi.uploadInputSample(
wizardWorkspaceId,
wizardEditId,
parseStructuredText(textValue('wizard-input-sample'))
);
showWizardLiveStatus('Input sample saved', 'The input sample is now stored against the current draft version.');
}
async function uploadOutputSampleFromWizard() {
await persistCurrentDraft(true);
await window.CrankApi.uploadOutputSample(
wizardWorkspaceId,
wizardEditId,
parseStructuredText(textValue('wizard-output-sample'))
);
showWizardLiveStatus('Output sample saved', 'The output sample is now stored against the current draft version.');
}
async function generateDraftFromWizard() {
await persistCurrentDraft(true);
var generated = await window.CrankApi.generateDraft(wizardWorkspaceId, wizardEditId, {});
setValue('tool-input-schema', JSON.stringify(crankSchemaToJsonSchema(generated.input_schema), null, 2));
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(generated.output_schema), null, 2));
setValue('tool-input-mapping', mappingSetToEditorValue(generated.input_mapping, 'input', wizardProtocol));
setValue('tool-output-mapping', mappingSetToEditorValue(generated.output_mapping, 'output', wizardProtocol));
showWizardLiveStatus('Draft generated', 'Schemas and mappings were regenerated from the saved samples.');
}
async function runWizardTest() {
await persistCurrentDraft(true);
var result = await window.CrankApi.runOperationTest(wizardWorkspaceId, wizardEditId, {
version: currentDraftVersion(),
input: parseStructuredText(textValue('wizard-test-input')),
});
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 : []);
showWizardLiveStatus(
result.ok ? 'Test run completed' : 'Test run returned errors',
result.ok
? 'The current draft executed successfully against the upstream.'
: 'The draft executed, but the backend returned validation or runtime errors.',
!result.ok
);
}
function copyTestResponseToOutputSample() {
if (!wizardTestResponsePreview) {
showWizardLiveStatus('No test response yet', 'Run a test first, then copy the response preview into the output sample editor.', true);
return;
}
setTextareaValue('wizard-output-sample', wizardTestResponsePreview);
showWizardLiveStatus('Response copied', 'The latest test response was copied into the output sample editor.');
}
function downloadTextFile(fileName, content, mimeType) {
var blob = new Blob([content], { type: mimeType || 'text/plain;charset=utf-8' });
var url = URL.createObjectURL(blob);
var link = document.createElement('a');
link.href = url;
link.download = fileName;
link.click();
URL.revokeObjectURL(url);
}
async function exportWizardYaml() {
await persistCurrentDraft(true);
var yaml = await window.CrankApi.exportOperation(wizardWorkspaceId, wizardEditId, {
mode: 'portable',
version: currentDraftVersion(),
});
downloadTextFile((textValue('tool-name') || 'operation') + '.yaml', yaml, 'application/yaml');
showWizardLiveStatus('YAML exported', 'The current draft YAML was downloaded from the backend.');
}
async function importWizardYaml() {
if (!wizardWorkspaceId) {
throw new Error('No workspace selected');
}
var yamlDocument = textValue('wizard-import-yaml-text');
if (!yamlDocument) {
showWizardLiveStatus('No YAML provided', 'Paste a YAML document or load it from a file before importing.', true);
return;
}
var imported = await window.CrankApi.importOperation(wizardWorkspaceId, yamlDocument, 'upsert');
showWizardLiveStatus('YAML imported', 'The imported operation draft was stored. The wizard will reopen it now.');
window.location.href = window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(imported.operation_id);
}
async function publishWizardOperation() {
await persistCurrentDraft(true);
var published = await window.CrankApi.publishOperation(
wizardWorkspaceId,
wizardEditId,
currentDraftVersion()
);
await refreshCurrentOperationState();
showWizardLiveStatus(
'Operation published',
'Version ' + published.published_version + ' is now published and can be bound into agents.'
);
}
function handleImportYamlFileSelection(event) {
var file = event.target.files && event.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(loadEvent) {
setValue('wizard-import-yaml-text', loadEvent.target.result || '');
showWizardLiveStatus('YAML loaded', 'The YAML document was loaded into the import editor.');
};
reader.readAsText(file);
}
function handleDescriptorSetSelection(event) {
var file = event.target.files && event.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(loadEvent) {
wizardDescriptorSetUpload = {
fileName: file.name,
bytes: new Uint8Array(loadEvent.target.result),
};
var fileNameEl = document.getElementById('wizard-descriptor-set-name');
if (fileNameEl) fileNameEl.textContent = file.name;
showWizardLiveStatus('Descriptor set selected', 'Upload the descriptor set to discover unary gRPC methods from the backend.');
};
reader.readAsArrayBuffer(file);
}
function renderDescriptorServiceList(services) {
grpcDescriptorServices = services || [];
var summaryEl = document.getElementById('wizard-descriptor-services-summary');
var listEl = document.getElementById('wizard-descriptor-services-list');
if (!summaryEl || !listEl) return;
var methodCount = grpcDescriptorServices.reduce(function(total, service) {
return total + ((service.methods || []).length);
}, 0);
summaryEl.textContent = grpcDescriptorServices.length
? (grpcDescriptorServices.length + ' services · ' + methodCount + ' unary methods discovered')
: 'No unary methods discovered yet';
listEl.innerHTML = '';
grpcDescriptorServices.forEach(function(service, serviceIndex) {
var group = document.createElement('div');
group.className = 'config-card';
group.style.marginBottom = '0';
var body = document.createElement('div');
body.className = 'config-card-body';
body.style.padding = '16px';
body.style.gap = '10px';
var title = document.createElement('div');
title.className = 'config-card-title';
title.textContent = (service.package ? service.package + '.' : '') + service.service;
body.appendChild(title);
(service.methods || []).forEach(function(method, methodIndex) {
var button = document.createElement('button');
button.type = 'button';
button.className = 'btn-ghost-sm';
button.style.width = '100%';
button.style.justifyContent = 'space-between';
button.textContent = method.name;
button.addEventListener('click', function() {
applyDiscoveredGrpcMethod(serviceIndex, methodIndex);
});
body.appendChild(button);
});
group.appendChild(body);
listEl.appendChild(group);
});
}
function applyDiscoveredGrpcMethod(serviceIndex, methodIndex) {
var service = grpcDescriptorServices[serviceIndex];
var method = service && service.methods ? service.methods[methodIndex] : null;
if (!service || !method) return;
var route = '/' + (service.package ? service.package + '.' : '') + service.service + '/' + method.name;
selectGrpcSource('manual');
setValue('grpc-manual-route', route);
setValue('grpc-manual-req-type', method.name + 'Request');
setValue('grpc-manual-res-type', method.name + 'Response');
grpcManualRouteInput(route);
grpcManualTypeInput();
setValue('tool-input-schema', JSON.stringify(crankSchemaToJsonSchema(method.input_schema), null, 2));
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(method.output_schema), null, 2));
showWizardLiveStatus('gRPC method applied', 'The route and schemas were filled from the uploaded descriptor set.');
}
async function uploadDescriptorSetAndDiscover() {
var hasSavedDescriptor = !!(
wizardCurrentVersion
&& wizardCurrentVersion.target
&& wizardCurrentVersion.target.kind === 'grpc'
&& wizardCurrentVersion.target.descriptor_ref
);
if (!wizardDescriptorSetUpload && !hasSavedDescriptor) {
showWizardLiveStatus('No descriptor set selected', 'Choose a descriptor-set file before uploading it, or save a draft that already has one attached.', true);
return;
}
await persistCurrentDraft(true);
var services = await window.CrankApi.listGrpcServices(
wizardWorkspaceId,
wizardEditId,
currentDraftVersion()
);
renderDescriptorServiceList(services.services || []);
showWizardLiveStatus('Descriptor services discovered', 'The backend parsed the descriptor set. Pick a method to apply its route and schemas.');
}
+20 -1
View File
@@ -6,7 +6,8 @@
"": {
"name": "crank-ui",
"dependencies": {
"alpinejs": "3.15.9"
"alpinejs": "3.15.9",
"js-yaml": "4.1.1"
}
},
"node_modules/@vue/reactivity": {
@@ -32,6 +33,24 @@
"dependencies": {
"@vue/reactivity": "~3.1.1"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/js-yaml": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz",
"integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==",
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
}
}
}
+3 -2
View File
@@ -2,9 +2,10 @@
"name": "crank-ui",
"private": true,
"scripts": {
"sync:vendor": "mkdir -p js/vendor/alpine && cp node_modules/alpinejs/dist/cdn.min.js js/vendor/alpine/cdn.min.js"
"sync:vendor": "mkdir -p js/vendor/alpine js/vendor/js-yaml && cp node_modules/alpinejs/dist/cdn.min.js js/vendor/alpine/cdn.min.js && cp node_modules/js-yaml/dist/js-yaml.min.js js/vendor/js-yaml/js-yaml.min.js"
},
"dependencies": {
"alpinejs": "3.15.9"
"alpinejs": "3.15.9",
"js-yaml": "4.1.1"
}
}
+16 -15
View File
@@ -115,8 +115,9 @@ UI-файлы:
Что еще не хватает:
- frontend adapter поверх `OperationDetail` и `OperationVersionDocument`, чтобы wizard перестал хранить edit-state в браузере;
- финальное выравнивание step payload c canonical DTO, чтобы create/edit/export/import использовали одну модель на фронте.
- server-side current workspace model вместо client-side `localStorage`;
- дальнейший UX polish вокруг gRPC discovery, потому что server reflection сознательно заменен на descriptor-driven live flow;
- возможные smoke tests для `wizard`, чтобы закрепить уже подключенный live contract.
Что убрать из UI после интеграции:
@@ -439,33 +440,33 @@ UI хранит current workspace в `localStorage`.
### Wave 1
- `Operations catalog`
- `Wizard`
- `Operations catalog` — completed
- `Wizard` — completed
### Wave 2
- `Agents`
- `API Keys`
- `Agents` — completed
- `API Keys` — completed
### Wave 3
- `Logs`
- `Usage`
- `Logs` — completed
- `Usage` — completed
### Wave 4
- `Workspace setup`
- частичный `Settings`
- `Workspace setup` — completed
- частичный `Settings` — completed
### Wave 5
- `Login`
- полноценный session/auth layer
- `Login` — completed
- полноценный session/auth layer — completed
## 7. Ближайший практический шаг
Следующий рабочий этап:
- реализовать frontend adapters для `Operations catalog` и `Wizard`;
- добить backend `PATCH/DELETE/archive` по operations;
- убрать `localStorage` overrides и mock `operations.json` для первых двух экранов.
- добить live `Settings` без заглушек для profile/security;
- закрыть `workspace access` lifecycle (`roles`, `member removal`, `delete/export workspace`);
- пройти stabilization pass по Alpine UI и убрать оставшиеся client-side fallback patterns.