feat: connect alpine operations and wizard to admin api
This commit is contained in:
@@ -13,6 +13,8 @@ DoD:
|
|||||||
- legacy React/Vite артефакты удалены из `apps/ui`
|
- legacy React/Vite артефакты удалены из `apps/ui`
|
||||||
- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs`
|
- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs`
|
||||||
- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта
|
- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта
|
||||||
|
- `Operations` page читает live catalog из `admin-api` и удаляет операции через backend
|
||||||
|
- `Wizard` использует live create/get/update flow вместо `localStorage` и `sessionStorage`
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
|
|||||||
@@ -13,6 +13,8 @@
|
|||||||
<script src="../../js/config.js"></script>
|
<script src="../../js/config.js"></script>
|
||||||
<script src="../../js/i18n.js"></script>
|
<script src="../../js/i18n.js"></script>
|
||||||
<script src="../../js/workspace.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>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
|
|||||||
@@ -94,7 +94,7 @@
|
|||||||
<div class="code-dots"><span></span><span></span><span></span></div>
|
<div class="code-dots"><span></span><span></span><span></span></div>
|
||||||
<span class="code-toolbar-label">json / auth-headers</span>
|
<span class="code-toolbar-label">json / auth-headers</span>
|
||||||
</div>
|
</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}"
|
"Authorization": "${secrets.API_KEY}"
|
||||||
}</textarea>
|
}</textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+22
-16
@@ -13,6 +13,7 @@
|
|||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/i18n.js"></script>
|
<script src="js/i18n.js"></script>
|
||||||
<script src="js/workspace.js"></script>
|
<script src="js/workspace.js"></script>
|
||||||
|
<script src="js/api.js"></script>
|
||||||
</head>
|
</head>
|
||||||
<body x-data="catalog()">
|
<body x-data="catalog()">
|
||||||
|
|
||||||
@@ -118,23 +119,23 @@
|
|||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label" data-i18n="stats.total">Total operations</div>
|
<div class="stat-label" data-i18n="stats.total">Total operations</div>
|
||||||
<div class="stat-value" x-text="operations.length">—</div>
|
<div class="stat-value" x-text="formatMetricNumber(stats.total)">—</div>
|
||||||
<div class="stat-delta up">+3 this month</div>
|
<div class="stat-delta" x-text="stats.total ? 'Catalog synced with backend' : 'No operations yet'"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label" data-i18n="stats.active">Active</div>
|
<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-value" x-text="formatMetricNumber(stats.active)">—</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-delta" x-text="stats.total ? Math.round((stats.active / stats.total) * 100) + '% of total' : 'No published tools yet'">—</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
|
<div class="stat-label" data-i18n="stats.requests">Requests today</div>
|
||||||
<div class="stat-value">4,821</div>
|
<div class="stat-value" x-text="formatMetricNumber(stats.requestsToday)">—</div>
|
||||||
<div class="stat-delta up">+12% vs yesterday</div>
|
<div class="stat-delta" x-text="stats.requestsToday ? 'Summed from operation usage' : 'No traffic recorded today'"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-card">
|
<div class="stat-card">
|
||||||
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
|
<div class="stat-label" data-i18n="stats.latency">Avg. latency</div>
|
||||||
<div class="stat-value">187ms</div>
|
<div class="stat-value" x-text="stats.averageLatencyMs ? stats.averageLatencyMs + 'ms' : '—'">—</div>
|
||||||
<div class="stat-delta down">+23ms this week</div>
|
<div class="stat-delta" x-text="stats.averageLatencyMs ? 'Weighted by today\\'s calls' : 'Waiting for runtime samples'"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -233,15 +234,15 @@
|
|||||||
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
|
:class="{ 'has-value': filterAgent, open: openDropdown === 'agent' }"
|
||||||
@click="toggleDropdown('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>
|
<svg class="chevron" width="10" height="10"><use href="icons/general/chevron-down.svg#icon"/></svg>
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu" x-show="openDropdown === 'agent'" x-transition>
|
<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
|
<button
|
||||||
class="dropdown-item"
|
class="dropdown-item"
|
||||||
:class="{ selected: filterAgent === a.id }"
|
:class="{ selected: filterAgent === a.agent_id }"
|
||||||
@click="setAgent(a.id)"
|
@click="setAgent(a.agent_id)"
|
||||||
>
|
>
|
||||||
<span x-text="a.display_name"></span>
|
<span x-text="a.display_name"></span>
|
||||||
<svg class="check" width="12" height="12"><use href="icons/general/check.svg#icon"/></svg>
|
<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>
|
<p data-i18n="ops.empty.sub">Try adjusting your search or filters.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="empty-state" x-show="!loading && loadError">
|
||||||
|
<h3>Backend connection failed</h3>
|
||||||
|
<p x-text="loadError"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Table itself -->
|
<!-- Table itself -->
|
||||||
<table x-show="!loading && totalFiltered > 0">
|
<table x-show="!loading && totalFiltered > 0 && !loadError">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th class="th-sortable" :class="{ 'sort-active': sort.startsWith('name') }" @click="setSort(sort === 'name_asc' ? 'name_desc' : 'name_asc')">
|
<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-name" x-text="op.name"></div>
|
||||||
<div class="op-display" x-text="op.display_name"></div>
|
<div class="op-display" x-text="op.display_name"></div>
|
||||||
<div class="op-agent-badges" x-show="(agentsByOpId[op.id] || []).length > 0">
|
<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>
|
<span class="op-agent-badge" x-text="a.display_name"></span>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,7 +374,7 @@
|
|||||||
<td class="col-url">
|
<td class="col-url">
|
||||||
<div class="target-url-cell">
|
<div class="target-url-cell">
|
||||||
<span x-text="truncateUrl(op.target_url)" :title="op.target_url"></span>
|
<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>
|
<svg width="11" height="11"><use href="icons/general/external.svg#icon"/></svg>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@@ -389,7 +395,7 @@
|
|||||||
</table>
|
</table>
|
||||||
|
|
||||||
<!-- Footer / pagination -->
|
<!-- 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">
|
<span class="table-footer-info">
|
||||||
Showing <span x-text="pageStart"></span>–<span x-text="pageEnd"></span> of <span x-text="totalFiltered"></span> operations
|
Showing <span x-text="pageStart"></span>–<span x-text="pageEnd"></span> of <span x-text="totalFiltered"></span> operations
|
||||||
</span>
|
</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
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}());
|
||||||
+254
-156
@@ -21,18 +21,76 @@ function getTabDefs() {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
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', () => ({
|
function mapOperation(item) {
|
||||||
// ── raw data ──
|
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 || [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStats() {
|
||||||
|
return {
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
requestsToday: 0,
|
||||||
|
averageLatencyMs: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function computeStats(operations) {
|
||||||
|
if (!operations.length) return emptyStats();
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
return {
|
||||||
|
total: operations.length,
|
||||||
|
active: operations.filter(function(operation) { return operation.status === 'active'; }).length,
|
||||||
|
requestsToday: requestsToday,
|
||||||
|
averageLatencyMs: latencyWeight > 0 ? Math.round(weightedLatency / latencyWeight) : 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener('alpine:init', function() {
|
||||||
|
Alpine.data('catalog', function() {
|
||||||
|
return {
|
||||||
operations: [],
|
operations: [],
|
||||||
agents: [],
|
|
||||||
loading: true,
|
loading: true,
|
||||||
|
loadError: '',
|
||||||
// ── page size (responsive) ──
|
|
||||||
pageSize: PAGE_SIZE_DESKTOP,
|
pageSize: PAGE_SIZE_DESKTOP,
|
||||||
|
|
||||||
// ── filter state ──
|
|
||||||
search: '',
|
search: '',
|
||||||
tab: 'all',
|
tab: 'all',
|
||||||
filterProtocol: null,
|
filterProtocol: null,
|
||||||
@@ -40,143 +98,152 @@ document.addEventListener('alpine:init', () => {
|
|||||||
filterAgent: null,
|
filterAgent: null,
|
||||||
sort: 'created_desc',
|
sort: 'created_desc',
|
||||||
page: 1,
|
page: 1,
|
||||||
|
|
||||||
// ── dropdown open state ──
|
|
||||||
openDropdown: null,
|
openDropdown: null,
|
||||||
|
|
||||||
// ── mobile nav ──
|
|
||||||
navOpen: false,
|
navOpen: false,
|
||||||
|
|
||||||
// ── derived option lists (built after data loads) ──
|
|
||||||
categoryOptions: [],
|
categoryOptions: [],
|
||||||
|
workspaceId: null,
|
||||||
|
stats: emptyStats(),
|
||||||
|
|
||||||
async init() {
|
async init() {
|
||||||
const [opsRes, agentsRes] = await Promise.all([
|
var self = this;
|
||||||
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)
|
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
|
||||||
try {
|
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
|
||||||
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
this.workspaceId = workspace ? workspace.id : null;
|
||||||
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;
|
var updatePageSize = function() {
|
||||||
this.categoryOptions = [...new Set(this.operations.map(op => op.category))].filter(Boolean).sort();
|
self.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
|
||||||
this.loading = false;
|
self.page = 1;
|
||||||
|
|
||||||
// Responsive page size
|
|
||||||
const updatePageSize = () => {
|
|
||||||
this.pageSize = window.innerWidth <= MOBILE_BREAKPOINT ? PAGE_SIZE_MOBILE : PAGE_SIZE_DESKTOP;
|
|
||||||
this.page = 1;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
updatePageSize();
|
updatePageSize();
|
||||||
window.addEventListener('resize', updatePageSize);
|
window.addEventListener('resize', updatePageSize);
|
||||||
|
window.addEventListener('crank:langchange', function() {
|
||||||
// Re-render labels on language change
|
self.sort = self.sort;
|
||||||
window.addEventListener('crank:langchange', () => {
|
|
||||||
this.sort = this.sort;
|
|
||||||
applyLang();
|
applyLang();
|
||||||
});
|
});
|
||||||
|
window.addEventListener('crank:workspacechange', async function(event) {
|
||||||
// Close dropdowns and mobile nav on outside click
|
self.workspaceId = event.detail ? event.detail.id : null;
|
||||||
document.addEventListener('click', (e) => {
|
await self.reload();
|
||||||
if (!e.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
|
});
|
||||||
this.openDropdown = null;
|
document.addEventListener('click', function(event) {
|
||||||
|
if (!event.target.closest('.filter-dropdown, .sort-dropdown, .user-menu')) {
|
||||||
|
self.openDropdown = null;
|
||||||
}
|
}
|
||||||
if (!e.target.closest('.navbar')) {
|
if (!event.target.closest('.navbar')) {
|
||||||
this.navOpen = false;
|
self.navOpen = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
await this.reload();
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── helpers for filtering (shared between filtered getter and tabCount) ──
|
async reload() {
|
||||||
_applyNonStatusFilters(ops) {
|
this.loading = true;
|
||||||
|
this.loadError = '';
|
||||||
|
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
|
||||||
|
this.loading = false;
|
||||||
|
this.page = 1;
|
||||||
|
},
|
||||||
|
|
||||||
|
_applyNonStatusFilters(operations) {
|
||||||
|
var filtered = operations;
|
||||||
|
|
||||||
if (this.search.trim()) {
|
if (this.search.trim()) {
|
||||||
const q = this.search.toLowerCase();
|
var query = this.search.toLowerCase();
|
||||||
ops = ops.filter(op =>
|
filtered = filtered.filter(function(operation) {
|
||||||
op.name.toLowerCase().includes(q) ||
|
return operation.name.toLowerCase().includes(query)
|
||||||
op.display_name.toLowerCase().includes(q) ||
|
|| operation.display_name.toLowerCase().includes(query)
|
||||||
(op.target_url || '').toLowerCase().includes(q)
|
|| operation.category.toLowerCase().includes(query)
|
||||||
);
|
|| (operation.target_url || '').toLowerCase().includes(query);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.filterProtocol) {
|
if (this.filterProtocol) {
|
||||||
ops = ops.filter(op => op.protocol === this.filterProtocol);
|
filtered = filtered.filter(function(operation) { return operation.protocol === this.filterProtocol; }, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.filterCategory) {
|
if (this.filterCategory) {
|
||||||
ops = ops.filter(op => op.category === this.filterCategory);
|
filtered = filtered.filter(function(operation) { return operation.category === this.filterCategory; }, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.filterAgent) {
|
if (this.filterAgent) {
|
||||||
const agent = this.agents.find(a => a.id === this.filterAgent);
|
filtered = filtered.filter(function(operation) {
|
||||||
const ids = agent ? agent.operation_ids : [];
|
return (operation.agent_refs || []).some(function(agent) { return agent.agent_id === this.filterAgent; }, this);
|
||||||
ops = ops.filter(op => ids.includes(op.id));
|
}, this);
|
||||||
}
|
}
|
||||||
return ops;
|
|
||||||
|
return filtered;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── computed: filtered + sorted + paginated ──
|
|
||||||
get filtered() {
|
get filtered() {
|
||||||
let ops = this._applyNonStatusFilters(this.operations);
|
var operations = this._applyNonStatusFilters(this.operations);
|
||||||
|
|
||||||
if (this.tab !== 'all') {
|
if (this.tab !== 'all') {
|
||||||
ops = ops.filter(op => op.status === this.tab);
|
operations = operations.filter(function(operation) {
|
||||||
|
return operation.status === this.tab;
|
||||||
|
}, this);
|
||||||
}
|
}
|
||||||
|
|
||||||
ops = [...ops].sort((a, b) => {
|
operations = operations.slice().sort(function(left, right) {
|
||||||
switch (this.sort) {
|
if (this.sort === 'created_desc') return new Date(right.created_at) - new Date(left.created_at);
|
||||||
case 'created_desc': return new Date(b.created_at) - new Date(a.created_at);
|
if (this.sort === 'created_asc') return new Date(left.created_at) - new Date(right.created_at);
|
||||||
case 'created_asc': return new Date(a.created_at) - new Date(b.created_at);
|
if (this.sort === 'name_asc') return left.name.localeCompare(right.name);
|
||||||
case 'name_asc': return a.name.localeCompare(b.name);
|
if (this.sort === 'name_desc') return right.name.localeCompare(left.name);
|
||||||
case 'name_desc': return b.name.localeCompare(a.name);
|
return 0;
|
||||||
default: return 0;
|
}.bind(this));
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return ops;
|
return operations;
|
||||||
},
|
},
|
||||||
|
|
||||||
get totalFiltered() { return this.filtered.length; },
|
get totalFiltered() {
|
||||||
get totalPages() { return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize)); },
|
return this.filtered.length;
|
||||||
|
},
|
||||||
|
|
||||||
|
get totalPages() {
|
||||||
|
return Math.max(1, Math.ceil(this.totalFiltered / this.pageSize));
|
||||||
|
},
|
||||||
|
|
||||||
get paginated() {
|
get paginated() {
|
||||||
const start = (this.page - 1) * this.pageSize;
|
var start = (this.page - 1) * this.pageSize;
|
||||||
return this.filtered.slice(start, start + this.pageSize);
|
return this.filtered.slice(start, start + this.pageSize);
|
||||||
},
|
},
|
||||||
|
|
||||||
get pageStart() { return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1; },
|
get pageStart() {
|
||||||
get pageEnd() { return Math.min(this.page * this.pageSize, this.totalFiltered); },
|
return this.totalFiltered === 0 ? 0 : (this.page - 1) * this.pageSize + 1;
|
||||||
|
},
|
||||||
// ── tab counts (respect non-status filters so numbers are consistent) ──
|
|
||||||
tabCount(tab) {
|
get pageEnd() {
|
||||||
const ops = this._applyNonStatusFilters(this.operations);
|
return Math.min(this.page * this.pageSize, this.totalFiltered);
|
||||||
if (tab === 'all') return ops.length;
|
},
|
||||||
return ops.filter(op => op.status === tab).length;
|
|
||||||
|
tabCount(tab) {
|
||||||
|
var operations = this._applyNonStatusFilters(this.operations);
|
||||||
|
if (tab === 'all') return operations.length;
|
||||||
|
return operations.filter(function(operation) { return operation.status === tab; }).length;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── active filter chips (for dismissible chip row) ──
|
|
||||||
get activeChips() {
|
get activeChips() {
|
||||||
const chips = [];
|
var chips = [];
|
||||||
if (this.filterProtocol) {
|
if (this.filterProtocol) chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
|
||||||
chips.push({ key: 'protocol', label: 'Protocol: ' + this.filterProtocol.toUpperCase() });
|
if (this.filterCategory) chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
|
||||||
}
|
|
||||||
if (this.filterCategory) {
|
|
||||||
chips.push({ key: 'category', label: 'Category: ' + this.filterCategory });
|
|
||||||
}
|
|
||||||
if (this.filterAgent) {
|
if (this.filterAgent) {
|
||||||
const a = this.agents.find(ag => ag.id === this.filterAgent);
|
var agent = this.activeAgents.find(function(item) { return item.agent_id === this.filterAgent; }, this);
|
||||||
chips.push({ key: 'agent', label: 'Agent: ' + (a ? a.display_name : '') });
|
chips.push({ key: 'agent', label: 'Agent: ' + (agent ? agent.display_name : '') });
|
||||||
}
|
}
|
||||||
return chips;
|
return chips;
|
||||||
},
|
},
|
||||||
@@ -201,109 +268,132 @@ document.addEventListener('alpine:init', () => {
|
|||||||
this.page = 1;
|
this.page = 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── agent lookup per operation (for badges in rows) ──
|
|
||||||
get agentsByOpId() {
|
get agentsByOpId() {
|
||||||
const map = {};
|
var map = {};
|
||||||
this.agents.forEach(a => {
|
this.operations.forEach(function(operation) {
|
||||||
a.operation_ids.forEach(id => {
|
map[operation.id] = operation.agent_refs || [];
|
||||||
if (!map[id]) map[id] = [];
|
|
||||||
map[id].push(a);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
return map;
|
return map;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── active agents only (for filter dropdown) ──
|
|
||||||
get activeAgents() {
|
get activeAgents() {
|
||||||
return this.agents.filter(a => a.status === 'active');
|
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);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── sort label ──
|
|
||||||
get sortLabel() {
|
get sortLabel() {
|
||||||
return getSortOptions().find(o => o.value === this.sort)?.label ?? 'Sort';
|
return getSortOptions().find(function(option) { return option.value === this.sort; }, this)?.label || 'Sort';
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── actions ──
|
|
||||||
setTab(tab) {
|
setTab(tab) {
|
||||||
this.tab = tab;
|
this.tab = tab;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSearch(val) {
|
setSearch(value) {
|
||||||
this.search = val;
|
this.search = value;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
setProtocol(val) {
|
setProtocol(value) {
|
||||||
this.filterProtocol = this.filterProtocol === val ? null : val;
|
this.filterProtocol = this.filterProtocol === value ? null : value;
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
setCategory(val) {
|
setCategory(value) {
|
||||||
this.filterCategory = this.filterCategory === val ? null : val;
|
this.filterCategory = this.filterCategory === value ? null : value;
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
setSort(val) {
|
setSort(value) {
|
||||||
this.sort = val;
|
this.sort = value;
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
},
|
},
|
||||||
|
|
||||||
setAgent(val) {
|
setAgent(value) {
|
||||||
this.filterAgent = this.filterAgent === val ? null : val;
|
this.filterAgent = this.filterAgent === value ? null : value;
|
||||||
this.openDropdown = null;
|
this.openDropdown = null;
|
||||||
this.page = 1;
|
this.page = 1;
|
||||||
},
|
},
|
||||||
|
|
||||||
clearProtocol() { this.filterProtocol = null; this.openDropdown = null; this.page = 1; },
|
clearProtocol() {
|
||||||
clearCategory() { this.filterCategory = null; this.openDropdown = null; this.page = 1; },
|
this.filterProtocol = null;
|
||||||
clearAgent() { this.filterAgent = null; this.openDropdown = null; this.page = 1; },
|
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) {
|
toggleDropdown(name) {
|
||||||
this.openDropdown = this.openDropdown === name ? null : name;
|
this.openDropdown = this.openDropdown === name ? null : name;
|
||||||
},
|
},
|
||||||
|
|
||||||
goPage(p) {
|
goPage(page) {
|
||||||
if (p >= 1 && p <= this.totalPages) this.page = p;
|
if (page >= 1 && page <= this.totalPages) this.page = page;
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── operation CRUD ──
|
editOperation(operation) {
|
||||||
editOperation(op) {
|
window.location.href = (window.APP_BASE || '') + 'html/wizard/?mode=edit&operationId=' + encodeURIComponent(operation.id);
|
||||||
try { sessionStorage.setItem('wizard_edit', JSON.stringify(op)); } catch (e) {}
|
|
||||||
window.location.href = (window.APP_BASE||'')+'html/wizard/?mode=edit';
|
|
||||||
},
|
},
|
||||||
|
|
||||||
deleteOperation(id) {
|
async deleteOperation(id) {
|
||||||
if (!confirm('Delete this operation? This cannot be undone.')) return;
|
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 {
|
try {
|
||||||
const overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
await window.CrankApi.deleteOperation(this.workspaceId, id);
|
||||||
const filtered = overrides.filter(o => o.id !== id);
|
this.operations = this.operations.filter(function(operation) { return operation.id !== id; });
|
||||||
filtered.push({ id, _deleted: true });
|
this.categoryOptions = Array.from(new Set(this.operations.map(function(operation) {
|
||||||
localStorage.setItem('crank_ops_overrides', JSON.stringify(filtered));
|
return operation.category;
|
||||||
} catch (e) {}
|
}).filter(Boolean))).sort();
|
||||||
|
this.stats = computeStats(this.operations);
|
||||||
|
} catch (error) {
|
||||||
|
alert(error.message || 'Failed to delete operation');
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
// ── helpers для шаблона ──
|
protocolLabel(operation) {
|
||||||
protocolLabel(op) {
|
if (operation.protocol === 'rest') {
|
||||||
if (op.protocol === 'rest') return `REST · ${op.method}`;
|
return operation.method ? ('REST · ' + operation.method) : 'REST';
|
||||||
if (op.protocol === 'graphql') return 'GraphQL';
|
}
|
||||||
|
if (operation.protocol === 'graphql') return 'GraphQL';
|
||||||
return 'gRPC';
|
return 'gRPC';
|
||||||
},
|
},
|
||||||
|
|
||||||
protocolClass(op) {
|
protocolClass(operation) {
|
||||||
return `badge badge-${op.protocol}`;
|
return 'badge badge-' + operation.protocol;
|
||||||
},
|
},
|
||||||
|
|
||||||
statusClass(op) {
|
statusClass(operation) {
|
||||||
return `status-badge status-${op.status}`;
|
return 'status-badge status-' + operation.status;
|
||||||
},
|
},
|
||||||
|
|
||||||
statusLabel(op) {
|
statusLabel(operation) {
|
||||||
return op.status.charAt(0).toUpperCase() + op.status.slice(1);
|
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) {
|
formatDate(dateStr) {
|
||||||
@@ -312,15 +402,23 @@ document.addEventListener('alpine:init', () => {
|
|||||||
},
|
},
|
||||||
|
|
||||||
truncateUrl(url) {
|
truncateUrl(url) {
|
||||||
if (!url) return '';
|
if (!url) return '—';
|
||||||
return url.length > 42 ? url.slice(0, 42) + '…' : url;
|
return url.length > 42 ? url.slice(0, 42) + '…' : url;
|
||||||
},
|
},
|
||||||
|
|
||||||
get sortOptions() { return getSortOptions(); },
|
formatMetricNumber(value) {
|
||||||
get tabDefs() { return getTabDefs(); },
|
return new Intl.NumberFormat('en-US').format(value || 0);
|
||||||
|
},
|
||||||
|
|
||||||
|
get sortOptions() {
|
||||||
|
return getSortOptions();
|
||||||
|
},
|
||||||
|
|
||||||
|
get tabDefs() {
|
||||||
|
return getTabDefs();
|
||||||
|
},
|
||||||
|
|
||||||
handleNewOperation() {
|
handleNewOperation() {
|
||||||
sessionStorage.removeItem('wizard_edit');
|
|
||||||
window.location.href = (window.APP_BASE || '') + 'html/wizard/';
|
window.location.href = (window.APP_BASE || '') + 'html/wizard/';
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -328,6 +426,6 @@ document.addEventListener('alpine:init', () => {
|
|||||||
localStorage.removeItem('crank_user');
|
localStorage.removeItem('crank_user');
|
||||||
window.location.href = (window.APP_BASE || '') + 'html/login.html';
|
window.location.href = (window.APP_BASE || '') + 'html/login.html';
|
||||||
},
|
},
|
||||||
}));
|
};
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+565
-180
@@ -3,6 +3,10 @@ var TOTAL_STEPS = 5;
|
|||||||
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc'
|
var wizardProtocol = 'rest'; // 'rest' | 'graphql' | 'grpc'
|
||||||
var wizardMode = 'create'; // 'create' | 'edit'
|
var wizardMode = 'create'; // 'create' | 'edit'
|
||||||
var wizardEditId = null;
|
var wizardEditId = null;
|
||||||
|
var wizardWorkspaceId = null;
|
||||||
|
var wizardCurrentOperation = null;
|
||||||
|
var wizardCurrentVersion = null;
|
||||||
|
var wizardProtoUpload = null;
|
||||||
|
|
||||||
/* ── Dynamic step loading ── */
|
/* ── Dynamic step loading ── */
|
||||||
function _stepFile(n) {
|
function _stepFile(n) {
|
||||||
@@ -115,9 +119,54 @@ function _doGoToStep(n) {
|
|||||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
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() {
|
document.querySelector('.btn-continue').addEventListener('click', function() {
|
||||||
if (currentStep < TOTAL_STEPS) {
|
if (currentStep < TOTAL_STEPS) {
|
||||||
goToStep(currentStep + 1);
|
goToStep(currentStep + 1);
|
||||||
@@ -130,12 +179,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
|||||||
if (!this.disabled && currentStep > 1) goToStep(currentStep - 1);
|
if (!this.disabled && currentStep > 1) goToStep(currentStep - 1);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sidebar step clicks
|
|
||||||
document.querySelectorAll('.step-item').forEach(function(item, i) {
|
document.querySelectorAll('.step-item').forEach(function(item, i) {
|
||||||
item.addEventListener('click', function() { goToStep(i + 1); });
|
item.addEventListener('click', function() { goToStep(i + 1); });
|
||||||
});
|
});
|
||||||
|
|
||||||
// Back to catalog / close
|
|
||||||
var backToCatalog = document.getElementById('back-to-catalog');
|
var backToCatalog = document.getElementById('back-to-catalog');
|
||||||
if (backToCatalog) {
|
if (backToCatalog) {
|
||||||
backToCatalog.addEventListener('click', function() {
|
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');
|
var saveDraftBtn = document.querySelector('.btn-save-draft');
|
||||||
if (saveDraftBtn) {
|
if (saveDraftBtn) {
|
||||||
saveDraftBtn.addEventListener('click', function() {
|
saveDraftBtn.addEventListener('click', function() {
|
||||||
var data = collectWizardData();
|
saveOperation(true);
|
||||||
if (!data.name) return; // nothing to save yet
|
|
||||||
try {
|
|
||||||
var overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
|
||||||
if (wizardMode === 'edit' && wizardEditId) {
|
|
||||||
overrides = overrides.map(function(o) {
|
|
||||||
return o.id === wizardEditId ? Object.assign({}, o, data, { id: wizardEditId }) : o;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
var draftId = 'op_draft_' + Date.now();
|
|
||||||
overrides.push(Object.assign({ id: draftId, status: 'draft', created_at: new Date().toISOString(), calls_today: 0, last_called_at: null, category: 'general' }, data));
|
|
||||||
}
|
|
||||||
localStorage.setItem('crank_ops_overrides', JSON.stringify(overrides));
|
|
||||||
} catch (e) {}
|
|
||||||
// Visual feedback
|
|
||||||
saveDraftBtn.textContent = 'Saved ✓';
|
|
||||||
setTimeout(function() { saveDraftBtn.textContent = 'Save draft'; }, 1800);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Detect edit mode from URL param and sessionStorage
|
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);
|
var params = new URLSearchParams(window.location.search);
|
||||||
if (params.get('mode') === 'edit') {
|
if (params.get('mode') === 'edit' && params.get('operationId')) {
|
||||||
wizardMode = 'edit';
|
wizardMode = 'edit';
|
||||||
|
wizardEditId = params.get('operationId');
|
||||||
document.title = 'Crank — Edit Operation';
|
document.title = 'Crank — Edit Operation';
|
||||||
try {
|
await loadOperationForEdit();
|
||||||
var editData = JSON.parse(sessionStorage.getItem('wizard_edit') || 'null');
|
|
||||||
if (editData) {
|
|
||||||
wizardEditId = editData.id;
|
|
||||||
prefillWizardFromEdit(editData);
|
|
||||||
}
|
|
||||||
} catch (e) {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Init: load step 1 fragment first, then render
|
_doGoToStep(1);
|
||||||
loadStep(1, function() { _doGoToStep(1); });
|
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -723,6 +701,7 @@ function handleProtoFile(event) {
|
|||||||
if (!file) return;
|
if (!file) return;
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = function(e) {
|
reader.onload = function(e) {
|
||||||
|
wizardProtoUpload = { fileName: file.name, content: e.target.result };
|
||||||
showProtoFileInfo(file.name, file.size);
|
showProtoFileInfo(file.name, file.size);
|
||||||
var parsed = parseProto(e.target.result);
|
var parsed = parseProto(e.target.result);
|
||||||
renderParsedProto(parsed);
|
renderParsedProto(parsed);
|
||||||
@@ -738,6 +717,7 @@ function handleProtoDrop(event) {
|
|||||||
if (!file || !file.name.endsWith('.proto')) return;
|
if (!file || !file.name.endsWith('.proto')) return;
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onload = function(e) {
|
reader.onload = function(e) {
|
||||||
|
wizardProtoUpload = { fileName: file.name, content: e.target.result };
|
||||||
showProtoFileInfo(file.name, file.size);
|
showProtoFileInfo(file.name, file.size);
|
||||||
renderParsedProto(parseProto(e.target.result));
|
renderParsedProto(parseProto(e.target.result));
|
||||||
};
|
};
|
||||||
@@ -769,6 +749,7 @@ function resetProto() {
|
|||||||
if (fileInput) fileInput.value = '';
|
if (fileInput) fileInput.value = '';
|
||||||
protoParsed = null;
|
protoParsed = null;
|
||||||
selectedRpcMethod = null;
|
selectedRpcMethod = null;
|
||||||
|
wizardProtoUpload = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleProtoPaste() {
|
function toggleProtoPaste() {
|
||||||
@@ -795,6 +776,7 @@ function parseProtoPasted() {
|
|||||||
var ta = document.getElementById('proto-paste-input');
|
var ta = document.getElementById('proto-paste-input');
|
||||||
if (!ta || !ta.value.trim()) return;
|
if (!ta || !ta.value.trim()) return;
|
||||||
var parsed = parseProto(ta.value);
|
var parsed = parseProto(ta.value);
|
||||||
|
wizardProtoUpload = { fileName: 'pasted.proto', content: ta.value };
|
||||||
document.getElementById('proto-paste-area').style.display = 'none';
|
document.getElementById('proto-paste-area').style.display = 'none';
|
||||||
var dropzone = document.getElementById('proto-dropzone');
|
var dropzone = document.getElementById('proto-dropzone');
|
||||||
if (dropzone) dropzone.style.display = 'none';
|
if (dropzone) dropzone.style.display = 'none';
|
||||||
@@ -909,133 +891,536 @@ function grpcManualTypeInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
/* ══════════════════════════════════════════════
|
function textValue(id) {
|
||||||
Wizard edit mode — collect, save, prefill
|
var element = document.getElementById(id);
|
||||||
══════════════════════════════════════════════ */
|
return element ? element.value.trim() : '';
|
||||||
|
|
||||||
function collectWizardData() {
|
|
||||||
var protocol = wizardProtocol;
|
|
||||||
|
|
||||||
// Upstream base URL
|
|
||||||
var upstream = upstreams.find(function(u) { return u.id === selectedUpstreamId; });
|
|
||||||
var baseUrl = upstream ? upstream.url : '';
|
|
||||||
|
|
||||||
// Protocol-specific path & method
|
|
||||||
var method = '';
|
|
||||||
var targetUrl = baseUrl;
|
|
||||||
if (protocol === 'rest') {
|
|
||||||
var activeMethod = document.querySelector('.method-card.active');
|
|
||||||
method = activeMethod ? activeMethod.dataset.method : 'POST';
|
|
||||||
var pathEl = document.getElementById('endpoint-path');
|
|
||||||
var path = pathEl ? pathEl.value.trim() : '';
|
|
||||||
targetUrl = baseUrl + path;
|
|
||||||
} else if (protocol === 'graphql') {
|
|
||||||
method = 'POST';
|
|
||||||
targetUrl = baseUrl + '/graphql';
|
|
||||||
} else if (protocol === 'grpc') {
|
|
||||||
var pathValEl = document.getElementById('grpc-path-value');
|
|
||||||
var grpcPath = pathValEl ? pathValEl.textContent.trim() : '';
|
|
||||||
targetUrl = baseUrl + grpcPath;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var val = function(id) { var el = document.getElementById(id); return el ? el.value.trim() : ''; };
|
function setValue(id, value) {
|
||||||
|
var element = document.getElementById(id);
|
||||||
|
if (element) element.value = value || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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) || {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
return {
|
||||||
name: val('tool-name'),
|
id: 'custom',
|
||||||
display_name: val('tool-display-name') || val('tool-name'),
|
name: customName || 'custom-upstream',
|
||||||
description: val('tool-description'),
|
url: customUrl,
|
||||||
protocol: protocol,
|
authHeaders: authHeaders,
|
||||||
method: method,
|
|
||||||
target_url: targetUrl,
|
|
||||||
input_schema: val('tool-input-schema'),
|
|
||||||
output_schema: val('tool-output-schema'),
|
|
||||||
input_mapping: val('tool-input-mapping'),
|
|
||||||
output_mapping: val('tool-output-mapping'),
|
|
||||||
exec_config: val('tool-exec-config'),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function saveOperation() {
|
function joinUrl(baseUrl, path) {
|
||||||
var data = collectWizardData();
|
if (!baseUrl) return path || '';
|
||||||
if (!data.name) { goToStep(4); return; }
|
if (!path) return baseUrl;
|
||||||
|
return baseUrl.replace(/\/+$/, '') + '/' + path.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedGraphqlOperationType() {
|
||||||
|
var active = document.querySelector('.gql-type-card.active');
|
||||||
|
return active ? active.dataset.gqlType : 'query';
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
try {
|
||||||
var overrides = JSON.parse(localStorage.getItem('crank_ops_overrides') || '[]');
|
var payload = collectWizardPayload();
|
||||||
|
var response;
|
||||||
|
|
||||||
|
if (!wizardWorkspaceId) {
|
||||||
|
throw new Error('No workspace selected');
|
||||||
|
}
|
||||||
|
|
||||||
if (wizardMode === 'edit' && wizardEditId) {
|
if (wizardMode === 'edit' && wizardEditId) {
|
||||||
var found = false;
|
response = await window.CrankApi.updateOperation(wizardWorkspaceId, wizardEditId, {
|
||||||
overrides = overrides.map(function(o) {
|
display_name: payload.display_name,
|
||||||
if (o.id === wizardEditId) {
|
category: payload.category,
|
||||||
found = true;
|
target: payload.target,
|
||||||
return Object.assign({}, o, data, { id: wizardEditId });
|
input_schema: payload.input_schema,
|
||||||
}
|
output_schema: payload.output_schema,
|
||||||
return o;
|
input_mapping: payload.input_mapping,
|
||||||
|
output_mapping: payload.output_mapping,
|
||||||
|
execution_config: payload.execution_config,
|
||||||
|
tool_description: payload.tool_description,
|
||||||
});
|
});
|
||||||
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 {
|
} else {
|
||||||
var newId = 'op_' + Date.now();
|
response = await window.CrankApi.createOperation(wizardWorkspaceId, payload);
|
||||||
overrides = overrides.filter(function(o) { return o.id !== newId; });
|
wizardEditId = response.operation_id;
|
||||||
overrides.push(Object.assign({ id: newId, status: 'draft', created_at: new Date().toISOString(), calls_today: 0, last_called_at: null, category: 'general' }, data));
|
wizardMode = 'edit';
|
||||||
|
history.replaceState({}, '', window.location.pathname + '?mode=edit&operationId=' + encodeURIComponent(wizardEditId));
|
||||||
|
setEditModePresentation();
|
||||||
|
var toolNameInput = document.getElementById('tool-name');
|
||||||
|
if (toolNameInput) toolNameInput.disabled = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
localStorage.setItem('crank_ops_overrides', JSON.stringify(overrides));
|
if (wizardProtocol === 'grpc' && wizardProtoUpload && wizardEditId) {
|
||||||
} catch (e) {}
|
var descriptor = await window.CrankApi.uploadProtoFile(
|
||||||
|
wizardWorkspaceId,
|
||||||
setTimeout(function() { window.location.href = (window.APP_BASE || '') + 'index.html'; }, 300);
|
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,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function prefillWizardFromEdit(op) {
|
if (stayOnPage) {
|
||||||
if (!op) return;
|
var saveDraftBtn = document.querySelector('.btn-save-draft');
|
||||||
|
if (saveDraftBtn) {
|
||||||
|
var label = saveDraftBtn.textContent;
|
||||||
|
saveDraftBtn.textContent = 'Saved ✓';
|
||||||
|
setTimeout(function() {
|
||||||
|
saveDraftBtn.textContent = label;
|
||||||
|
}, 1400);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// Update wizard header labels for edit mode
|
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');
|
var progressLabel = document.querySelector('.progress-label');
|
||||||
if (progressLabel) progressLabel.textContent = 'Edit operation';
|
if (progressLabel) progressLabel.textContent = 'Edit operation';
|
||||||
var sidebarBrand = document.querySelector('.step-sidebar-brand');
|
var sidebarBrand = document.querySelector('.step-sidebar-brand');
|
||||||
if (sidebarBrand) sidebarBrand.innerHTML = 'Edit <span>operation</span>';
|
if (sidebarBrand) sidebarBrand.innerHTML = 'Edit <span>operation</span>';
|
||||||
|
}
|
||||||
|
|
||||||
// Step 1: protocol
|
function selectProtocol(protocol) {
|
||||||
wizardProtocol = op.protocol || 'rest';
|
wizardProtocol = protocol || 'rest';
|
||||||
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
document.querySelectorAll('.protocol-card').forEach(function(card) {
|
||||||
var proto = card.dataset.protocol ||
|
var proto = card.dataset.protocol
|
||||||
(card.classList.contains('rest') ? 'rest' : card.classList.contains('graphql') ? 'graphql' : 'grpc');
|
|| (card.classList.contains('rest') ? 'rest' : card.classList.contains('graphql') ? 'graphql' : 'grpc');
|
||||||
var sel = proto === wizardProtocol;
|
var selected = proto === wizardProtocol;
|
||||||
card.classList.toggle('selected', sel);
|
card.classList.toggle('selected', selected);
|
||||||
card.setAttribute('aria-checked', sel ? 'true' : 'false');
|
card.setAttribute('aria-checked', selected ? 'true' : 'false');
|
||||||
});
|
});
|
||||||
var s3name = document.getElementById('sidebar-step-3-name');
|
var s3name = document.getElementById('sidebar-step-3-name');
|
||||||
if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || 'Protocol config';
|
if (s3name) s3name.textContent = STEP3_LABELS[wizardProtocol] || 'Protocol config';
|
||||||
|
}
|
||||||
|
|
||||||
// Step 3: endpoint path & method
|
function prefillUpstream(baseUrl, headers) {
|
||||||
if (op.protocol === 'rest') {
|
var known = upstreams.find(function(item) { return item.url === baseUrl; });
|
||||||
var pathEl = document.getElementById('endpoint-path');
|
if (known) {
|
||||||
if (pathEl && op.target_url) {
|
pickUpstream(known.id);
|
||||||
try {
|
return;
|
||||||
var url = new URL(op.target_url);
|
}
|
||||||
pathEl.value = url.pathname + url.search;
|
|
||||||
} catch (e) {
|
selectedUpstreamId = null;
|
||||||
pathEl.value = op.target_url;
|
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>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (op.method) {
|
|
||||||
document.querySelectorAll('.method-card').forEach(function(card) {
|
function crankSchemaToJsonSchema(schema) {
|
||||||
card.classList.toggle('active', card.dataset.method === op.method);
|
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;
|
||||||
}
|
}
|
||||||
} else if (op.protocol === 'graphql') {
|
if (schema.type === 'array' && schema.items) {
|
||||||
var gqlEl = document.getElementById('gql-query-editor');
|
result.items = crankSchemaToJsonSchema(schema.items);
|
||||||
if (gqlEl && op.input_mapping) gqlEl.value = op.input_mapping;
|
}
|
||||||
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Steps 4 & 5: form fields
|
function mappingSetToEditorValue(mappingSet, mode, protocol) {
|
||||||
var set = function(id, val) { var el = document.getElementById(id); if (el) el.value = val || ''; };
|
var rules = mappingSet && mappingSet.rules ? mappingSet.rules : [];
|
||||||
set('tool-name', op.name);
|
var object = {};
|
||||||
set('tool-display-name', op.display_name);
|
rules.forEach(function(rule) {
|
||||||
set('tool-description', op.description);
|
if (mode === 'input') {
|
||||||
set('tool-input-schema', op.input_schema);
|
var key = rule.target.replace(mappingRootForProtocol(protocol) + '.', '');
|
||||||
set('tool-output-schema', op.output_schema);
|
object[key] = rule.source.replace('$.mcp.', '$.input.');
|
||||||
set('tool-input-mapping', op.input_mapping);
|
return;
|
||||||
set('tool-output-mapping',op.output_mapping);
|
}
|
||||||
set('tool-exec-config', op.exec_config);
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
+107
-23
@@ -1,35 +1,80 @@
|
|||||||
/* ═══════════════════════════════════════════════════
|
|
||||||
Crank — workspace switcher
|
|
||||||
═══════════════════════════════════════════════════ */
|
|
||||||
|
|
||||||
var WS_LIST = [
|
var WS_LIST = [
|
||||||
{ slug: 'acme-workspace', name: 'acme-workspace', role: 'Owner', letter: 'A', color: '#0d9488' },
|
{ id: 'ws_default', slug: 'default', name: 'Default workspace', role: 'Owner', letter: 'D', color: '#0d9488' }
|
||||||
{ slug: 'startup-demo', name: 'startup-demo', role: 'Developer', letter: 'S', color: '#7c3aed' },
|
|
||||||
{ slug: 'personal', name: 'personal', role: 'Owner', letter: 'P', color: '#0891b2' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function getCurrentWs() {
|
var workspaceLoadPromise = null;
|
||||||
var slug = localStorage.getItem('crank_workspace') || 'acme-workspace';
|
var workspaceLoadFailed = false;
|
||||||
return WS_LIST.find(function(w) { return w.slug === slug; }) || WS_LIST[0];
|
|
||||||
|
function workspaceColor(index) {
|
||||||
|
return ['#0d9488', '#7c3aed', '#0891b2', '#f59e0b', '#2563eb'][index % 5];
|
||||||
}
|
}
|
||||||
|
|
||||||
function initWorkspaceSwitcher() {
|
function mapWorkspace(record, index) {
|
||||||
var ws = getCurrentWs();
|
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 nameEl = document.getElementById('ws-current-name');
|
||||||
var dotEl = document.getElementById('ws-dot');
|
var dotEl = document.getElementById('ws-dot');
|
||||||
if (nameEl) nameEl.textContent = ws.name;
|
if (nameEl) nameEl.textContent = workspace ? workspace.name : 'No workspace';
|
||||||
if (dotEl) { dotEl.textContent = ws.letter; dotEl.style.background = ws.color; }
|
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');
|
var list = document.getElementById('ws-dropdown-list');
|
||||||
if (!list) return;
|
if (!list) return;
|
||||||
|
|
||||||
list.innerHTML = WS_LIST.map(function(w) {
|
list.innerHTML = WS_LIST.map(function(workspace) {
|
||||||
var active = w.slug === ws.slug;
|
var active = current && workspace.id === current.id;
|
||||||
return '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + w.slug + '\')">' +
|
return '<div class="ws-dropdown-item' + (active ? ' active' : '') + '" onclick="switchWorkspace(\'' + workspace.id + '\')">' +
|
||||||
'<div class="ws-item-dot" style="background:' + w.color + '">' + w.letter + '</div>' +
|
'<div class="ws-item-dot" style="background:' + workspace.color + '">' + workspace.letter + '</div>' +
|
||||||
'<div class="ws-item-info">' +
|
'<div class="ws-item-info">' +
|
||||||
'<div class="ws-item-name">' + w.name + '</div>' +
|
'<div class="ws-item-name">' + workspace.name + '</div>' +
|
||||||
'<div class="ws-item-role">' + w.role + '</div>' +
|
'<div class="ws-item-role">' + workspace.role + '</div>' +
|
||||||
'</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>';
|
'</div>';
|
||||||
@@ -41,19 +86,58 @@ function initWorkspaceSwitcher() {
|
|||||||
'</a>';
|
'</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) {
|
function toggleWsSwitcher(e) {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
var dd = document.getElementById('ws-dropdown');
|
var dd = document.getElementById('ws-dropdown');
|
||||||
if (dd) dd.style.display = dd.style.display === 'none' ? '' : 'none';
|
if (dd) dd.style.display = dd.style.display === 'none' ? '' : 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
function switchWorkspace(slug) {
|
function switchWorkspace(workspaceId) {
|
||||||
localStorage.setItem('crank_workspace', slug);
|
var workspace = WS_LIST.find(function(item) { return item.id === workspaceId; });
|
||||||
|
if (!workspace) return;
|
||||||
|
persistCurrentWorkspace(workspace);
|
||||||
var dd = document.getElementById('ws-dropdown');
|
var dd = document.getElementById('ws-dropdown');
|
||||||
if (dd) dd.style.display = 'none';
|
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('DOMContentLoaded', initWorkspaceSwitcher);
|
||||||
|
|
||||||
document.addEventListener('click', function(e) {
|
document.addEventListener('click', function(e) {
|
||||||
|
|||||||
@@ -72,7 +72,8 @@ UI-файлы:
|
|||||||
|
|
||||||
- эту страницу можно интегрировать первой;
|
- эту страницу можно интегрировать первой;
|
||||||
- backend уже отдает server-side category, usage summary и agent refs;
|
- 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
|
### 4.2. Wizard
|
||||||
|
|
||||||
@@ -127,7 +128,8 @@ UI-файлы:
|
|||||||
|
|
||||||
- wizard почти готов для реального backend;
|
- wizard почти готов для реального backend;
|
||||||
- это основной экран второй очереди после catalog;
|
- это основной экран второй очереди после 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
|
### 4.3. Agents
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user