diff --git a/TASKS.md b/TASKS.md index 921590d..fb7b1fa 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,19 @@ ## Current -### `feat/alpine-agents` +### `feat/alpine-api-keys` Status: completed DoD: -- `Agents` page читает live catalog агентов из `admin-api` -- backend для `Agents` поддерживает `PATCH`, `DELETE` и richer summary для Alpine UI -- `Agents` page использует live `create/edit/delete` flow вместо `agents.json` -- `Agents` page использует live operations picker вместо `operations.json` +- `API Keys` page читает live keys catalog из `admin-api` +- `API Keys` page использует live `create/revoke/delete` flow вместо `keys.json` +- scope model и тексты в UI выровнены с реальным backend - `test-ui` остается нетронутым как fallback ## Next -- `feat/alpine-api-keys` +- `feat/alpine-logs-usage` ## Backlog diff --git a/apps/ui/html/api-keys.html b/apps/ui/html/api-keys.html index daeff65..f2e7350 100644 --- a/apps/ui/html/api-keys.html +++ b/apps/ui/html/api-keys.html @@ -27,6 +27,7 @@ + @@ -185,7 +186,7 @@
deploy
-
Publish draft operations, rollback deployments, and manage traffic splits.
+
Publish agents and operations and perform deploy-scoped platform actions.
diff --git a/apps/ui/js/api-keys.js b/apps/ui/js/api-keys.js index f21cc5a..b1d3b9d 100644 --- a/apps/ui/js/api-keys.js +++ b/apps/ui/js/api-keys.js @@ -1,56 +1,55 @@ -/* ═══════════════════════════════════════════════════ - Crank — API Keys page - Data: GET /api/workspaces/{slug}/keys - ═══════════════════════════════════════════════════ */ - var KEYS = []; -var currentWorkspaceSlug = ''; - -var SCOPES = ['read', 'write', 'deploy']; -var SCOPE_DESC = { - read: 'Read operations, logs, and usage', - write: 'Create and update operations', - deploy: 'Publish and rollback deployments', -}; - +var currentWorkspaceId = null; var selectedScopes = new Set(['read']); var search = ''; -// ── API helpers ────────────────────────────────────────────────────────────── +var SCOPES = ['read', 'write', 'deploy']; +var SCOPE_DESC = { + read: 'Read operations, logs, and usage', + write: 'Create and update operations', + deploy: 'Publish agents and operations', +}; -function apiBase() { - return '/api/workspaces/' + currentWorkspaceSlug + '/keys'; +function mapKeyRecord(record) { + var apiKey = record.api_key || {}; + return { + id: apiKey.id, + name: apiKey.name, + prefix: apiKey.prefix || '', + scopes: apiKey.scopes || [], + created: apiKey.created_at ? apiKey.created_at.slice(0, 10) : '—', + lastUsed: apiKey.last_used_at ? apiKey.last_used_at.slice(0, 10) : 'Never', + status: apiKey.status || 'active', + }; } -// In production: replace fetch(mockUrl()) with fetch(apiBase()) -function mockUrl() { - return (window.DATA_URL || 'data/') + 'keys.json'; +function currentWorkspace() { + return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null; } -// ── Load keys for current workspace ───────────────────────────────────────── - -function loadKeys() { - currentWorkspaceSlug = getCurrentWs().slug; +async function loadKeys() { + var workspace = currentWorkspace(); + currentWorkspaceId = workspace ? workspace.id : null; setTableLoading(true); - // Production: fetch(apiBase()) - fetch(mockUrl()) - .then(function(r) { - if (!r.ok) throw new Error('HTTP ' + r.status); - return r.json(); - }) - .then(function(data) { - // Mock JSON is keyed by workspace slug; real API returns array directly - KEYS = Array.isArray(data) ? data : (data[currentWorkspaceSlug] || []); - setTableLoading(false); - renderTable(); - }) - .catch(function() { - KEYS = []; - setTableLoading(false); - renderTable(); - }); + if (!currentWorkspaceId || !window.CrankApi) { + KEYS = []; + setTableLoading(false); + renderTable('Workspace or API is unavailable'); + return; + } + + try { + var response = await window.CrankApi.listPlatformApiKeys(currentWorkspaceId); + KEYS = ((response && response.items) || []).map(mapKeyRecord); + setTableLoading(false); + renderTable(); + } catch (error) { + KEYS = []; + setTableLoading(false); + renderTable(error.message || 'Failed to load API keys'); + } } function setTableLoading(on) { @@ -66,54 +65,50 @@ function setTableLoading(on) { tbody.appendChild(tr); } -// ── Mutations (in production: real fetch calls) ────────────────────────────── - -function revokeKey(id) { +async function revokeKey(id) { if (!confirm('Revoke this key? All requests using it will fail immediately.')) return; - // Production: fetch(apiBase() + '/' + id, { method: 'PATCH', body: JSON.stringify({ status: 'revoked' }) }) - var k = KEYS.find(function(k) { return k.id === id; }); - if (k) { k.status = 'revoked'; renderTable(); } + try { + await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id); + await loadKeys(); + } catch (error) { + alert(error.message || 'Failed to revoke key'); + } } -function deleteKey(id) { - // Production: fetch(apiBase() + '/' + id, { method: 'DELETE' }) - KEYS.splice(KEYS.findIndex(function(k) { return k.id === id; }), 1); - renderTable(); +async function deleteKey(id) { + if (!confirm('Delete this key? This cannot be undone.')) return; + + try { + await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id); + await loadKeys(); + } catch (error) { + alert(error.message || 'Failed to delete key'); + } } -function createKey(name, scopes) { - // Production: fetch(apiBase(), { method: 'POST', body: JSON.stringify({ name, scopes }) }) - // → response: { key: 'mcp_...', prefix: 'mcp_...', id: '...' } - var rand = Array.from({length: 32}, function() { return Math.random().toString(36)[2]; }).join(''); - var rawKey = 'mcp_' + name.toLowerCase().replace(/[^a-z0-9]/g, '').slice(0, 4) + '_' + rand; - var prefix = rawKey.slice(0, 12) + '…'; - var newKey = { - id: 'key_' + Date.now(), - name: name, - prefix: prefix, - scopes: scopes, - created: new Date().toISOString().slice(0, 10), - lastUsed: 'Never', - status: 'active', +async function createKey(name, scopes) { + var created = await window.CrankApi.createPlatformApiKey(currentWorkspaceId, { + name: name, + scopes: scopes, + }); + return { + rawKey: created.secret, + record: mapKeyRecord(created.api_key), }; - KEYS.unshift(newKey); - return rawKey; } -// ── Render ─────────────────────────────────────────────────────────────────── - function renderScopes() { - var el = document.getElementById('scope-checkboxes'); + var el = document.getElementById('scope-checkboxes'); var tmpl = document.getElementById('tmpl-scope-checkbox'); el.innerHTML = ''; - SCOPES.forEach(function(s) { - var node = tmpl.content.cloneNode(true); + SCOPES.forEach(function(scope) { + var node = tmpl.content.cloneNode(true); var input = node.querySelector('input'); - input.dataset.scope = s; - if (selectedScopes.has(s)) input.checked = true; - node.querySelector('.scope-checkbox-name').textContent = s; - node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[s]; + input.dataset.scope = scope; + if (selectedScopes.has(scope)) input.checked = true; + node.querySelector('.scope-checkbox-name').textContent = scope; + node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[scope]; input.addEventListener('change', function() { if (this.checked) selectedScopes.add(this.dataset.scope); else selectedScopes.delete(this.dataset.scope); @@ -122,18 +117,36 @@ function renderScopes() { }); } -function renderTable() { - var q = search.toLowerCase(); +function renderTable(errorMessage) { + var q = search.toLowerCase(); var tbody = document.getElementById('keys-tbody'); - var rows = KEYS.filter(function(k) { - return !q || k.name.toLowerCase().includes(q) || k.prefix.includes(q); + var rows = KEYS.filter(function(key) { + return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q); }); + var subtitle = document.querySelector('.section-card-subtitle'); tbody.innerHTML = ''; + if (subtitle) { + var active = KEYS.filter(function(key) { return key.status === 'active'; }).length; + var revoked = KEYS.filter(function(key) { return key.status === 'revoked'; }).length; + subtitle.textContent = active + ' active · ' + revoked + ' revoked'; + } + + if (errorMessage) { + var errorRow = document.createElement('tr'); + var errorCell = document.createElement('td'); + errorCell.colSpan = 7; + errorCell.style.cssText = 'text-align:center;padding:36px;color:var(--danger,#f85149);'; + errorCell.textContent = errorMessage; + errorRow.appendChild(errorCell); + tbody.appendChild(errorRow); + return; + } + if (!rows.length) { var empty = document.createElement('tr'); - var td = document.createElement('td'); + var td = document.createElement('td'); td.colSpan = 7; td.style.cssText = 'text-align:center;padding:36px;color:var(--text-muted);'; td.textContent = KEYS.length ? 'No keys match your search' : 'No API keys yet'; @@ -143,88 +156,107 @@ function renderTable() { } var tmpl = document.getElementById('tmpl-key-row'); - rows.forEach(function(k) { + rows.forEach(function(key) { var node = tmpl.content.cloneNode(true); - node.querySelector('.col-name').textContent = k.name; - node.querySelector('.col-mono').textContent = k.prefix; - node.querySelector('.col-created').textContent = k.created; - node.querySelector('.col-last-used').textContent = k.lastUsed; + node.querySelector('.col-name').textContent = key.name; + node.querySelector('.col-mono').textContent = key.prefix; + node.querySelector('.col-created').textContent = key.created; + node.querySelector('.col-last-used').textContent = key.lastUsed; var scopesEl = node.querySelector('.col-scopes'); - k.scopes.forEach(function(s) { + key.scopes.forEach(function(scope) { var span = document.createElement('span'); - span.className = 'badge badge-scope'; - span.textContent = s; + span.className = 'badge badge-scope'; + span.textContent = scope; scopesEl.appendChild(span); }); var badge = document.createElement('span'); - badge.className = k.status === 'active' ? 'badge badge-active' : 'badge badge-revoked'; - badge.textContent = k.status === 'active' ? 'Active' : 'Revoked'; + badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked'; + badge.textContent = key.status === 'active' ? 'Active' : 'Revoked'; node.querySelector('.col-status').appendChild(badge); - var actionsActive = node.querySelector('.actions-active'); + var actionsActive = node.querySelector('.actions-active'); var actionsRevoked = node.querySelector('.actions-revoked'); - if (k.status === 'active') { - actionsActive.querySelector('[title="Copy key prefix"]') - .addEventListener('click', function() { copyPrefix(k.prefix); }); - actionsActive.querySelector('[title="Revoke key"]') - .addEventListener('click', function() { revokeKey(k.id); }); + if (key.status === 'active') { + actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() { + copyPrefix(key.prefix); + }); + actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() { + revokeKey(key.id); + }); } else { - actionsActive.style.display = 'none'; + actionsActive.style.display = 'none'; actionsRevoked.style.display = ''; - actionsRevoked.querySelector('[title="Delete"]') - .addEventListener('click', function() { deleteKey(k.id); }); + actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() { + deleteKey(key.id); + }); } tbody.appendChild(node); }); } -// ── Modal ──────────────────────────────────────────────────────────────────── - var modal = document.getElementById('modal-create'); function openModal() { - document.getElementById('modal-form-body').style.display = ''; - document.getElementById('modal-reveal-body').style.display = 'none'; + document.getElementById('modal-form-body').style.display = ''; + document.getElementById('modal-reveal-body').style.display = 'none'; document.getElementById('modal-footer-create').style.display = ''; - document.getElementById('modal-footer-done').style.display = 'none'; + document.getElementById('modal-footer-done').style.display = 'none'; document.getElementById('new-key-name').value = ''; selectedScopes = new Set(['read']); renderScopes(); modal.classList.add('open'); - setTimeout(function() { document.getElementById('new-key-name').focus(); }, 50); + setTimeout(function() { + document.getElementById('new-key-name').focus(); + }, 50); } -function closeModal() { modal.classList.remove('open'); } +function closeModal() { + modal.classList.remove('open'); +} document.getElementById('btn-create-key').addEventListener('click', openModal); document.getElementById('modal-close-btn').addEventListener('click', closeModal); document.getElementById('modal-cancel-btn').addEventListener('click', closeModal); -modal.addEventListener('click', function(e) { if (e.target === modal) closeModal(); }); +modal.addEventListener('click', function(event) { + if (event.target === modal) closeModal(); +}); -document.getElementById('modal-confirm-btn').addEventListener('click', function() { +document.getElementById('modal-confirm-btn').addEventListener('click', async function() { var name = document.getElementById('new-key-name').value.trim(); - if (!name) { document.getElementById('new-key-name').focus(); return; } - if (!selectedScopes.size) { alert('Select at least one scope.'); return; } + if (!name) { + document.getElementById('new-key-name').focus(); + return; + } + if (!selectedScopes.size) { + alert('Select at least one scope.'); + return; + } - var rawKey = createKey(name, Array.from(selectedScopes)); - - document.getElementById('reveal-key-value').textContent = rawKey; - document.getElementById('modal-form-body').style.display = 'none'; - document.getElementById('modal-reveal-body').style.display = ''; - document.getElementById('modal-footer-create').style.display = 'none'; - document.getElementById('modal-footer-done').style.display = ''; - renderTable(); + try { + var created = await createKey(name, Array.from(selectedScopes)); + KEYS.unshift(created.record); + document.getElementById('reveal-key-value').textContent = created.rawKey; + document.getElementById('modal-form-body').style.display = 'none'; + document.getElementById('modal-reveal-body').style.display = ''; + document.getElementById('modal-footer-create').style.display = 'none'; + document.getElementById('modal-footer-done').style.display = ''; + renderTable(); + } catch (error) { + alert(error.message || 'Failed to create key'); + } }); document.getElementById('modal-done-btn').addEventListener('click', closeModal); document.getElementById('copy-key-btn').addEventListener('click', function() { - var val = document.getElementById('reveal-key-value').textContent; - navigator.clipboard && navigator.clipboard.writeText(val); - this.innerHTML = ''; + var value = document.getElementById('reveal-key-value').textContent; + if (navigator.clipboard) { + navigator.clipboard.writeText(value).catch(function() {}); + } + this.innerHTML = ''; }); document.getElementById('key-search').addEventListener('input', function() { @@ -232,10 +264,16 @@ document.getElementById('key-search').addEventListener('input', function() { renderTable(); }); -// ── Init ───────────────────────────────────────────────────────────────────── - function copyPrefix(prefix) { - navigator.clipboard && navigator.clipboard.writeText(prefix); + if (navigator.clipboard) { + navigator.clipboard.writeText(prefix).catch(function() {}); + } } -document.addEventListener('DOMContentLoaded', loadKeys); +document.addEventListener('DOMContentLoaded', async function() { + await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve()); + await loadKeys(); + window.addEventListener('crank:workspacechange', function() { + loadKeys(); + }); +}); diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 2f2a553..221760e 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -131,5 +131,17 @@ publishAgent: function(workspaceId, agentId, payload) { return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', payload); }, + listPlatformApiKeys: function(workspaceId) { + return get('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys'); + }, + createPlatformApiKey: function(workspaceId, payload) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys', payload); + }, + revokePlatformApiKey: function(workspaceId, keyId) { + return post('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId) + '/revoke', {}); + }, + deletePlatformApiKey: function(workspaceId, keyId) { + return del('/workspaces/' + encodeURIComponent(workspaceId) + '/platform-api-keys/' + encodeURIComponent(keyId)); + }, }; }()); diff --git a/docs/alpine-ui-integration-plan.md b/docs/alpine-ui-integration-plan.md index 0f909c8..91035c9 100644 --- a/docs/alpine-ui-integration-plan.md +++ b/docs/alpine-ui-integration-plan.md @@ -200,23 +200,19 @@ UI-файлы: Что еще не хватает: -- финальная договоренность по scope model; -- согласование response shape с UI: - - `secret` - - `prefix` - - `status` - - `last_used_at` +- отдельный usage/last-used update flow, когда ключи реально начнут использоваться в runtime; +- если захотим richer UX, можно добавить server-side pagination и фильтрацию по статусу. Отдельный конфликт: -- UI сейчас обещает scope `deploy` и формулировки про rollback/deployments; -- backend этого продуктово не поддерживает; -- scope-тексты надо выровнять до интеграции. +- одноразовый `secret` доступен только в create-response, а list endpoint возвращает только metadata; +- это уже совпадает с backend, но UX нельзя случайно перевести в режим “показывать ключ повторно”. Простой итог: -- эта страница почти закрывается текущим backend; -- здесь нужен не новый слой, а выравнивание payload и текстов. +- страница уже сажается на текущий backend без новых ручек; +- live `list/create/revoke/delete` можно считать закрытым; +- следующий реальный шаг здесь только в polishing вокруг usage и audit trail. ### 4.5. Logs