feat: connect alpine operations and wizard to admin api

This commit is contained in:
a.tolmachev
2026-03-30 01:03:17 +03:00
parent 23ca2cda59
commit f45ace378a
9 changed files with 1213 additions and 520 deletions
+2
View File
@@ -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
+2
View File
@@ -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>
+1 -1
View File
@@ -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
View File
@@ -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>
+114
View File
@@ -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
View File
@@ -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 AZ' },
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name ZA' },
{ 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 AZ' },
{ value: 'name_desc', label: (window.t && t('sort.name_desc')) || 'Sort: Name ZA' },
];
}
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
View File
@@ -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
View File
@@ -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) {
+4 -2
View File
@@ -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