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