Files
crank/apps/ui/js/wizard.js
T
2026-03-30 00:15:02 +03:00

1042 lines
40 KiB
JavaScript

var currentStep = 1;
var TOTAL_STEPS = 5;
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc'
var wizardMode = 'create'; // 'create' | 'edit'
var wizardEditId = null;
/* ── Dynamic step loading ── */
function _stepFile(n) {
return (n === 3) ? 'step3-' + wizardProtocol + '.html' : 'step' + n + '.html';
}
function loadStep(n, callback) {
var panelId = (n === 3) ? step3PanelId() : 'step-panel-' + n;
if (document.getElementById(panelId)) { if (callback) callback(); return; }
fetch(_stepFile(n))
.then(function(r) { return r.text(); })
.then(function(html) {
var c = document.getElementById('step-panel-container');
if (c) c.insertAdjacentHTML('beforeend', html);
if (callback) callback();
})
.catch(function() { if (callback) callback(); });
}
/* Step 3 panel id by protocol */
function step3PanelId() {
if (wizardProtocol === 'graphql') return 'step-panel-3-graphql';
if (wizardProtocol === 'grpc') return 'step-panel-3-grpc';
return 'step-panel-3-rest';
}
/* Sidebar step-3 label by protocol */
var STEP3_LABELS = {
rest: 'Request config',
graphql: 'GQL operation',
grpc: 'RPC method',
};
var CHECK_SVG = '<svg width="10" height="10"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>';
var ARROW_SVG = '<svg width="13" height="13"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-right.svg#icon"/></svg>';
var BACK_SVG = '<svg width="13" height="13"><use href="' + (window.APP_BASE||'') + 'icons/general/arrow-left.svg#icon"/></svg>';
function goToStep(n) {
if (n < 1 || n > TOTAL_STEPS) return;
loadStep(n, function() { _doGoToStep(n); });
}
function _doGoToStep(n) {
// 1. hide all panes, show target (step 4 is protocol-specific)
document.querySelectorAll('.step-pane').forEach(function(p) { p.style.display = 'none'; });
var panelId = (n === 3) ? step3PanelId() : 'step-panel-' + n;
var pane = document.getElementById(panelId);
if (pane) pane.style.display = '';
// 2. update sidebar step items
document.querySelectorAll('.step-item').forEach(function(item, i) {
var num = i + 1;
item.classList.remove('active', 'done', 'pending');
var ind = item.querySelector('.step-indicator');
var statusEl = item.querySelector('.step-status-text');
if (num < n) {
item.classList.add('done');
if (ind) ind.innerHTML = CHECK_SVG;
if (statusEl) statusEl.textContent = 'Completed';
} else if (num === n) {
item.classList.add('active');
if (ind) ind.textContent = num;
if (statusEl) statusEl.textContent = 'In progress';
} else {
item.classList.add('pending');
if (ind) ind.textContent = num;
if (statusEl) statusEl.textContent = 'Not started';
}
});
// 3. update progress bar
var pct = Math.round((n / TOTAL_STEPS) * 100);
var fill = document.querySelector('.progress-bar-fill');
if (fill) fill.style.width = pct + '%';
var pctEl = document.querySelector('.progress-pct');
if (pctEl) pctEl.textContent = pct + '%';
// 4. update step counter
var counter = document.querySelector('.step-counter');
if (counter) counter.innerHTML = 'Step <strong>' + n + '</strong> of ' + TOTAL_STEPS;
// 5. back button
var backBtn = document.querySelector('.btn-back');
if (backBtn) {
if (n === 1) {
backBtn.disabled = true;
backBtn.style.opacity = '0.35';
backBtn.style.cursor = 'not-allowed';
} else {
backBtn.disabled = false;
backBtn.style.opacity = '';
backBtn.style.cursor = '';
}
}
// 6. continue button
var contBtn = document.querySelector('.btn-continue');
if (contBtn) {
if (n === TOTAL_STEPS) {
var finalLabel = wizardMode === 'edit' ? 'Save changes' : 'Create operation';
contBtn.innerHTML = finalLabel + ' ' + ARROW_SVG;
contBtn.style.cssText = 'background:#3fb950;border-color:#2ea043;box-shadow:0 1px 4px rgba(63,185,80,0.4);';
} else {
contBtn.innerHTML = 'Continue ' + ARROW_SVG;
contBtn.style.cssText = '';
}
}
currentStep = n;
window.scrollTo({ top: 0, behavior: 'smooth' });
}
document.addEventListener('DOMContentLoaded', function() {
// Navigation buttons
document.querySelector('.btn-continue').addEventListener('click', function() {
if (currentStep < TOTAL_STEPS) {
goToStep(currentStep + 1);
} else {
saveOperation();
}
});
document.querySelector('.btn-back').addEventListener('click', function() {
if (!this.disabled && currentStep > 1) goToStep(currentStep - 1);
});
// Sidebar step clicks
document.querySelectorAll('.step-item').forEach(function(item, i) {
item.addEventListener('click', function() { goToStep(i + 1); });
});
// Back to catalog / close
var backToCatalog = document.getElementById('back-to-catalog');
if (backToCatalog) {
backToCatalog.addEventListener('click', function() {
window.location.href = (window.APP_BASE || '') + 'index.html';
});
}
var closeBtn = document.querySelector('.progress-close');
if (closeBtn) {
closeBtn.addEventListener('click', function() {
window.location.href = (window.APP_BASE || '') + 'index.html';
});
}
// Protocol card selection
document.querySelectorAll('.protocol-card').forEach(function(card) {
card.addEventListener('click', function() {
document.querySelectorAll('.protocol-card').forEach(function(c) {
c.classList.remove('selected');
c.setAttribute('aria-checked', 'false');
});
card.classList.add('selected');
card.setAttribute('aria-checked', 'true');
// Detect protocol from card data-protocol or class
var proto = card.dataset.protocol ||
(card.classList.contains('rest') ? 'rest' :
card.classList.contains('graphql') ? 'graphql' :
card.classList.contains('grpc') ? 'grpc' : 'rest');
wizardProtocol = proto;
// Update sidebar step-3 label
var s3name = document.getElementById('sidebar-step-3-name');
if (s3name) s3name.textContent = STEP3_LABELS[proto] || 'Protocol config';
// GraphQL: default endpoint path to /graphql and show hint
if (proto === 'graphql') {
var pathInput = document.getElementById('endpoint-path');
if (pathInput && pathInput.value === '/v1/leads') pathInput.value = '/graphql';
}
});
card.addEventListener('keydown', function(e) {
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); card.click(); }
});
});
// Method button selection
document.querySelectorAll('.method-btn').forEach(function(btn) {
btn.addEventListener('click', function() {
var cls = ['selected-get','selected-post','selected-put','selected-patch','selected-delete'];
document.querySelectorAll('.method-btn').forEach(function(b) {
cls.forEach(function(c) { b.classList.remove(c); });
});
btn.classList.add('selected-' + btn.querySelector('.method-verb').textContent.toLowerCase());
});
});
// Toggle switches
document.querySelectorAll('.toggle-row').forEach(function(row) {
row.addEventListener('click', function() {
var t = row.querySelector('.toggle');
if (t) t.classList.toggle('on');
});
});
// Save draft button
var saveDraftBtn = document.querySelector('.btn-save-draft');
if (saveDraftBtn) {
saveDraftBtn.addEventListener('click', function() {
var data = collectWizardData();
if (!data.name) return; // nothing to save yet
try {
var overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
if (wizardMode === 'edit' && wizardEditId) {
overrides = overrides.map(function(o) {
return o.id === wizardEditId ? Object.assign({}, o, data, { id: wizardEditId }) : o;
});
} else {
var draftId = 'op_draft_' + Date.now();
overrides.push(Object.assign({ id: draftId, status: 'draft', created_at: new Date().toISOString(), calls_today: 0, last_called_at: null, category: 'general' }, data));
}
localStorage.setItem('crank_ops_overrides', JSON.stringify(overrides));
} catch (e) {}
// Visual feedback
saveDraftBtn.textContent = 'Saved ✓';
setTimeout(function() { saveDraftBtn.textContent = 'Save draft'; }, 1800);
});
}
// Detect edit mode from URL param and sessionStorage
var params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'edit') {
wizardMode = 'edit';
document.title = 'Crank — Edit Operation';
try {
var editData = JSON.parse(sessionStorage.getItem('wizard_edit') || 'null');
if (editData) {
wizardEditId = editData.id;
prefillWizardFromEdit(editData);
}
} catch (e) {}
}
// Init: load step 1 fragment first, then render
loadStep(1, function() { _doGoToStep(1); });
});
/* ── HTTP method picker (Step 4 REST) ── */
var METHOD_CALLOUTS = {
GET: null,
DELETE: null,
POST: { icon: 'info', text: '<strong>POST selected — body encoding required.</strong> In step 4 you will define the JSON schema for the body payload. Crank will automatically serialize MCP tool arguments into the format you specify.' },
PUT: { icon: 'info', text: '<strong>PUT selected — full-resource replacement.</strong> The request body must contain a complete resource representation. Define the body schema in step 4.' },
PATCH: { icon: 'info', text: '<strong>PATCH selected — partial update.</strong> Only include the fields you want to modify in the body schema defined in step 4.' },
};
function selectMethod(btn) {
document.querySelectorAll('.method-card').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
var method = btn.dataset.method;
var callout = document.getElementById('method-callout-rest');
if (!callout) return;
var info = METHOD_CALLOUTS[method];
if (info) {
callout.innerHTML =
'<svg width="15" height="15" style="flex-shrink:0;color:var(--accent)"><use href="' + (window.APP_BASE||'') + 'icons/general/info-circle.svg#icon"/></svg>' +
'<span>' + info.text + '</span>';
callout.style.display = 'flex';
} else {
callout.style.display = 'none';
}
}
/* ── GraphQL operation type picker (Step 4 GraphQL) ── */
function selectGqlType(btn) {
document.querySelectorAll('.gql-type-card').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
// Update query editor placeholder if it's still the default
var editor = document.getElementById('gql-query-editor');
if (!editor) return;
var type = btn.dataset.gqlType;
var defaults = {
query: 'query GetContact($id: ID!) {\n contact(id: $id) {\n id\n firstName\n lastName\n email\n company {\n id\n name\n }\n createdAt\n }\n}',
mutation: 'mutation CreateContact(\n $firstName: String!\n $lastName: String!\n $email: String!\n $company: String\n) {\n createContact(input: {\n firstName: $firstName\n lastName: $lastName\n email: $email\n company: $company\n }) {\n id\n firstName\n lastName\n createdAt\n }\n}'
};
if (defaults[type]) editor.value = defaults[type];
}
/* ── Upstream selector (Step 3) ── */
var upstreams = [
{ id: 'acme-api', name: 'acme-api', url: 'https://api.acme.com', auth: 'bearer', authLabel: 'Bearer token' },
{ id: 'stripe', name: 'stripe-payments', url: 'https://api.stripe.com', auth: 'apikey', authLabel: 'API key' },
{ id: 'internal-crm', name: 'internal-crm', url: 'https://crm.internal.acme.io', auth: 'none', authLabel: 'No auth' },
{ id: 'sendgrid', name: 'sendgrid-mail', url: 'https://api.sendgrid.com', auth: 'apikey', authLabel: 'API key' },
{ id: 'twilio', name: 'twilio-sms', url: 'https://api.twilio.com', auth: 'bearer', authLabel: 'Bearer token' },
];
var selectedUpstreamId = null;
var dropdownOpen = false;
function renderDropdownList(filter) {
var list = document.getElementById('upstream-dropdown-list');
if (!list) return;
var q = (filter || '').toLowerCase();
var filtered = upstreams.filter(function(u) {
return !q || u.name.toLowerCase().includes(q) || u.url.toLowerCase().includes(q);
});
if (filtered.length === 0) {
list.innerHTML = '<div class="upstream-dropdown-empty">No upstreams match "' + escapeHtml(filter) + '"</div>';
return;
}
var tmpl = document.getElementById('tmpl-upstream-item');
list.innerHTML = '';
filtered.forEach(function(u) {
var sel = u.id === selectedUpstreamId;
var node = tmpl.content.cloneNode(true);
var item = node.querySelector('.upstream-dropdown-item');
if (sel) item.classList.add('selected');
item.addEventListener('click', function() { pickUpstream(u.id); });
node.querySelector('.upstream-dropdown-item-name').textContent = u.name;
node.querySelector('.upstream-dropdown-item-url').textContent = u.url;
var badge = node.querySelector('.upstream-auth-badge');
badge.textContent = u.authLabel;
badge.className = 'upstream-auth-badge auth-' + u.auth;
list.appendChild(node);
});
}
function openUpstreamDropdown() {
var combobox = document.getElementById('upstream-combobox');
var dropdown = document.getElementById('upstream-dropdown');
var search = document.getElementById('upstream-search');
if (!combobox || !dropdown) return;
combobox.classList.add('open');
dropdown.style.display = '';
dropdownOpen = true;
renderDropdownList('');
if (search) { search.value = ''; search.focus(); }
// Close new-upstream form if open
var form = document.getElementById('upstream-new-form');
if (form) form.style.display = 'none';
}
function closeUpstreamDropdown() {
var combobox = document.getElementById('upstream-combobox');
var dropdown = document.getElementById('upstream-dropdown');
if (!combobox || !dropdown) return;
combobox.classList.remove('open');
dropdown.style.display = 'none';
dropdownOpen = false;
}
function toggleUpstreamDropdown(e) {
e.stopPropagation();
if (dropdownOpen) { closeUpstreamDropdown(); } else { openUpstreamDropdown(); }
}
function filterUpstreams(val) {
renderDropdownList(val);
}
function pickUpstream(id) {
selectedUpstreamId = id;
closeUpstreamDropdown();
var u = upstreams.find(function(x) { return x.id === id; });
if (!u) return;
// Update combobox trigger value
var val = document.getElementById('upstream-combobox-value');
if (val) {
var trigTmpl = document.getElementById('tmpl-upstream-trigger-value');
if (trigTmpl) {
val.innerHTML = '';
var trigNode = trigTmpl.content.cloneNode(true);
trigNode.querySelector('.upstream-trigger-name').textContent = u.name;
trigNode.querySelector('.upstream-trigger-url').textContent = u.url;
val.appendChild(trigNode);
} else {
val.innerHTML =
'<div class="upstream-trigger-name">' + escapeHtml(u.name) + '</div>' +
'<div class="upstream-trigger-url">' + escapeHtml(u.url) + '</div>';
}
}
// Show preview strip
var preview = document.getElementById('upstream-preview');
var pName = document.getElementById('upstream-preview-name');
var pUrl = document.getElementById('upstream-preview-url');
var pBadge = document.getElementById('upstream-preview-badge');
if (preview) {
pName.textContent = u.name;
pUrl.textContent = u.url;
pBadge.textContent = u.authLabel;
pBadge.className = 'upstream-auth-badge auth-' + u.auth;
preview.style.display = 'flex';
}
// Hide new-upstream form and reset trigger
var form = document.getElementById('upstream-new-form');
if (form) form.style.display = 'none';
var trigger = document.getElementById('upstream-new-trigger');
if (trigger) trigger.classList.remove('active');
// Update reflection panel upstream info strip
var reflectName = document.getElementById('grpc-reflect-upstream-name');
var reflectUrl = document.getElementById('grpc-reflect-upstream-url');
if (reflectName) reflectName.textContent = u.name;
if (reflectUrl) reflectUrl.textContent = u.url;
}
function startNewUpstream() {
closeUpstreamDropdown();
var trigger = document.getElementById('upstream-new-trigger');
var form = document.getElementById('upstream-new-form');
var isOpen = form && form.style.display !== 'none';
if (isOpen) {
// toggle off — restore previous state
if (form) form.style.display = 'none';
if (trigger) trigger.classList.remove('active');
return;
}
// Clear combobox trigger & hide preview
var val = document.getElementById('upstream-combobox-value');
if (val) val.innerHTML = '<span class="upstream-combobox-placeholder">Select an upstream…</span>';
var preview = document.getElementById('upstream-preview');
if (preview) preview.style.display = 'none';
selectedUpstreamId = null;
// Mark trigger active and show form
if (trigger) trigger.classList.add('active');
if (form) {
form.style.display = '';
var first = form.querySelector('input');
if (first) setTimeout(function() { first.focus(); }, 50);
}
}
function saveNewUpstream(e) {
e.stopPropagation();
var nameEl = document.getElementById('new-upstream-name');
var urlEl = document.getElementById('new-upstream-url');
var name = nameEl ? nameEl.value.trim() : '';
var url = urlEl ? urlEl.value.trim() : '';
if (!name) { if (nameEl) nameEl.focus(); return; }
if (!url) { if (urlEl) urlEl.focus(); return; }
// Add to list
var newId = 'custom-' + Date.now();
upstreams.push({ id: newId, name: name, url: url, auth: 'bearer', authLabel: 'Bearer token' });
// Reset form
if (nameEl) nameEl.value = '';
if (urlEl) urlEl.value = '';
var form = document.getElementById('upstream-new-form');
if (form) form.style.display = 'none';
// Select the new upstream
pickUpstream(newId);
}
function cancelNewUpstream(e) {
e.stopPropagation();
var form = document.getElementById('upstream-new-form');
if (form) form.style.display = 'none';
var trigger = document.getElementById('upstream-new-trigger');
if (trigger) trigger.classList.remove('active');
}
function escapeHtml(str) {
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
// Close dropdown on outside click
document.addEventListener('click', function() {
if (dropdownOpen) closeUpstreamDropdown();
});
/* ══════════════════════════════════════════════
gRPC — source selector
══════════════════════════════════════════════ */
var grpcSource = 'proto'; // 'proto' | 'reflection' | 'manual'
function selectGrpcSource(source) {
grpcSource = source;
document.querySelectorAll('.grpc-source-btn').forEach(function(btn) {
btn.classList.toggle('active', btn.dataset.source === source);
});
document.getElementById('grpc-src-proto').style.display = source === 'proto' ? '' : 'none';
document.getElementById('grpc-src-reflection').style.display = source === 'reflection' ? '' : 'none';
document.getElementById('grpc-src-manual').style.display = source === 'manual' ? '' : 'none';
// hide shared detail when switching sources
var detail = document.getElementById('grpc-method-detail');
if (detail) detail.style.display = 'none';
selectedRpcMethod = null;
}
/* ══════════════════════════════════════════════
gRPC — Proto file parser & UI
══════════════════════════════════════════════ */
var protoParsed = null; // { package, services, messages, streamingCount }
var selectedRpcMethod = null; // { service, method, request, response }
/* Extract top-level blocks with proper brace matching */
function extractBlocks(text, keyword) {
var blocks = [];
var re = new RegExp(keyword + '\\s+(\\w+)\\s*\\{', 'g');
var match;
while ((match = re.exec(text)) !== null) {
var name = match[1];
var start = match.index + match[0].length;
var depth = 1;
var i = start;
while (i < text.length && depth > 0) {
if (text[i] === '{') depth++;
else if (text[i] === '}') depth--;
i++;
}
blocks.push({ name: name, body: text.slice(start, i - 1), start: match.index });
}
return blocks;
}
/* Strip single-line and block comments */
function stripProtoComments(text) {
// block comments
text = text.replace(/\/\*[\s\S]*?\*\//g, '');
// line comments
text = text.replace(/\/\/[^\n]*/g, '');
return text;
}
/* Parse a proto text → { package, services, messages, streamingCount } */
function parseProto(text) {
text = stripProtoComments(text);
var pkg = '';
var pkgMatch = text.match(/\bpackage\s+([\w.]+)\s*;/);
if (pkgMatch) pkg = pkgMatch[1];
// messages: map name → [{ repeated, type, name }]
var messages = {};
extractBlocks(text, 'message').forEach(function(blk) {
var fields = [];
var fieldRe = /\b(repeated\s+)?([\w.]+)\s+(\w+)\s*=\s*\d+/g;
var fm;
while ((fm = fieldRe.exec(blk.body)) !== null) {
// skip reserved / map keywords
if (['option','reserved','extensions','oneof'].indexOf(fm[2]) !== -1) continue;
fields.push({ repeated: !!fm[1], type: fm[2], name: fm[3] });
}
messages[blk.name] = fields;
});
var streamingCount = 0;
var services = [];
extractBlocks(text, 'service').forEach(function(svc) {
var methods = [];
// rpc Name ( [stream] ReqType ) returns ( [stream] ResType ) { }
var rpcRe = /\brpc\s+(\w+)\s*\(\s*(stream\s+)?([\w.]+)\s*\)\s*returns\s*\(\s*(stream\s+)?([\w.]+)\s*\)/g;
var rm;
while ((rm = rpcRe.exec(svc.body)) !== null) {
var streamIn = !!rm[2];
var streamOut = !!rm[4];
if (streamIn || streamOut) {
streamingCount++;
} else {
// Strip package prefix from type names for display
var reqType = rm[3].split('.').pop();
var resType = rm[5].split('.').pop();
methods.push({ name: rm[1], request: reqType, response: resType });
}
}
if (methods.length > 0) {
services.push({ name: svc.name, methods: methods });
}
});
return { package: pkg, services: services, messages: messages, streamingCount: streamingCount };
}
/* Generic renderer: fills a service/method list into given element IDs */
function renderServiceResults(parsed, ids) {
protoParsed = parsed;
selectedRpcMethod = null;
var notice = document.getElementById(ids.notice);
var countEl = document.getElementById(ids.count);
if (notice) {
notice.style.display = parsed.streamingCount > 0 ? '' : 'none';
if (countEl && parsed.streamingCount > 0) countEl.textContent = parsed.streamingCount;
}
var totalMethods = parsed.services.reduce(function(n, s) { return n + s.methods.length; }, 0);
var summaryEl = document.getElementById(ids.summary);
if (summaryEl) {
summaryEl.textContent =
parsed.services.length + ' service' + (parsed.services.length !== 1 ? 's' : '') +
' · ' + totalMethods + ' unary method' + (totalMethods !== 1 ? 's' : '') + ' found';
}
var listEl = document.getElementById(ids.list);
if (!listEl) return;
listEl.innerHTML = '';
if (parsed.services.length === 0) {
var emptyDiv = document.createElement('div');
emptyDiv.className = 'proto-empty';
emptyDiv.textContent = 'No unary RPC methods found.';
listEl.appendChild(emptyDiv);
} else {
var svcTmpl = document.getElementById('tmpl-proto-service-group');
var methodTmpl = document.getElementById('tmpl-proto-method-card');
parsed.services.forEach(function(svc) {
var svcNode = svcTmpl.content.cloneNode(true);
svcNode.querySelector('.proto-service-name').textContent = svc.name;
var grid = svcNode.querySelector('.proto-method-grid');
svc.methods.forEach(function(m) {
var mNode = methodTmpl.content.cloneNode(true);
var btn = mNode.querySelector('.proto-method-card');
btn.dataset.service = svc.name;
btn.dataset.method = m.name;
btn.dataset.req = m.request;
btn.dataset.res = m.response;
mNode.querySelector('.proto-method-name').textContent = m.name;
mNode.querySelector('.proto-req-type').textContent = m.request;
mNode.querySelector('.proto-res-type').textContent = m.response;
grid.appendChild(mNode);
});
listEl.appendChild(svcNode);
});
}
document.getElementById(ids.view).style.display = '';
document.getElementById('grpc-method-detail').style.display = 'none';
}
function renderParsedProto(parsed) {
renderServiceResults(parsed, {
notice: 'proto-streaming-notice', count: 'proto-streaming-count',
summary: 'proto-services-summary', list: 'proto-services-list', view: 'proto-parsed-view',
});
}
function renderReflectionResult(parsed) {
renderServiceResults(parsed, {
notice: 'reflect-streaming-notice', count: 'reflect-streaming-count',
summary: 'reflect-services-summary', list: 'reflect-services-list', view: 'reflect-parsed-view',
});
}
/* Select a method card (shared between proto + reflection) */
function selectRpcMethod(btn) {
document.querySelectorAll('.proto-method-card').forEach(function(b) { b.classList.remove('active'); });
btn.classList.add('active');
var svcName = btn.dataset.service;
var methodName = btn.dataset.method;
var reqType = btn.dataset.req;
var resType = btn.dataset.res;
selectedRpcMethod = { service: svcName, method: methodName, request: reqType, response: resType };
var pkg = protoParsed && protoParsed.package ? protoParsed.package + '.' : '';
var grpcPath = '/' + pkg + svcName + '/' + methodName;
var pathEl = document.getElementById('grpc-path-value');
if (pathEl) pathEl.textContent = grpcPath;
var sub = document.getElementById('grpc-method-subtitle');
if (sub) sub.textContent = 'rpc ' + methodName + ' (' + reqType + ') returns (' + resType + ')';
var reqTypeEl = document.getElementById('grpc-req-type');
var resTypeEl = document.getElementById('grpc-res-type');
if (reqTypeEl) reqTypeEl.textContent = reqType;
if (resTypeEl) resTypeEl.textContent = resType;
var sigGrid = document.getElementById('grpc-detail-sig');
if (sigGrid) sigGrid.style.display = '';
renderFieldList('grpc-req-fields', reqType);
renderFieldList('grpc-res-fields', resType);
document.getElementById('grpc-method-detail').style.display = '';
}
function renderFieldList(elId, typeName) {
var el = document.getElementById(elId);
if (!el) return;
var fields = protoParsed && protoParsed.messages[typeName];
el.innerHTML = '';
if (!fields || fields.length === 0) {
var emptyEl = document.createElement('div');
emptyEl.className = 'proto-field-empty';
emptyEl.innerHTML = 'Fields not resolved.<br><span style="color:var(--text-muted);font-size:11px;">Import or external types are not expanded client-side.</span>';
el.appendChild(emptyEl);
return;
}
var fieldTmpl = document.getElementById('tmpl-proto-field-row');
fields.forEach(function(f) {
var node = fieldTmpl.content.cloneNode(true);
node.querySelector('.proto-field-name').textContent = f.name;
node.querySelector('.proto-field-type').textContent = (f.repeated ? 'repeated ' : '') + f.type;
el.appendChild(node);
});
}
/* File handling */
function handleProtoFile(event) {
var file = event.target.files[0];
if (!file) return;
var reader = new FileReader();
reader.onload = function(e) {
showProtoFileInfo(file.name, file.size);
var parsed = parseProto(e.target.result);
renderParsedProto(parsed);
};
reader.readAsText(file);
}
function handleProtoDrop(event) {
event.preventDefault();
var dropzone = document.getElementById('proto-dropzone');
if (dropzone) dropzone.classList.remove('drag-over');
var file = event.dataTransfer.files[0];
if (!file || !file.name.endsWith('.proto')) return;
var reader = new FileReader();
reader.onload = function(e) {
showProtoFileInfo(file.name, file.size);
renderParsedProto(parseProto(e.target.result));
};
reader.readAsText(file);
}
function showProtoFileInfo(name, size) {
var dropzone = document.getElementById('proto-dropzone');
var info = document.getElementById('proto-file-info');
var nameEl = document.getElementById('proto-file-name');
var sizeEl = document.getElementById('proto-file-size');
if (dropzone) dropzone.style.display = 'none';
if (info) info.style.display = '';
if (nameEl) nameEl.textContent = name;
if (sizeEl) sizeEl.textContent = (size / 1024).toFixed(1) + ' KB';
// hide paste area
document.getElementById('proto-paste-area').style.display = 'none';
document.getElementById('proto-paste-btn').textContent = 'Paste content';
}
function resetProto() {
var dropzone = document.getElementById('proto-dropzone');
var info = document.getElementById('proto-file-info');
var parsedView = document.getElementById('proto-parsed-view');
if (dropzone) dropzone.style.display = '';
if (info) info.style.display = 'none';
if (parsedView) parsedView.style.display = 'none';
var fileInput = document.getElementById('proto-file-input');
if (fileInput) fileInput.value = '';
protoParsed = null;
selectedRpcMethod = null;
}
function toggleProtoPaste() {
var area = document.getElementById('proto-paste-area');
var btn = document.getElementById('proto-paste-btn');
if (!area) return;
var open = area.style.display !== 'none';
area.style.display = open ? 'none' : '';
if (btn) btn.textContent = open ? 'Paste content' : 'Cancel paste';
if (!open) {
var ta = document.getElementById('proto-paste-input');
if (ta) setTimeout(function() { ta.focus(); }, 50);
}
}
function cancelProtoPaste() {
var area = document.getElementById('proto-paste-area');
var btn = document.getElementById('proto-paste-btn');
if (area) area.style.display = 'none';
if (btn) btn.textContent = 'Paste content';
}
function parseProtoPasted() {
var ta = document.getElementById('proto-paste-input');
if (!ta || !ta.value.trim()) return;
var parsed = parseProto(ta.value);
document.getElementById('proto-paste-area').style.display = 'none';
var dropzone = document.getElementById('proto-dropzone');
if (dropzone) dropzone.style.display = 'none';
var info = document.getElementById('proto-file-info');
var nameEl = document.getElementById('proto-file-name');
var sizeEl = document.getElementById('proto-file-size');
if (info) info.style.display = '';
if (nameEl) nameEl.textContent = 'pasted.proto';
if (sizeEl) sizeEl.textContent = (ta.value.length / 1024).toFixed(1) + ' KB';
renderParsedProto(parsed);
}
/* ══════════════════════════════════════════════
gRPC — Server reflection (simulated)
══════════════════════════════════════════════ */
var REFLECTION_MOCK = {
package: 'acme.v1',
streamingCount: 2,
services: [
{ name: 'ContactService', methods: [
{ name: 'GetContact', request: 'GetContactRequest', response: 'Contact' },
{ name: 'CreateContact', request: 'CreateContactRequest', response: 'Contact' },
{ name: 'UpdateContact', request: 'UpdateContactRequest', response: 'Contact' },
{ name: 'DeleteContact', request: 'DeleteContactRequest', response: 'DeleteContactResponse' },
]},
{ name: 'CompanyService', methods: [
{ name: 'GetCompany', request: 'GetCompanyRequest', response: 'Company' },
{ name: 'ListCompanies', request: 'ListCompaniesRequest', response: 'ListCompaniesResponse' },
]},
],
messages: {
'GetContactRequest': [{ type: 'string', name: 'id' }],
'Contact': [{ type: 'string', name: 'id' }, { type: 'string', name: 'first_name' }, { type: 'string', name: 'last_name' }, { type: 'string', name: 'email' }, { type: 'string', name: 'company_id' }, { type: 'string', name: 'created_at' }],
'CreateContactRequest': [{ type: 'string', name: 'first_name' }, { type: 'string', name: 'last_name' }, { type: 'string', name: 'email' }, { type: 'string', name: 'company_id' }],
'UpdateContactRequest': [{ type: 'string', name: 'id' }, { type: 'string', name: 'first_name' }, { type: 'string', name: 'last_name' }, { type: 'string', name: 'email' }],
'DeleteContactRequest': [{ type: 'string', name: 'id' }],
'DeleteContactResponse': [{ type: 'bool', name: 'success' }],
'GetCompanyRequest': [{ type: 'string', name: 'id' }],
'Company': [{ type: 'string', name: 'id' }, { type: 'string', name: 'name' }, { type: 'string', name: 'domain' }, { type: 'int32', name: 'employee_count' }],
'ListCompaniesRequest': [{ type: 'int32', name: 'page' }, { type: 'int32', name: 'page_size' }, { type: 'string', name: 'query' }],
'ListCompaniesResponse': [{ type: 'Company', name: 'companies', repeated: true }, { type: 'int32', name: 'total' }],
},
};
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);
}
/* ══════════════════════════════════════════════
gRPC — Manual entry
══════════════════════════════════════════════ */
function grpcManualRouteInput(val) {
var detail = document.getElementById('grpc-method-detail');
var sigGrid = document.getElementById('grpc-detail-sig');
if (!val || !val.startsWith('/') || val.indexOf('/', 1) === -1) {
if (detail) detail.style.display = 'none';
return;
}
// Parse service + method from route: /[pkg.]Service/Method
var parts = val.replace(/^\//, '').split('/');
var servicePart = parts[0] || '';
var methodName = parts[1] || '';
var serviceName = servicePart.split('.').pop();
var pathEl = document.getElementById('grpc-path-value');
if (pathEl) pathEl.textContent = val;
var sub = document.getElementById('grpc-method-subtitle');
if (sub) sub.textContent = serviceName + '/' + methodName;
// hide sig grid in manual mode (no field data)
if (sigGrid) sigGrid.style.display = 'none';
grpcManualTypeInput();
if (detail) detail.style.display = '';
}
function grpcManualTypeInput() {
var reqInput = document.getElementById('grpc-manual-req-type');
var resInput = document.getElementById('grpc-manual-res-type');
var reqTypeEl = document.getElementById('grpc-req-type');
var resTypeEl = document.getElementById('grpc-res-type');
if (reqTypeEl) reqTypeEl.textContent = (reqInput && reqInput.value.trim()) || '—';
if (resTypeEl) resTypeEl.textContent = (resInput && resInput.value.trim()) || '—';
}
/* ══════════════════════════════════════════════
Wizard edit mode — collect, save, prefill
══════════════════════════════════════════════ */
function collectWizardData() {
var protocol = wizardProtocol;
// Upstream base URL
var upstream = upstreams.find(function(u) { return u.id === selectedUpstreamId; });
var baseUrl = upstream ? upstream.url : '';
// Protocol-specific path & method
var method = '';
var targetUrl = baseUrl;
if (protocol === 'rest') {
var activeMethod = document.querySelector('.method-card.active');
method = activeMethod ? activeMethod.dataset.method : 'POST';
var pathEl = document.getElementById('endpoint-path');
var path = pathEl ? pathEl.value.trim() : '';
targetUrl = baseUrl + path;
} else if (protocol === 'graphql') {
method = 'POST';
targetUrl = baseUrl + '/graphql';
} else if (protocol === 'grpc') {
var pathValEl = document.getElementById('grpc-path-value');
var grpcPath = pathValEl ? pathValEl.textContent.trim() : '';
targetUrl = baseUrl + grpcPath;
}
var val = function(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; };
return {
name: val('tool-name'),
display_name: val('tool-display-name') || val('tool-name'),
description: val('tool-description'),
protocol: protocol,
method: method,
target_url: targetUrl,
input_schema: val('tool-input-schema'),
output_schema: val('tool-output-schema'),
input_mapping: val('tool-input-mapping'),
output_mapping: val('tool-output-mapping'),
exec_config: val('tool-exec-config'),
};
}
function saveOperation() {
var data = collectWizardData();
if (!data.name) { goToStep(4); return; }
try {
var overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
if (wizardMode === 'edit' && wizardEditId) {
var found = false;
overrides = overrides.map(function(o) {
if (o.id === wizardEditId) {
found = true;
return Object.assign({}, o, data, { id: wizardEditId });
}
return o;
});
if (!found) {
overrides.push(Object.assign({ id: wizardEditId, status: 'draft', created_at: new Date().toISOString(), calls_today: 0, last_called_at: null, category: 'general' }, data));
}
} else {
var newId = 'op_' + Date.now();
overrides = overrides.filter(function(o) { return o.id !== newId; });
overrides.push(Object.assign({ id: newId, status: 'draft', created_at: new Date().toISOString(), calls_today: 0, last_called_at: null, category: 'general' }, data));
}
localStorage.setItem('crank_ops_overrides', JSON.stringify(overrides));
} catch (e) {}
setTimeout(function() { window.location.href = (window.APP_BASE || '') + 'index.html'; }, 300);
}
function prefillWizardFromEdit(op) {
if (!op) return;
// Update wizard header labels for edit mode
var progressLabel = document.querySelector('.progress-label');
if (progressLabel) progressLabel.textContent = 'Edit operation';
var sidebarBrand = document.querySelector('.step-sidebar-brand');
if (sidebarBrand) sidebarBrand.innerHTML = 'Edit <span>operation</span>';
// Step 1: protocol
wizardProtocol = op.protocol || 'rest';
document.querySelectorAll('.protocol-card').forEach(function(card) {
var proto = card.dataset.protocol ||
(card.classList.contains('rest') ? 'rest' : card.classList.contains('graphql') ? 'graphql' : 'grpc');
var sel = proto === wizardProtocol;
card.classList.toggle('selected', sel);
card.setAttribute('aria-checked', sel ? 'true' : 'false');
});
var s3name = document.getElementById('sidebar-step-3-name');
if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || 'Protocol config';
// Step 3: endpoint path & method
if (op.protocol === 'rest') {
var pathEl = document.getElementById('endpoint-path');
if (pathEl && op.target_url) {
try {
var url = new URL(op.target_url);
pathEl.value = url.pathname + url.search;
} catch (e) {
pathEl.value = op.target_url;
}
}
if (op.method) {
document.querySelectorAll('.method-card').forEach(function(card) {
card.classList.toggle('active', card.dataset.method === op.method);
});
}
} else if (op.protocol === 'graphql') {
var gqlEl = document.getElementById('gql-query-editor');
if (gqlEl && op.input_mapping) gqlEl.value = op.input_mapping;
}
// Steps 4 & 5: form fields
var set = function(id, val) { var el = document.getElementById(id); if (el) el.value = val || ''; };
set('tool-name', op.name);
set('tool-display-name', op.display_name);
set('tool-description', op.description);
set('tool-input-schema', op.input_schema);
set('tool-output-schema', op.output_schema);
set('tool-input-mapping', op.input_mapping);
set('tool-output-mapping',op.output_mapping);
set('tool-exec-config', op.exec_config);
}