feat: connect alpine operations and wizard to admin api
This commit is contained in:
@@ -13,6 +13,8 @@ DoD:
|
||||
- legacy React/Vite артефакты удалены из `apps/ui`
|
||||
- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs`
|
||||
- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта
|
||||
- `Operations` page читает live catalog из `admin-api` и удаляет операции через backend
|
||||
- `Wizard` использует live create/get/update flow вместо `localStorage` и `sessionStorage`
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -13,6 +13,8 @@
|
||||
<script src="../../js/config.js"></script>
|
||||
<script src="../../js/i18n.js"></script>
|
||||
<script src="../../js/workspace.js"></script>
|
||||
<script src="../../js/api.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/js-yaml@4.1.0/dist/js-yaml.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
||||
@@ -94,7 +94,7 @@
|
||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||
<span class="code-toolbar-label">json / auth-headers</span>
|
||||
</div>
|
||||
<textarea class="form-textarea code-textarea" rows="7">{
|
||||
<textarea class="form-textarea code-textarea" id="new-upstream-auth-headers" rows="7">{
|
||||
"Authorization": "${secrets.API_KEY}"
|
||||
}</textarea>
|
||||
</div>
|
||||
|
||||
+22
-16
@@ -13,6 +13,7 @@
|
||||
<script src="js/config.js"></script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script src="js/workspace.js"></script>
|
||||
<script src="js/api.js"></script>
|
||||
</head>
|
||||
<body x-data="catalog()">
|
||||
|
||||
@@ -118,23 +119,23 @@
|
||||
<div class="stats-row">
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.total">Total operations</div>
|
||||
<div class="stat-value" x-text="operations.length">—</div>
|
||||
<div class="stat-delta up">+3 this month</div>
|
||||
<div class="stat-value" x-text="formatMetricNumber(stats.total)">—</div>
|
||||
<div class="stat-delta" x-text="stats.total ? 'Catalog synced with backend' : 'No operations yet'"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.active">Active</div>
|
||||
<div class="stat-value" x-text="operations.filter(o => o.status === 'active').length">—</div>
|
||||
<div class="stat-delta" x-text="operations.length ? Math.round(operations.filter(o=>o.status==='active').length/operations.length*100)+'% of total' : ''">—</div>
|
||||
<div class="stat-value" x-text="formatMetricNumber(stats.active)">—</div>
|
||||
<div class="stat-delta" x-text="stats.total ? Math.round((stats.active / stats.total) * 100) + '% of total' : 'No published tools yet'">—</div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
|
||||
<div class="stat-value">4,821</div>
|
||||
<div class="stat-delta up">+12% vs yesterday</div>
|
||||
<div class="stat-value" x-text="formatMetricNumber(stats.requestsToday)">—</div>
|
||||
<div class="stat-delta" x-text="stats.requestsToday ? 'Summed from operation usage' : 'No traffic recorded today'"></div>
|
||||
</div>
|
||||
<div class="stat-card">
|
||||
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
|
||||
<div class="stat-value">187ms</div>
|
||||
<div class="stat-delta down">+23ms this week</div>
|
||||
<div class="stat-value" x-text="stats.averageLatencyMs ? stats.averageLatencyMs + 'ms' : '—'">—</div>
|
||||
<div class="stat-delta" x-text="stats.averageLatencyMs ? 'Weighted by today\\'s calls' : 'Waiting for runtime samples'"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -233,15 +234,15 @@
|
||||
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
|
||||
@click="toggleDropdown('agent')"
|
||||
>
|
||||
<span x-text="filterAgent ? activeAgents.find(a => a.id === filterAgent)?.display_name : 'Agent'"></span>
|
||||
<span x-text="filterAgent ? activeAgents.find(a => a.agent_id === filterAgent)?.display_name : 'Agent'"></span>
|
||||
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||
</button>
|
||||
<div class="dropdown-menu" x-show="openDropdown === 'agent'" x-transition>
|
||||
<template x-for="a in activeAgents" :key="a.id">
|
||||
<template x-for="a in activeAgents" :key="a.agent_id">
|
||||
<button
|
||||
class="dropdown-item"
|
||||
:class="{ selected: filterAgent === a.id }"
|
||||
@click="setAgent(a.id)"
|
||||
:class="{ selected: filterAgent === a.agent_id }"
|
||||
@click="setAgent(a.agent_id)"
|
||||
>
|
||||
<span x-text="a.display_name"></span>
|
||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
||||
@@ -321,8 +322,13 @@
|
||||
<p data-i18n="ops.empty.sub">Try adjusting your search or filters.</p>
|
||||
</div>
|
||||
|
||||
<div class="empty-state" x-show="!loading && loadError">
|
||||
<h3>Backend connection failed</h3>
|
||||
<p x-text="loadError"></p>
|
||||
</div>
|
||||
|
||||
<!-- Table itself -->
|
||||
<table x-show="!loading && totalFiltered > 0">
|
||||
<table x-show="!loading && totalFiltered > 0 && !loadError">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="th-sortable" :class="{ 'sort-active': sort.startsWith('name') }" @click="setSort(sort === 'name_asc' ? 'name_desc' : 'name_asc')">
|
||||
@@ -350,7 +356,7 @@
|
||||
<div class="op-name" x-text="op.name"></div>
|
||||
<div class="op-display" x-text="op.display_name"></div>
|
||||
<div class="op-agent-badges" x-show="(agentsByOpId[op.id] || []).length > 0">
|
||||
<template x-for="a in (agentsByOpId[op.id] || [])" :key="a.id">
|
||||
<template x-for="a in (agentsByOpId[op.id] || [])" :key="a.agent_id">
|
||||
<span class="op-agent-badge" x-text="a.display_name"></span>
|
||||
</template>
|
||||
</div>
|
||||
@@ -368,7 +374,7 @@
|
||||
<td class="col-url">
|
||||
<div class="target-url-cell">
|
||||
<span x-text="truncateUrl(op.target_url)" :title="op.target_url"></span>
|
||||
<a :href="op.target_url && op.target_url.startsWith('http') ? op.target_url : '#'" target="_blank" rel="noopener">
|
||||
<a :href="op.target_url && op.target_url.startsWith('http') ? op.target_url : '#'" target="_blank" rel="noopener" :style="!op.target_url ? 'opacity:.35;pointer-events:none;' : ''">
|
||||
<svg width="11" height="11"><use href="icons/general/external.svg#icon"/></svg>
|
||||
</a>
|
||||
</div>
|
||||
@@ -389,7 +395,7 @@
|
||||
</table>
|
||||
|
||||
<!-- Footer / pagination -->
|
||||
<div class="table-footer" x-show="!loading && totalFiltered > 0">
|
||||
<div class="table-footer" x-show="!loading && totalFiltered > 0 && !loadError">
|
||||
<span class="table-footer-info">
|
||||
Showing <span x-text="pageStart"></span>–<span x-text="pageEnd"></span> of <span x-text="totalFiltered"></span> operations
|
||||
</span>
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
(function() {
|
||||
var API_BASE = '/api/admin';
|
||||
|
||||
function headers(extra) {
|
||||
return Object.assign({
|
||||
'Accept': 'application/json',
|
||||
}, extra || {});
|
||||
}
|
||||
|
||||
async function request(path, options) {
|
||||
var response = await fetch(API_BASE + path, Object.assign({
|
||||
credentials: 'same-origin',
|
||||
headers: headers(),
|
||||
}, options || {}));
|
||||
|
||||
if (response.status === 204) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var payload = null;
|
||||
var text = '';
|
||||
|
||||
try {
|
||||
payload = await response.json();
|
||||
} catch (_error) {
|
||||
text = await response.text();
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
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 payload;
|
||||
}
|
||||
|
||||
function get(path) {
|
||||
return request(path);
|
||||
}
|
||||
|
||||
function post(path, body) {
|
||||
return request(path, {
|
||||
method: 'POST',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function patch(path, body) {
|
||||
return request(path, {
|
||||
method: 'PATCH',
|
||||
headers: headers({ 'Content-Type': 'application/json' }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
function del(path) {
|
||||
return request(path, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
function postBytes(path, bytes, fileName) {
|
||||
return request(path, {
|
||||
method: 'POST',
|
||||
headers: headers(fileName ? { 'X-File-Name': fileName } : {}),
|
||||
body: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
window.CrankApi = {
|
||||
listWorkspaces: function() {
|
||||
return get('/workspaces');
|
||||
},
|
||||
listOperations: function(workspaceId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations');
|
||||
},
|
||||
getOperation: function(workspaceId, operationId) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId));
|
||||
},
|
||||
getOperationVersion: function(workspaceId, operationId, version) {
|
||||
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions/' + encodeURIComponent(version));
|
||||
},
|
||||
createOperation: function(workspaceId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations', payload);
|
||||
},
|
||||
updateOperation: function(workspaceId, operationId, payload) {
|
||||
return patch('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId), payload);
|
||||
},
|
||||
deleteOperation: function(workspaceId, operationId) {
|
||||
return del('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId));
|
||||
},
|
||||
archiveOperation: function(workspaceId, operationId) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/archive', {});
|
||||
},
|
||||
createOperationVersion: function(workspaceId, operationId, payload) {
|
||||
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/versions', payload);
|
||||
},
|
||||
uploadProtoFile: function(workspaceId, operationId, content, fileName) {
|
||||
return postBytes(
|
||||
'/workspaces/' + encodeURIComponent(workspaceId) + '/operations/' + encodeURIComponent(operationId) + '/descriptors/proto',
|
||||
content,
|
||||
fileName
|
||||
);
|
||||
},
|
||||
};
|
||||
}());
|
||||
+380
-282
@@ -1,333 +1,431 @@
|
||||
const PAGE_SIZE_DESKTOP = 6;
|
||||
const PAGE_SIZE_MOBILE = 4;
|
||||
const PAGE_SIZE_MOBILE = 4;
|
||||
const MOBILE_BREAKPOINT = 960;
|
||||
|
||||
function getSortOptions() {
|
||||
return [
|
||||
{ value: 'created_desc', label: (window.t && t('sort.created_desc')) || 'Sort: Last created' },
|
||||
{ value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' },
|
||||
{ value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name A–Z' },
|
||||
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name Z–A' },
|
||||
{ value: 'created_asc', label: (window.t && t('sort.created_asc')) || 'Sort: Oldest first' },
|
||||
{ value: 'name_asc', label: (window.t && t('sort.name_asc')) || 'Sort: Name A–Z' },
|
||||
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name Z–A' },
|
||||
];
|
||||
}
|
||||
|
||||
function getTabDefs() {
|
||||
return [
|
||||
{ id: 'all', label: (window.t && t('tab.all')) || 'All' },
|
||||
{ id: 'active', label: (window.t && t('tab.active')) || 'Active' },
|
||||
{ id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' },
|
||||
{ id: 'error', label: (window.t && t('tab.error')) || 'Error' },
|
||||
{ id: 'all', label: (window.t && t('tab.all')) || 'All' },
|
||||
{ id: 'active', label: (window.t && t('tab.active')) || 'Active' },
|
||||
{ id: 'draft', label: (window.t && t('tab.draft')) || 'Draft' },
|
||||
{ id: 'error', label: (window.t && t('tab.error')) || 'Error' },
|
||||
{ id: 'inactive', label: (window.t && t('tab.inactive')) || 'Inactive' },
|
||||
];
|
||||
}
|
||||
|
||||
document.addEventListener('alpine:init', () => {
|
||||
function uiStatus(rawStatus) {
|
||||
if (rawStatus === 'published') return 'active';
|
||||
if (rawStatus === 'archived') return 'inactive';
|
||||
if (rawStatus === 'testing') return 'active';
|
||||
return rawStatus || 'draft';
|
||||
}
|
||||
|
||||
Alpine.data('catalog', () => ({
|
||||
// ── raw data ──
|
||||
operations: [],
|
||||
agents: [],
|
||||
loading: true,
|
||||
|
||||
// ── page size (responsive) ──
|
||||
pageSize: PAGE_SIZE_DESKTOP,
|
||||
|
||||
// ── filter state ──
|
||||
search: '',
|
||||
tab: 'all',
|
||||
filterProtocol: null,
|
||||
filterCategory: null,
|
||||
filterAgent: null,
|
||||
sort: 'created_desc',
|
||||
page: 1,
|
||||
|
||||
// ── dropdown open state ──
|
||||
openDropdown: null,
|
||||
|
||||
// ── mobile nav ──
|
||||
navOpen: false,
|
||||
|
||||
// ── derived option lists (built after data loads) ──
|
||||
categoryOptions: [],
|
||||
|
||||
async init() {
|
||||
const [opsRes, agentsRes] = await Promise.all([
|
||||
fetch((window.DATA_URL||'data/')+'operations.json'),
|
||||
fetch((window.DATA_URL||'data/')+'agents.json'),
|
||||
]);
|
||||
let ops = await opsRes.json();
|
||||
this.agents = await agentsRes.json();
|
||||
|
||||
// Apply localStorage overrides (from wizard create/edit)
|
||||
try {
|
||||
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
||||
if (overrides.length) {
|
||||
const existingIds = new Set(ops.map(o => o.id));
|
||||
ops = ops.map(op => {
|
||||
const ov = overrides.find(o => o.id === op.id);
|
||||
return ov ? Object.assign({}, op, ov) : op;
|
||||
});
|
||||
overrides
|
||||
.filter(o => !existingIds.has(o.id))
|
||||
.forEach(o => ops.push(o));
|
||||
}
|
||||
} catch (e) {}
|
||||
|
||||
this.operations = ops;
|
||||
this.categoryOptions = [...new Set(this.operations.map(op => op.category))].filter(Boolean).sort();
|
||||
this.loading = false;
|
||||
|
||||
// Responsive page size
|
||||
const updatePageSize = () => {
|
||||
this.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
|
||||
this.page = 1;
|
||||
};
|
||||
updatePageSize();
|
||||
window.addEventListener('resize', updatePageSize);
|
||||
|
||||
// Re-render labels on language change
|
||||
window.addEventListener('crank:langchange', () => {
|
||||
this.sort = this.sort;
|
||||
applyLang();
|
||||
});
|
||||
|
||||
// Close dropdowns and mobile nav on outside click
|
||||
document.addEventListener('click', (e) => {
|
||||
if (!e.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
|
||||
this.openDropdown = null;
|
||||
}
|
||||
if (!e.target.closest('.navbar')) {
|
||||
this.navOpen = false;
|
||||
}
|
||||
});
|
||||
function mapOperation(item) {
|
||||
return {
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
display_name: item.display_name,
|
||||
protocol: item.protocol,
|
||||
status: uiStatus(item.status),
|
||||
raw_status: item.status,
|
||||
category: item.category || 'general',
|
||||
created_at: item.created_at,
|
||||
updated_at: item.updated_at,
|
||||
published_at: item.published_at,
|
||||
target_url: '',
|
||||
method: '',
|
||||
usage_summary: item.usage_summary || {
|
||||
calls_today: 0,
|
||||
error_rate_pct: 0,
|
||||
avg_latency_ms: 0,
|
||||
},
|
||||
agent_refs: item.agent_refs || [],
|
||||
};
|
||||
}
|
||||
|
||||
// ── helpers for filtering (shared between filtered getter and tabCount) ──
|
||||
_applyNonStatusFilters(ops) {
|
||||
if (this.search.trim()) {
|
||||
const q = this.search.toLowerCase();
|
||||
ops = ops.filter(op =>
|
||||
op.name.toLowerCase().includes(q) ||
|
||||
op.display_name.toLowerCase().includes(q) ||
|
||||
(op.target_url || '').toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
if (this.filterProtocol) {
|
||||
ops = ops.filter(op => op.protocol === this.filterProtocol);
|
||||
}
|
||||
if (this.filterCategory) {
|
||||
ops = ops.filter(op => op.category === this.filterCategory);
|
||||
}
|
||||
if (this.filterAgent) {
|
||||
const agent = this.agents.find(a => a.id === this.filterAgent);
|
||||
const ids = agent ? agent.operation_ids : [];
|
||||
ops = ops.filter(op => ids.includes(op.id));
|
||||
}
|
||||
return ops;
|
||||
},
|
||||
function emptyStats() {
|
||||
return {
|
||||
total: 0,
|
||||
active: 0,
|
||||
requestsToday: 0,
|
||||
averageLatencyMs: 0,
|
||||
};
|
||||
}
|
||||
|
||||
// ── computed: filtered + sorted + paginated ──
|
||||
get filtered() {
|
||||
let ops = this._applyNonStatusFilters(this.operations);
|
||||
function computeStats(operations) {
|
||||
if (!operations.length) return emptyStats();
|
||||
|
||||
if (this.tab !== 'all') {
|
||||
ops = ops.filter(op => op.status === this.tab);
|
||||
}
|
||||
var requestsToday = operations.reduce(function(sum, operation) {
|
||||
return sum + (operation.usage_summary ? operation.usage_summary.calls_today : 0);
|
||||
}, 0);
|
||||
var latencyBase = operations.filter(function(operation) {
|
||||
return operation.usage_summary && operation.usage_summary.calls_today > 0;
|
||||
});
|
||||
var latencyWeight = latencyBase.reduce(function(sum, operation) {
|
||||
return sum + operation.usage_summary.calls_today;
|
||||
}, 0);
|
||||
var weightedLatency = latencyBase.reduce(function(sum, operation) {
|
||||
return sum + (operation.usage_summary.avg_latency_ms * operation.usage_summary.calls_today);
|
||||
}, 0);
|
||||
|
||||
ops = [...ops].sort((a, b) => {
|
||||
switch (this.sort) {
|
||||
case 'created_desc': return new Date(b.created_at) - new Date(a.created_at);
|
||||
case 'created_asc': return new Date(a.created_at) - new Date(b.created_at);
|
||||
case 'name_asc': return a.name.localeCompare(b.name);
|
||||
case 'name_desc': return b.name.localeCompare(a.name);
|
||||
default: return 0;
|
||||
}
|
||||
});
|
||||
return {
|
||||
total: operations.length,
|
||||
active: operations.filter(function(operation) { return operation.status === 'active'; }).length,
|
||||
requestsToday: requestsToday,
|
||||
averageLatencyMs: latencyWeight > 0 ? Math.round(weightedLatency / latencyWeight) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
return ops;
|
||||
},
|
||||
document.addEventListener('alpine:init', function() {
|
||||
Alpine.data('catalog', function() {
|
||||
return {
|
||||
operations: [],
|
||||
loading: true,
|
||||
loadError: '',
|
||||
pageSize: PAGE_SIZE_DESKTOP,
|
||||
search: '',
|
||||
tab: 'all',
|
||||
filterProtocol: null,
|
||||
filterCategory: null,
|
||||
filterAgent: null,
|
||||
sort: 'created_desc',
|
||||
page: 1,
|
||||
openDropdown: null,
|
||||
navOpen: false,
|
||||
categoryOptions: [],
|
||||
workspaceId: null,
|
||||
stats: emptyStats(),
|
||||
|
||||
get totalFiltered() { return this.filtered.length; },
|
||||
get totalPages() { return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize)); },
|
||||
async init() {
|
||||
var self = this;
|
||||
|
||||
get paginated() {
|
||||
const start = (this.page - 1) * this.pageSize;
|
||||
return this.filtered.slice(start, start + this.pageSize);
|
||||
},
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
this.workspaceId = workspace ? workspace.id : null;
|
||||
|
||||
get pageStart() { return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1; },
|
||||
get pageEnd() { return Math.min(this.page * this.pageSize, this.totalFiltered); },
|
||||
var updatePageSize = function() {
|
||||
self.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
|
||||
self.page = 1;
|
||||
};
|
||||
|
||||
// ── tab counts (respect non-status filters so numbers are consistent) ──
|
||||
tabCount(tab) {
|
||||
const ops = this._applyNonStatusFilters(this.operations);
|
||||
if (tab === 'all') return ops.length;
|
||||
return ops.filter(op => op.status === tab).length;
|
||||
},
|
||||
|
||||
// ── active filter chips (for dismissible chip row) ──
|
||||
get activeChips() {
|
||||
const chips = [];
|
||||
if (this.filterProtocol) {
|
||||
chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
|
||||
}
|
||||
if (this.filterCategory) {
|
||||
chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
|
||||
}
|
||||
if (this.filterAgent) {
|
||||
const a = this.agents.find(ag => ag.id === this.filterAgent);
|
||||
chips.push({ key: 'agent', label: 'Agent: ' + (a ? a.display_name : '') });
|
||||
}
|
||||
return chips;
|
||||
},
|
||||
|
||||
get hasActiveFilters() {
|
||||
return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim());
|
||||
},
|
||||
|
||||
clearChip(key) {
|
||||
if (key === 'protocol') this.filterProtocol = null;
|
||||
if (key === 'category') this.filterCategory = null;
|
||||
if (key === 'agent') this.filterAgent = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
this.filterProtocol = null;
|
||||
this.filterCategory = null;
|
||||
this.filterAgent = null;
|
||||
this.search = '';
|
||||
this.tab = 'all';
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
// ── agent lookup per operation (for badges in rows) ──
|
||||
get agentsByOpId() {
|
||||
const map = {};
|
||||
this.agents.forEach(a => {
|
||||
a.operation_ids.forEach(id => {
|
||||
if (!map[id]) map[id] = [];
|
||||
map[id].push(a);
|
||||
updatePageSize();
|
||||
window.addEventListener('resize', updatePageSize);
|
||||
window.addEventListener('crank:langchange', function() {
|
||||
self.sort = self.sort;
|
||||
applyLang();
|
||||
});
|
||||
window.addEventListener('crank:workspacechange', async function(event) {
|
||||
self.workspaceId = event.detail ? event.detail.id : null;
|
||||
await self.reload();
|
||||
});
|
||||
document.addEventListener('click', function(event) {
|
||||
if (!event.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
|
||||
self.openDropdown = null;
|
||||
}
|
||||
if (!event.target.closest('.navbar')) {
|
||||
self.navOpen = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
return map;
|
||||
},
|
||||
|
||||
// ── active agents only (for filter dropdown) ──
|
||||
get activeAgents() {
|
||||
return this.agents.filter(a => a.status === 'active');
|
||||
},
|
||||
await this.reload();
|
||||
},
|
||||
|
||||
// ── sort label ──
|
||||
get sortLabel() {
|
||||
return getSortOptions().find(o => o.value === this.sort)?.label ?? 'Sort';
|
||||
},
|
||||
async reload() {
|
||||
this.loading = true;
|
||||
this.loadError = '';
|
||||
|
||||
// ── actions ──
|
||||
setTab(tab) {
|
||||
this.tab = tab;
|
||||
this.page = 1;
|
||||
},
|
||||
try {
|
||||
var response = await window.CrankApi.listOperations(this.workspaceId);
|
||||
this.operations = (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) {
|
||||
this.operations = [];
|
||||
this.categoryOptions = [];
|
||||
this.stats = emptyStats();
|
||||
this.loadError = error.message || 'Failed to load operations';
|
||||
}
|
||||
|
||||
setSearch(val) {
|
||||
this.search = val;
|
||||
this.page = 1;
|
||||
},
|
||||
this.loading = false;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setProtocol(val) {
|
||||
this.filterProtocol = this.filterProtocol === val ? null : val;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
_applyNonStatusFilters(operations) {
|
||||
var filtered = operations;
|
||||
|
||||
setCategory(val) {
|
||||
this.filterCategory = this.filterCategory === val ? null : val;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
if (this.search.trim()) {
|
||||
var query = this.search.toLowerCase();
|
||||
filtered = filtered.filter(function(operation) {
|
||||
return operation.name.toLowerCase().includes(query)
|
||||
|| operation.display_name.toLowerCase().includes(query)
|
||||
|| operation.category.toLowerCase().includes(query)
|
||||
|| (operation.target_url || '').toLowerCase().includes(query);
|
||||
});
|
||||
}
|
||||
|
||||
setSort(val) {
|
||||
this.sort = val;
|
||||
this.openDropdown = null;
|
||||
},
|
||||
if (this.filterProtocol) {
|
||||
filtered = filtered.filter(function(operation) { return operation.protocol === this.filterProtocol; }, this);
|
||||
}
|
||||
|
||||
setAgent(val) {
|
||||
this.filterAgent = this.filterAgent === val ? null : val;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
if (this.filterCategory) {
|
||||
filtered = filtered.filter(function(operation) { return operation.category === this.filterCategory; }, this);
|
||||
}
|
||||
|
||||
clearProtocol() { this.filterProtocol = null; this.openDropdown = null; this.page = 1; },
|
||||
clearCategory() { this.filterCategory = null; this.openDropdown = null; this.page = 1; },
|
||||
clearAgent() { this.filterAgent = null; this.openDropdown = null; this.page = 1; },
|
||||
if (this.filterAgent) {
|
||||
filtered = filtered.filter(function(operation) {
|
||||
return (operation.agent_refs || []).some(function(agent) { return agent.agent_id === this.filterAgent; }, this);
|
||||
}, this);
|
||||
}
|
||||
|
||||
toggleDropdown(name) {
|
||||
this.openDropdown = this.openDropdown === name ? null : name;
|
||||
},
|
||||
return filtered;
|
||||
},
|
||||
|
||||
goPage(p) {
|
||||
if (p >= 1 && p <= this.totalPages) this.page = p;
|
||||
},
|
||||
get filtered() {
|
||||
var operations = this._applyNonStatusFilters(this.operations);
|
||||
|
||||
// ── operation CRUD ──
|
||||
editOperation(op) {
|
||||
try { sessionStorage.setItem('wizard_edit', JSON.stringify(op)); } catch (e) {}
|
||||
window.location.href = (window.APP_BASE||'')+'html/wizard/?mode=edit';
|
||||
},
|
||||
if (this.tab !== 'all') {
|
||||
operations = operations.filter(function(operation) {
|
||||
return operation.status === this.tab;
|
||||
}, this);
|
||||
}
|
||||
|
||||
deleteOperation(id) {
|
||||
if (!confirm('Delete this operation? This cannot be undone.')) return;
|
||||
this.operations = this.operations.filter(op => op.id !== id);
|
||||
// Persist deletion in overrides as a tombstone
|
||||
try {
|
||||
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
||||
const filtered = overrides.filter(o => o.id !== id);
|
||||
filtered.push({ id, _deleted: true });
|
||||
localStorage.setItem('crank_ops_overrides', JSON.stringify(filtered));
|
||||
} catch (e) {}
|
||||
},
|
||||
operations = operations.slice().sort(function(left, right) {
|
||||
if (this.sort === 'created_desc') return new Date(right.created_at) - new Date(left.created_at);
|
||||
if (this.sort === 'created_asc') return new Date(left.created_at) - new Date(right.created_at);
|
||||
if (this.sort === 'name_asc') return left.name.localeCompare(right.name);
|
||||
if (this.sort === 'name_desc') return right.name.localeCompare(left.name);
|
||||
return 0;
|
||||
}.bind(this));
|
||||
|
||||
// ── helpers для шаблона ──
|
||||
protocolLabel(op) {
|
||||
if (op.protocol === 'rest') return `REST · ${op.method}`;
|
||||
if (op.protocol === 'graphql') return 'GraphQL';
|
||||
return 'gRPC';
|
||||
},
|
||||
return operations;
|
||||
},
|
||||
|
||||
protocolClass(op) {
|
||||
return `badge badge-${op.protocol}`;
|
||||
},
|
||||
get totalFiltered() {
|
||||
return this.filtered.length;
|
||||
},
|
||||
|
||||
statusClass(op) {
|
||||
return `status-badge status-${op.status}`;
|
||||
},
|
||||
get totalPages() {
|
||||
return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize));
|
||||
},
|
||||
|
||||
statusLabel(op) {
|
||||
return op.status.charAt(0).toUpperCase() + op.status.slice(1);
|
||||
},
|
||||
get paginated() {
|
||||
var start = (this.page - 1) * this.pageSize;
|
||||
return this.filtered.slice(start, start + this.pageSize);
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
},
|
||||
get pageStart() {
|
||||
return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
|
||||
},
|
||||
|
||||
truncateUrl(url) {
|
||||
if (!url) return '';
|
||||
return url.length > 42 ? url.slice(0, 42) + '…' : url;
|
||||
},
|
||||
get pageEnd() {
|
||||
return Math.min(this.page * this.pageSize, this.totalFiltered);
|
||||
},
|
||||
|
||||
get sortOptions() { return getSortOptions(); },
|
||||
get tabDefs() { return getTabDefs(); },
|
||||
tabCount(tab) {
|
||||
var operations = this._applyNonStatusFilters(this.operations);
|
||||
if (tab === 'all') return operations.length;
|
||||
return operations.filter(function(operation) { return operation.status === tab; }).length;
|
||||
},
|
||||
|
||||
handleNewOperation() {
|
||||
sessionStorage.removeItem('wizard_edit');
|
||||
window.location.href = (window.APP_BASE||'')+'html/wizard/';
|
||||
},
|
||||
get activeChips() {
|
||||
var chips = [];
|
||||
if (this.filterProtocol) chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
|
||||
if (this.filterCategory) chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
|
||||
if (this.filterAgent) {
|
||||
var agent = this.activeAgents.find(function(item) { return item.agent_id === this.filterAgent; }, this);
|
||||
chips.push({ key: 'agent', label: 'Agent: ' + (agent ? agent.display_name : '') });
|
||||
}
|
||||
return chips;
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
localStorage.removeItem('crank_user');
|
||||
window.location.href = (window.APP_BASE||'')+'html/login.html';
|
||||
},
|
||||
}));
|
||||
get hasActiveFilters() {
|
||||
return !!(this.filterProtocol || this.filterCategory || this.filterAgent || this.search.trim());
|
||||
},
|
||||
|
||||
clearChip(key) {
|
||||
if (key === 'protocol') this.filterProtocol = null;
|
||||
if (key === 'category') this.filterCategory = null;
|
||||
if (key === 'agent') this.filterAgent = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearAllFilters() {
|
||||
this.filterProtocol = null;
|
||||
this.filterCategory = null;
|
||||
this.filterAgent = null;
|
||||
this.search = '';
|
||||
this.tab = 'all';
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
get agentsByOpId() {
|
||||
var map = {};
|
||||
this.operations.forEach(function(operation) {
|
||||
map[operation.id] = operation.agent_refs || [];
|
||||
});
|
||||
return map;
|
||||
},
|
||||
|
||||
get activeAgents() {
|
||||
var seen = {};
|
||||
var agents = [];
|
||||
this.operations.forEach(function(operation) {
|
||||
(operation.agent_refs || []).forEach(function(agent) {
|
||||
if (!seen[agent.agent_id]) {
|
||||
seen[agent.agent_id] = true;
|
||||
agents.push(agent);
|
||||
}
|
||||
});
|
||||
});
|
||||
return agents.sort(function(left, right) {
|
||||
return left.display_name.localeCompare(right.display_name);
|
||||
});
|
||||
},
|
||||
|
||||
get sortLabel() {
|
||||
return getSortOptions().find(function(option) { return option.value === this.sort; }, this)?.label || 'Sort';
|
||||
},
|
||||
|
||||
setTab(tab) {
|
||||
this.tab = tab;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setSearch(value) {
|
||||
this.search = value;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setProtocol(value) {
|
||||
this.filterProtocol = this.filterProtocol === value ? null : value;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setCategory(value) {
|
||||
this.filterCategory = this.filterCategory === value ? null : value;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
setSort(value) {
|
||||
this.sort = value;
|
||||
this.openDropdown = null;
|
||||
},
|
||||
|
||||
setAgent(value) {
|
||||
this.filterAgent = this.filterAgent === value ? null : value;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearProtocol() {
|
||||
this.filterProtocol = null;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearCategory() {
|
||||
this.filterCategory = null;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
clearAgent() {
|
||||
this.filterAgent = null;
|
||||
this.openDropdown = null;
|
||||
this.page = 1;
|
||||
},
|
||||
|
||||
toggleDropdown(name) {
|
||||
this.openDropdown = this.openDropdown === name ? null : name;
|
||||
},
|
||||
|
||||
goPage(page) {
|
||||
if (page >= 1 && page <= this.totalPages) this.page = page;
|
||||
},
|
||||
|
||||
editOperation(operation) {
|
||||
window.location.href = (window.APP_BASE || '') + 'html/wizard/?mode=edit&operationId=' + encodeURIComponent(operation.id);
|
||||
},
|
||||
|
||||
async deleteOperation(id) {
|
||||
if (!confirm('Delete this operation? This cannot be undone.')) return;
|
||||
try {
|
||||
await window.CrankApi.deleteOperation(this.workspaceId, id);
|
||||
this.operations = this.operations.filter(function(operation) { return operation.id !== id; });
|
||||
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
|
||||
return operation.category;
|
||||
}).filter(Boolean))).sort();
|
||||
this.stats = computeStats(this.operations);
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to delete operation');
|
||||
}
|
||||
},
|
||||
|
||||
protocolLabel(operation) {
|
||||
if (operation.protocol === 'rest') {
|
||||
return operation.method ? ('REST · ' + operation.method) : 'REST';
|
||||
}
|
||||
if (operation.protocol === 'graphql') return 'GraphQL';
|
||||
return 'gRPC';
|
||||
},
|
||||
|
||||
protocolClass(operation) {
|
||||
return 'badge badge-' + operation.protocol;
|
||||
},
|
||||
|
||||
statusClass(operation) {
|
||||
return 'status-badge status-' + operation.status;
|
||||
},
|
||||
|
||||
statusLabel(operation) {
|
||||
if (operation.status === 'active') return 'Active';
|
||||
if (operation.status === 'inactive') return 'Inactive';
|
||||
if (operation.status === 'error') return 'Error';
|
||||
return operation.status.charAt(0).toUpperCase() + operation.status.slice(1);
|
||||
},
|
||||
|
||||
formatDate(dateStr) {
|
||||
if (!dateStr) return '—';
|
||||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
},
|
||||
|
||||
truncateUrl(url) {
|
||||
if (!url) return '—';
|
||||
return url.length > 42 ? url.slice(0, 42) + '…' : url;
|
||||
},
|
||||
|
||||
formatMetricNumber(value) {
|
||||
return new Intl.NumberFormat('en-US').format(value || 0);
|
||||
},
|
||||
|
||||
get sortOptions() {
|
||||
return getSortOptions();
|
||||
},
|
||||
|
||||
get tabDefs() {
|
||||
return getTabDefs();
|
||||
},
|
||||
|
||||
handleNewOperation() {
|
||||
window.location.href = (window.APP_BASE || '') + 'html/wizard/';
|
||||
},
|
||||
|
||||
handleLogout() {
|
||||
localStorage.removeItem('crank_user');
|
||||
window.location.href = (window.APP_BASE || '') + 'html/login.html';
|
||||
},
|
||||
};
|
||||
});
|
||||
});
|
||||
|
||||
+574
-189
@@ -3,6 +3,10 @@ var TOTAL_STEPS = 5;
|
||||
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc'
|
||||
var wizardMode = 'create'; // 'create' | 'edit'
|
||||
var wizardEditId = null;
|
||||
var wizardWorkspaceId = null;
|
||||
var wizardCurrentOperation = null;
|
||||
var wizardCurrentVersion = null;
|
||||
var wizardProtoUpload = null;
|
||||
|
||||
/* ── Dynamic step loading ── */
|
||||
function _stepFile(n) {
|
||||
@@ -115,9 +119,54 @@ function _doGoToStep(n) {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
function loadWizardPanels(steps) {
|
||||
return steps.reduce(function(chain, step) {
|
||||
return chain.then(function() {
|
||||
return new Promise(function(resolve) {
|
||||
loadStep(step, resolve);
|
||||
});
|
||||
});
|
||||
}, Promise.resolve());
|
||||
}
|
||||
|
||||
// Navigation buttons
|
||||
function bindProtocolCards() {
|
||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||
card.addEventListener('click', function() {
|
||||
document.querySelectorAll('.protocol-card').forEach(function(item) {
|
||||
item.classList.remove('selected');
|
||||
item.setAttribute('aria-checked', 'false');
|
||||
});
|
||||
card.classList.add('selected');
|
||||
card.setAttribute('aria-checked', 'true');
|
||||
|
||||
var proto = card.dataset.protocol
|
||||
|| (card.classList.contains('rest')
|
||||
? 'rest'
|
||||
: card.classList.contains('graphql')
|
||||
? 'graphql'
|
||||
: card.classList.contains('grpc')
|
||||
? 'grpc'
|
||||
: 'rest');
|
||||
|
||||
wizardProtocol = proto;
|
||||
var s3name = document.getElementById('sidebar-step-3-name');
|
||||
if (s3name) s3name.textContent = STEP3_LABELS[proto] || 'Protocol config';
|
||||
|
||||
if (proto === 'graphql') {
|
||||
var pathInput = document.getElementById('endpoint-path');
|
||||
if (pathInput && pathInput.value === '/v1/leads') pathInput.value = '/graphql';
|
||||
}
|
||||
});
|
||||
card.addEventListener('keydown', function(event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
card.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', async function() {
|
||||
document.querySelector('.btn-continue').addEventListener('click', function() {
|
||||
if (currentStep < TOTAL_STEPS) {
|
||||
goToStep(currentStep + 1);
|
||||
@@ -130,12 +179,10 @@ document.addEventListener('DOMContentLoaded', 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() {
|
||||
@@ -150,98 +197,29 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
}
|
||||
|
||||
// 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);
|
||||
saveOperation(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Detect edit mode from URL param and sessionStorage
|
||||
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||
wizardWorkspaceId = workspace ? workspace.id : null;
|
||||
|
||||
await loadWizardPanels([1, 2, 3, 4, 5]);
|
||||
bindProtocolCards();
|
||||
|
||||
var params = new URLSearchParams(window.location.search);
|
||||
if (params.get('mode') === 'edit') {
|
||||
if (params.get('mode') === 'edit' && params.get('operationId')) {
|
||||
wizardMode = 'edit';
|
||||
wizardEditId = params.get('operationId');
|
||||
document.title = 'Crank — Edit Operation';
|
||||
try {
|
||||
var editData = JSON.parse(sessionStorage.getItem('wizard_edit') || 'null');
|
||||
if (editData) {
|
||||
wizardEditId = editData.id;
|
||||
prefillWizardFromEdit(editData);
|
||||
}
|
||||
} catch (e) {}
|
||||
await loadOperationForEdit();
|
||||
}
|
||||
|
||||
// Init: load step 1 fragment first, then render
|
||||
loadStep(1, function() { _doGoToStep(1); });
|
||||
|
||||
_doGoToStep(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -723,6 +701,7 @@ function handleProtoFile(event) {
|
||||
if (!file) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
wizardProtoUpload = { fileName: file.name, content: e.target.result };
|
||||
showProtoFileInfo(file.name, file.size);
|
||||
var parsed = parseProto(e.target.result);
|
||||
renderParsedProto(parsed);
|
||||
@@ -738,6 +717,7 @@ function handleProtoDrop(event) {
|
||||
if (!file || !file.name.endsWith('.proto')) return;
|
||||
var reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
wizardProtoUpload = { fileName: file.name, content: e.target.result };
|
||||
showProtoFileInfo(file.name, file.size);
|
||||
renderParsedProto(parseProto(e.target.result));
|
||||
};
|
||||
@@ -769,6 +749,7 @@ function resetProto() {
|
||||
if (fileInput) fileInput.value = '';
|
||||
protoParsed = null;
|
||||
selectedRpcMethod = null;
|
||||
wizardProtoUpload = null;
|
||||
}
|
||||
|
||||
function toggleProtoPaste() {
|
||||
@@ -795,6 +776,7 @@ function parseProtoPasted() {
|
||||
var ta = document.getElementById('proto-paste-input');
|
||||
if (!ta || !ta.value.trim()) return;
|
||||
var parsed = parseProto(ta.value);
|
||||
wizardProtoUpload = { fileName: 'pasted.proto', content: ta.value };
|
||||
document.getElementById('proto-paste-area').style.display = 'none';
|
||||
var dropzone = document.getElementById('proto-dropzone');
|
||||
if (dropzone) dropzone.style.display = 'none';
|
||||
@@ -909,133 +891,536 @@ function grpcManualTypeInput() {
|
||||
}
|
||||
|
||||
|
||||
/* ══════════════════════════════════════════════
|
||||
Wizard edit mode — collect, save, prefill
|
||||
══════════════════════════════════════════════ */
|
||||
function textValue(id) {
|
||||
var element = document.getElementById(id);
|
||||
return element ? element.value.trim() : '';
|
||||
}
|
||||
|
||||
function collectWizardData() {
|
||||
var protocol = wizardProtocol;
|
||||
function setValue(id, value) {
|
||||
var element = document.getElementById(id);
|
||||
if (element) element.value = value || '';
|
||||
}
|
||||
|
||||
// Upstream base URL
|
||||
var upstream = upstreams.find(function(u) { return u.id === selectedUpstreamId; });
|
||||
var baseUrl = upstream ? upstream.url : '';
|
||||
function parseStructuredText(text) {
|
||||
if (!text.trim()) return {};
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch (_error) {
|
||||
if (!window.jsyaml) throw new Error('YAML parser is not available');
|
||||
return window.jsyaml.load(text) || {};
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
function inferSchemaKind(typeName) {
|
||||
if (typeName === 'object') return 'object';
|
||||
if (typeName === 'array') return 'array';
|
||||
if (typeName === 'string') return 'string';
|
||||
if (typeName === 'integer') return 'integer';
|
||||
if (typeName === 'number') return 'number';
|
||||
if (typeName === 'boolean') return 'boolean';
|
||||
if (typeName === 'null') return 'null';
|
||||
return 'string';
|
||||
}
|
||||
|
||||
function convertJsonSchemaToCrankSchema(node, requiredNames) {
|
||||
var schemaNode = node || {};
|
||||
var typeName = Array.isArray(schemaNode.type)
|
||||
? schemaNode.type.find(function(value) { return value !== 'null'; }) || 'string'
|
||||
: schemaNode.type || (schemaNode.properties ? 'object' : 'string');
|
||||
var nullable = Array.isArray(schemaNode.type) && schemaNode.type.indexOf('null') >= 0;
|
||||
var kind = schemaNode.enum ? 'enum' : inferSchemaKind(typeName);
|
||||
var schema = {
|
||||
type: kind,
|
||||
description: schemaNode.description || null,
|
||||
required: false,
|
||||
nullable: nullable,
|
||||
default_value: Object.prototype.hasOwnProperty.call(schemaNode, 'default') ? schemaNode.default : null,
|
||||
fields: {},
|
||||
items: null,
|
||||
enum_values: schemaNode.enum || [],
|
||||
variants: [],
|
||||
};
|
||||
|
||||
if (kind === 'object') {
|
||||
var requiredSet = new Set(schemaNode.required || requiredNames || []);
|
||||
Object.keys(schemaNode.properties || {}).forEach(function(fieldName) {
|
||||
var field = convertJsonSchemaToCrankSchema(schemaNode.properties[fieldName], []);
|
||||
field.required = requiredSet.has(fieldName);
|
||||
schema.fields[fieldName] = field;
|
||||
});
|
||||
}
|
||||
|
||||
var val = function(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; };
|
||||
if (kind === 'array' && schemaNode.items) {
|
||||
schema.items = convertJsonSchemaToCrankSchema(schemaNode.items, []);
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
function mappingRootForProtocol(protocol) {
|
||||
if (protocol === 'graphql') return '$.request.variables';
|
||||
if (protocol === 'grpc') return '$.request.grpc';
|
||||
var activeMethod = document.querySelector('.method-card.active');
|
||||
var method = activeMethod ? activeMethod.dataset.method : 'POST';
|
||||
return method === 'GET' || method === 'DELETE' ? '$.request.query' : '$.request.body';
|
||||
}
|
||||
|
||||
function normalizeInputSource(path) {
|
||||
if (typeof path !== 'string') return '$.mcp';
|
||||
if (path.indexOf('$.input.') === 0) return '$.mcp.' + path.slice('$.input.'.length);
|
||||
if (path === '$.input') return '$.mcp';
|
||||
return path;
|
||||
}
|
||||
|
||||
function normalizeOutputSource(path) {
|
||||
if (typeof path !== 'string') return '$.response.data';
|
||||
return path;
|
||||
}
|
||||
|
||||
function buildMappingSet(rawValue, mode) {
|
||||
if (rawValue && Array.isArray(rawValue.rules)) {
|
||||
return { rules: rawValue.rules };
|
||||
}
|
||||
|
||||
var rules = [];
|
||||
if (!rawValue || typeof rawValue !== 'object') {
|
||||
return { rules: rules };
|
||||
}
|
||||
|
||||
var root = mappingRootForProtocol(wizardProtocol);
|
||||
|
||||
Object.keys(rawValue).forEach(function(key) {
|
||||
var source = rawValue[key];
|
||||
if (typeof source !== 'string') return;
|
||||
if (mode === 'input') {
|
||||
rules.push({
|
||||
source: normalizeInputSource(source),
|
||||
target: root + '.' + key,
|
||||
required: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
rules.push({
|
||||
source: normalizeOutputSource(source),
|
||||
target: '$.output.' + key,
|
||||
required: false,
|
||||
});
|
||||
});
|
||||
|
||||
return { rules: rules };
|
||||
}
|
||||
|
||||
function parseExecutionConfig(text) {
|
||||
var value = parseStructuredText(text);
|
||||
var retry = value.retry || value.retry_policy || null;
|
||||
var headers = value.headers && typeof value.headers === 'object' ? value.headers : {};
|
||||
var config = {
|
||||
timeout_ms: Number(value.timeout_ms || 10000),
|
||||
retry_policy: retry && retry.max_attempts ? { max_attempts: Number(retry.max_attempts) } : null,
|
||||
auth_profile_ref: value.auth && value.auth.profile ? value.auth.profile : null,
|
||||
headers: headers,
|
||||
protocol_options: null,
|
||||
};
|
||||
|
||||
if (wizardProtocol === 'grpc') {
|
||||
var useTls = false;
|
||||
if (value.protocol_options && value.protocol_options.grpc) {
|
||||
useTls = !!value.protocol_options.grpc.use_tls;
|
||||
} else if (value.tls) {
|
||||
useTls = !!value.tls.verify;
|
||||
}
|
||||
config.protocol_options = { grpc: { use_tls: useTls } };
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
function currentUpstream() {
|
||||
var selected = upstreams.find(function(item) { return item.id === selectedUpstreamId; });
|
||||
if (selected) return selected;
|
||||
|
||||
var customName = textValue('new-upstream-name');
|
||||
var customUrl = textValue('new-upstream-url');
|
||||
if (!customUrl) return null;
|
||||
|
||||
var authHeaders = textValue('new-upstream-auth-headers');
|
||||
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'),
|
||||
id: 'custom',
|
||||
name: customName || 'custom-upstream',
|
||||
url: customUrl,
|
||||
authHeaders: authHeaders,
|
||||
};
|
||||
}
|
||||
|
||||
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 joinUrl(baseUrl, path) {
|
||||
if (!baseUrl) return path || '';
|
||||
if (!path) return baseUrl;
|
||||
return baseUrl.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
|
||||
}
|
||||
|
||||
function prefillWizardFromEdit(op) {
|
||||
if (!op) return;
|
||||
function selectedGraphqlOperationType() {
|
||||
var active = document.querySelector('.gql-type-card.active');
|
||||
return active ? active.dataset.gqlType : 'query';
|
||||
}
|
||||
|
||||
// Update wizard header labels for edit mode
|
||||
function graphqlTopLevelField(queryText) {
|
||||
var match = queryText.replace(/#[^\n]*/g, '').match(/\{\s*([A-Za-z_][A-Za-z0-9_]*)/);
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
function graphqlOperationName(queryText) {
|
||||
var match = queryText.match(/\b(query|mutation)\s+([A-Za-z_][A-Za-z0-9_]*)/);
|
||||
return match ? match[2] : textValue('tool-name');
|
||||
}
|
||||
|
||||
function buildTarget() {
|
||||
var upstream = currentUpstream();
|
||||
if (!upstream || !upstream.url) {
|
||||
throw new Error('Select or register an upstream first');
|
||||
}
|
||||
|
||||
var pathTemplate = textValue('endpoint-path') || '/';
|
||||
|
||||
if (wizardProtocol === 'rest') {
|
||||
var activeMethod = document.querySelector('.method-card.active');
|
||||
return {
|
||||
kind: 'rest',
|
||||
base_url: upstream.url,
|
||||
method: activeMethod ? activeMethod.dataset.method : 'POST',
|
||||
path_template: pathTemplate,
|
||||
static_headers: parseHeaderMap(upstream.authHeaders),
|
||||
};
|
||||
}
|
||||
|
||||
if (wizardProtocol === 'graphql') {
|
||||
var queryTemplate = textValue('gql-query-editor');
|
||||
var topField = graphqlTopLevelField(queryTemplate);
|
||||
return {
|
||||
kind: 'graphql',
|
||||
endpoint: joinUrl(upstream.url, pathTemplate),
|
||||
operation_type: selectedGraphqlOperationType(),
|
||||
operation_name: graphqlOperationName(queryTemplate),
|
||||
query_template: queryTemplate,
|
||||
response_path: topField ? '$.response.body.data.' + topField : '$.response.body.data',
|
||||
};
|
||||
}
|
||||
|
||||
var route = textValue('grpc-manual-route') || (document.getElementById('grpc-path-value') ? document.getElementById('grpc-path-value').textContent.trim() : '');
|
||||
var descriptorRef = wizardCurrentVersion && wizardCurrentVersion.target && wizardCurrentVersion.target.kind === 'grpc'
|
||||
? wizardCurrentVersion.target.descriptor_ref
|
||||
: 'desc_pending';
|
||||
var descriptorSetB64 = wizardCurrentVersion && wizardCurrentVersion.target && wizardCurrentVersion.target.kind === 'grpc'
|
||||
? (wizardCurrentVersion.target.descriptor_set_b64 || '')
|
||||
: '';
|
||||
var parsedRoute = parseGrpcRoute(route);
|
||||
|
||||
return {
|
||||
kind: 'grpc',
|
||||
server_addr: upstream.url,
|
||||
package: parsedRoute.package,
|
||||
service: parsedRoute.service,
|
||||
method: parsedRoute.method,
|
||||
descriptor_ref: descriptorRef,
|
||||
descriptor_set_b64: descriptorSetB64,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGrpcRoute(route) {
|
||||
var normalized = route.replace(/^\/+/, '');
|
||||
var parts = normalized.split('/');
|
||||
if (parts.length !== 2) {
|
||||
return {
|
||||
package: '',
|
||||
service: '',
|
||||
method: '',
|
||||
};
|
||||
}
|
||||
|
||||
var servicePart = parts[0];
|
||||
var method = parts[1];
|
||||
var serviceSegments = servicePart.split('.');
|
||||
var service = serviceSegments.pop() || '';
|
||||
var packageName = serviceSegments.join('.');
|
||||
|
||||
return {
|
||||
package: packageName,
|
||||
service: service,
|
||||
method: method,
|
||||
};
|
||||
}
|
||||
|
||||
function parseHeaderMap(text) {
|
||||
if (!text || !text.trim()) return {};
|
||||
var value = parseStructuredText(text);
|
||||
return value && typeof value === 'object' ? value : {};
|
||||
}
|
||||
|
||||
function buildToolDescription() {
|
||||
return {
|
||||
title: textValue('tool-title') || textValue('tool-display-name') || textValue('tool-name'),
|
||||
description: textValue('tool-description'),
|
||||
tags: [],
|
||||
examples: [],
|
||||
};
|
||||
}
|
||||
|
||||
function collectWizardPayload() {
|
||||
var name = textValue('tool-name');
|
||||
if (!name) throw new Error('Tool name is required');
|
||||
|
||||
var inputSchemaValue = parseStructuredText(textValue('tool-input-schema'));
|
||||
var outputSchemaValue = parseStructuredText(textValue('tool-output-schema'));
|
||||
var inputMappingValue = parseStructuredText(textValue('tool-input-mapping'));
|
||||
var outputMappingValue = parseStructuredText(textValue('tool-output-mapping'));
|
||||
|
||||
return {
|
||||
name: name,
|
||||
display_name: textValue('tool-display-name') || name,
|
||||
category: 'general',
|
||||
protocol: wizardProtocol,
|
||||
target: buildTarget(),
|
||||
input_schema: convertJsonSchemaToCrankSchema(inputSchemaValue, []),
|
||||
output_schema: convertJsonSchemaToCrankSchema(outputSchemaValue, []),
|
||||
input_mapping: buildMappingSet(inputMappingValue, 'input'),
|
||||
output_mapping: buildMappingSet(outputMappingValue, 'output'),
|
||||
execution_config: parseExecutionConfig(textValue('tool-exec-config')),
|
||||
tool_description: buildToolDescription(),
|
||||
};
|
||||
}
|
||||
|
||||
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';
|
||||
} catch (error) {
|
||||
alert(error.message || 'Failed to save operation');
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOperationForEdit() {
|
||||
if (!wizardWorkspaceId || !wizardEditId) return;
|
||||
var detail = await window.CrankApi.getOperation(wizardWorkspaceId, wizardEditId);
|
||||
wizardProtocol = detail.protocol || 'rest';
|
||||
await loadWizardPanels([3]);
|
||||
var draftVersion = await window.CrankApi.getOperationVersion(
|
||||
wizardWorkspaceId,
|
||||
wizardEditId,
|
||||
detail.draft_version_ref.version
|
||||
);
|
||||
wizardCurrentOperation = detail;
|
||||
wizardCurrentVersion = draftVersion;
|
||||
prefillWizardFromEdit(detail, draftVersion);
|
||||
}
|
||||
|
||||
function setEditModePresentation() {
|
||||
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';
|
||||
function selectProtocol(protocol) {
|
||||
wizardProtocol = 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 proto = card.dataset.protocol
|
||||
|| (card.classList.contains('rest') ? 'rest' : card.classList.contains('graphql') ? 'graphql' : 'grpc');
|
||||
var selected = proto === wizardProtocol;
|
||||
card.classList.toggle('selected', selected);
|
||||
card.setAttribute('aria-checked', selected ? '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;
|
||||
function prefillUpstream(baseUrl, headers) {
|
||||
var known = upstreams.find(function(item) { return item.url === baseUrl; });
|
||||
if (known) {
|
||||
pickUpstream(known.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// 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);
|
||||
selectedUpstreamId = null;
|
||||
var trigger = document.getElementById('upstream-new-trigger');
|
||||
var form = document.getElementById('upstream-new-form');
|
||||
if (trigger) trigger.classList.add('active');
|
||||
if (form) form.style.display = '';
|
||||
setValue('new-upstream-name', 'imported-upstream');
|
||||
setValue('new-upstream-url', baseUrl);
|
||||
setValue('new-upstream-auth-headers', headers && Object.keys(headers).length ? JSON.stringify(headers, null, 2) : '{\n}');
|
||||
|
||||
var valueEl = document.getElementById('upstream-combobox-value');
|
||||
if (valueEl) {
|
||||
valueEl.innerHTML =
|
||||
'<div class="upstream-trigger-name">imported-upstream</div>' +
|
||||
'<div class="upstream-trigger-url">' + escapeHtml(baseUrl) + '</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function crankSchemaToJsonSchema(schema) {
|
||||
var result = {};
|
||||
var typeName = schema.type === 'enum' ? 'string' : schema.type;
|
||||
result.type = schema.nullable ? [typeName, 'null'] : typeName;
|
||||
if (schema.description) result.description = schema.description;
|
||||
if (schema.default_value !== null && schema.default_value !== undefined) result.default = schema.default_value;
|
||||
if (schema.enum_values && schema.enum_values.length) result.enum = schema.enum_values.slice();
|
||||
if (schema.type === 'object') {
|
||||
result.type = schema.nullable ? ['object', 'null'] : 'object';
|
||||
result.properties = {};
|
||||
var required = [];
|
||||
Object.keys(schema.fields || {}).forEach(function(fieldName) {
|
||||
var field = schema.fields[fieldName];
|
||||
result.properties[fieldName] = crankSchemaToJsonSchema(field);
|
||||
if (field.required) required.push(fieldName);
|
||||
});
|
||||
if (required.length) result.required = required;
|
||||
result.additionalProperties = false;
|
||||
}
|
||||
if (schema.type === 'array' && schema.items) {
|
||||
result.items = crankSchemaToJsonSchema(schema.items);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function mappingSetToEditorValue(mappingSet, mode, protocol) {
|
||||
var rules = mappingSet && mappingSet.rules ? mappingSet.rules : [];
|
||||
var object = {};
|
||||
rules.forEach(function(rule) {
|
||||
if (mode === 'input') {
|
||||
var key = rule.target.replace(mappingRootForProtocol(protocol) + '.', '');
|
||||
object[key] = rule.source.replace('$.mcp.', '$.input.');
|
||||
return;
|
||||
}
|
||||
var outputKey = rule.target.replace('$.output.', '');
|
||||
object[outputKey] = rule.source;
|
||||
});
|
||||
return window.jsyaml ? window.jsyaml.dump(object, { lineWidth: -1 }) : JSON.stringify(object, null, 2);
|
||||
}
|
||||
|
||||
function executionConfigToEditorValue(config) {
|
||||
var value = {
|
||||
timeout_ms: config.timeout_ms,
|
||||
};
|
||||
if (config.retry_policy) {
|
||||
value.retry = { max_attempts: config.retry_policy.max_attempts };
|
||||
}
|
||||
if (config.auth_profile_ref) {
|
||||
value.auth = { profile: config.auth_profile_ref };
|
||||
}
|
||||
if (config.headers && Object.keys(config.headers).length) {
|
||||
value.headers = config.headers;
|
||||
}
|
||||
if (config.protocol_options && config.protocol_options.grpc) {
|
||||
value.tls = { verify: !!config.protocol_options.grpc.use_tls };
|
||||
}
|
||||
return window.jsyaml ? window.jsyaml.dump(value, { lineWidth: -1 }) : JSON.stringify(value, null, 2);
|
||||
}
|
||||
|
||||
function prefillWizardFromEdit(detail, versionDocument) {
|
||||
if (!detail || !versionDocument) return;
|
||||
|
||||
setEditModePresentation();
|
||||
selectProtocol(detail.protocol);
|
||||
|
||||
var target = versionDocument.target || {};
|
||||
if (target.kind === 'rest') {
|
||||
prefillUpstream(target.base_url, target.static_headers);
|
||||
setValue('endpoint-path', target.path_template);
|
||||
document.querySelectorAll('.method-card').forEach(function(card) {
|
||||
card.classList.toggle('active', card.dataset.method === target.method);
|
||||
});
|
||||
} else if (target.kind === 'graphql') {
|
||||
var endpoint = target.endpoint || '';
|
||||
var parsedEndpoint = new URL(endpoint, window.location.origin);
|
||||
prefillUpstream(parsedEndpoint.origin, {});
|
||||
setValue('endpoint-path', parsedEndpoint.pathname + parsedEndpoint.search);
|
||||
setValue('gql-query-editor', target.query_template);
|
||||
document.querySelectorAll('.gql-type-card').forEach(function(card) {
|
||||
card.classList.toggle('active', card.dataset.gqlType === target.operation_type);
|
||||
});
|
||||
} else if (target.kind === 'grpc') {
|
||||
prefillUpstream(target.server_addr, {});
|
||||
var route = '/' + (target.package ? target.package + '.' : '') + target.service + '/' + target.method;
|
||||
setValue('grpc-manual-route', route);
|
||||
setValue('grpc-manual-req-type', '');
|
||||
setValue('grpc-manual-res-type', '');
|
||||
grpcManualRouteInput(route);
|
||||
grpcManualTypeInput();
|
||||
}
|
||||
|
||||
setValue('tool-name', detail.name);
|
||||
setValue('tool-display-name', detail.display_name);
|
||||
setValue('tool-title', versionDocument.tool_description ? versionDocument.tool_description.title : detail.display_name);
|
||||
setValue('tool-description', versionDocument.tool_description ? versionDocument.tool_description.description : '');
|
||||
setValue('tool-input-schema', JSON.stringify(crankSchemaToJsonSchema(versionDocument.input_schema), null, 2));
|
||||
setValue('tool-output-schema', JSON.stringify(crankSchemaToJsonSchema(versionDocument.output_schema), null, 2));
|
||||
setValue('tool-input-mapping', mappingSetToEditorValue(versionDocument.input_mapping, 'input', detail.protocol));
|
||||
setValue('tool-output-mapping', mappingSetToEditorValue(versionDocument.output_mapping, 'output', detail.protocol));
|
||||
setValue('tool-exec-config', executionConfigToEditorValue(versionDocument.execution_config));
|
||||
|
||||
var toolNameInput = document.getElementById('tool-name');
|
||||
if (toolNameInput) toolNameInput.disabled = true;
|
||||
}
|
||||
|
||||
+114
-30
@@ -1,44 +1,121 @@
|
||||
/* ═══════════════════════════════════════════════════
|
||||
Crank — workspace switcher
|
||||
═══════════════════════════════════════════════════ */
|
||||
|
||||
var WS_LIST = [
|
||||
{ slug: 'acme-workspace', name: 'acme-workspace', role: 'Owner', letter: 'A', color: '#0d9488' },
|
||||
{ slug: 'startup-demo', name: 'startup-demo', role: 'Developer', letter: 'S', color: '#7c3aed' },
|
||||
{ slug: 'personal', name: 'personal', role: 'Owner', letter: 'P', color: '#0891b2' },
|
||||
{ id: 'ws_default', slug: 'default', name: 'Default workspace', role: 'Owner', letter: 'D', color: '#0d9488' }
|
||||
];
|
||||
|
||||
function getCurrentWs() {
|
||||
var slug = localStorage.getItem('crank_workspace') || 'acme-workspace';
|
||||
return WS_LIST.find(function(w) { return w.slug === slug; }) || WS_LIST[0];
|
||||
var workspaceLoadPromise = null;
|
||||
var workspaceLoadFailed = false;
|
||||
|
||||
function workspaceColor(index) {
|
||||
return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5];
|
||||
}
|
||||
|
||||
function initWorkspaceSwitcher() {
|
||||
var ws = getCurrentWs();
|
||||
function mapWorkspace(record, index) {
|
||||
var workspace = record && record.workspace ? record.workspace : record;
|
||||
var displayName = workspace.display_name || workspace.slug || workspace.id;
|
||||
return {
|
||||
id: workspace.id,
|
||||
slug: workspace.slug,
|
||||
name: displayName,
|
||||
role: 'Owner',
|
||||
letter: displayName.charAt(0).toUpperCase(),
|
||||
color: workspaceColor(index),
|
||||
status: workspace.status,
|
||||
};
|
||||
}
|
||||
|
||||
function selectedWorkspaceId() {
|
||||
return localStorage.getItem('crank_workspace_id');
|
||||
}
|
||||
|
||||
function selectedWorkspaceSlug() {
|
||||
return localStorage.getItem('crank_workspace_slug');
|
||||
}
|
||||
|
||||
function persistCurrentWorkspace(workspace) {
|
||||
if (!workspace) return;
|
||||
localStorage.setItem('crank_workspace_id', workspace.id);
|
||||
localStorage.setItem('crank_workspace_slug', workspace.slug);
|
||||
}
|
||||
|
||||
function getCurrentWs() {
|
||||
var workspaceId = selectedWorkspaceId();
|
||||
var workspaceSlug = selectedWorkspaceSlug();
|
||||
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; })
|
||||
|| WS_LIST.find(function(item) { return item.slug === workspaceSlug; })
|
||||
|| WS_LIST[0];
|
||||
|
||||
if (workspace) {
|
||||
persistCurrentWorkspace(workspace);
|
||||
}
|
||||
|
||||
return workspace;
|
||||
}
|
||||
|
||||
function updateWorkspaceHeader(workspace) {
|
||||
var nameEl = document.getElementById('ws-current-name');
|
||||
var dotEl = document.getElementById('ws-dot');
|
||||
if (nameEl) nameEl.textContent = ws.name;
|
||||
if (dotEl) { dotEl.textContent = ws.letter; dotEl.style.background = ws.color; }
|
||||
var dotEl = document.getElementById('ws-dot');
|
||||
if (nameEl) nameEl.textContent = workspace ? workspace.name : 'No workspace';
|
||||
if (dotEl && workspace) {
|
||||
dotEl.textContent = workspace.letter;
|
||||
dotEl.style.background = workspace.color;
|
||||
}
|
||||
}
|
||||
|
||||
function renderWorkspaceList() {
|
||||
var current = getCurrentWs();
|
||||
updateWorkspaceHeader(current);
|
||||
|
||||
var list = document.getElementById('ws-dropdown-list');
|
||||
if (!list) return;
|
||||
|
||||
list.innerHTML = WS_LIST.map(function(w) {
|
||||
var active = w.slug === ws.slug;
|
||||
return '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + w.slug + '\')">' +
|
||||
'<div class="ws-item-dot" style="background:' + w.color + '">' + w.letter + '</div>' +
|
||||
list.innerHTML = WS_LIST.map(function(workspace) {
|
||||
var active = current && workspace.id === current.id;
|
||||
return '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + workspace.id + '\')">' +
|
||||
'<div class="ws-item-dot" style="background:' + workspace.color + '">' + workspace.letter + '</div>' +
|
||||
'<div class="ws-item-info">' +
|
||||
'<div class="ws-item-name">' + w.name + '</div>' +
|
||||
'<div class="ws-item-role">' + w.role + '</div>' +
|
||||
'<div class="ws-item-name">' + workspace.name + '</div>' +
|
||||
'<div class="ws-item-role">' + workspace.role + '</div>' +
|
||||
'</div>' +
|
||||
(active ? '<svg width="12" height="12"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>' : '') +
|
||||
(active ? '<svg width="12" height="12"><use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"/></svg>' : '') +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'<div class="ws-dropdown-divider"></div>' +
|
||||
'<a class="ws-dropdown-mgmt-link" href="' + (window.APP_BASE||'') + 'html/workspace-setup.html" onclick="var dd=document.getElementById(\'ws-dropdown\');if(dd)dd.style.display=\'none\'">' +
|
||||
'<svg width="14" height="14"><use href="' + (window.APP_BASE||'') + 'icons/general/settings.svg#icon"/></svg>' +
|
||||
'Workspace settings' +
|
||||
'</a>';
|
||||
'<div class="ws-dropdown-divider"></div>' +
|
||||
'<a class="ws-dropdown-mgmt-link" href="' + (window.APP_BASE || '') + 'html/workspace-setup.html" onclick="var dd=document.getElementById(\'ws-dropdown\');if(dd)dd.style.display=\'none\'">' +
|
||||
'<svg width="14" height="14"><use href="' + (window.APP_BASE || '') + 'icons/general/settings.svg#icon"/></svg>' +
|
||||
'Workspace settings' +
|
||||
'</a>';
|
||||
}
|
||||
|
||||
async function loadWorkspaces() {
|
||||
if (workspaceLoadPromise) return workspaceLoadPromise;
|
||||
|
||||
workspaceLoadPromise = (async function() {
|
||||
if (!window.CrankApi) {
|
||||
renderWorkspaceList();
|
||||
return WS_LIST;
|
||||
}
|
||||
|
||||
try {
|
||||
var response = await window.CrankApi.listWorkspaces();
|
||||
var items = (response && response.items ? response.items : []).map(mapWorkspace);
|
||||
if (items.length > 0) {
|
||||
WS_LIST = items;
|
||||
}
|
||||
workspaceLoadFailed = false;
|
||||
} catch (_error) {
|
||||
workspaceLoadFailed = true;
|
||||
}
|
||||
|
||||
renderWorkspaceList();
|
||||
return WS_LIST;
|
||||
}());
|
||||
|
||||
return workspaceLoadPromise;
|
||||
}
|
||||
|
||||
function initWorkspaceSwitcher() {
|
||||
renderWorkspaceList();
|
||||
loadWorkspaces();
|
||||
}
|
||||
|
||||
function toggleWsSwitcher(e) {
|
||||
@@ -47,13 +124,20 @@ function toggleWsSwitcher(e) {
|
||||
if (dd) dd.style.display = dd.style.display === 'none' ? '' : 'none';
|
||||
}
|
||||
|
||||
function switchWorkspace(slug) {
|
||||
localStorage.setItem('crank_workspace', slug);
|
||||
function switchWorkspace(workspaceId) {
|
||||
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; });
|
||||
if (!workspace) return;
|
||||
persistCurrentWorkspace(workspace);
|
||||
var dd = document.getElementById('ws-dropdown');
|
||||
if (dd) dd.style.display = 'none';
|
||||
initWorkspaceSwitcher();
|
||||
renderWorkspaceList();
|
||||
window.dispatchEvent(new CustomEvent('crank:workspacechange', { detail: workspace }));
|
||||
}
|
||||
|
||||
window.getCurrentWorkspace = getCurrentWs;
|
||||
window.whenWorkspacesReady = loadWorkspaces;
|
||||
window.hasWorkspaceApiFailure = function() { return workspaceLoadFailed; };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', initWorkspaceSwitcher);
|
||||
|
||||
document.addEventListener('click', function(e) {
|
||||
|
||||
@@ -72,7 +72,8 @@ UI-файлы:
|
||||
|
||||
- эту страницу можно интегрировать первой;
|
||||
- backend уже отдает server-side category, usage summary и agent refs;
|
||||
- дальше нужно убрать `localStorage`-состояние и посадить UI на реальные list/detail/mutation вызовы.
|
||||
- каталог уже может работать через реальные `list/delete/edit` вызовы;
|
||||
- следующий шаг здесь только в richer summary shape, если нужно вернуть `target_url` и server-side paging.
|
||||
|
||||
### 4.2. Wizard
|
||||
|
||||
@@ -127,7 +128,8 @@ UI-файлы:
|
||||
|
||||
- wizard почти готов для реального backend;
|
||||
- это основной экран второй очереди после catalog;
|
||||
- backend уже закрывает create/edit/archive/test/import/export, дальше нужен только аккуратный frontend adapter.
|
||||
- базовый create/edit draft flow уже можно посадить на live `create/get version/update` endpoints;
|
||||
- следующий разрыв здесь - test/publish/import/export wiring и полноценный gRPC descriptor-set lifecycle.
|
||||
|
||||
### 4.3. Agents
|
||||
|
||||
|
||||
Reference in New Issue
Block a user