feat: connect alpine api keys to admin api

This commit is contained in:
a.tolmachev
2026-03-30 01:36:42 +03:00
parent a489e651c2
commit 354d88618c
5 changed files with 190 additions and 144 deletions
+5 -6
View File
@@ -2,20 +2,19 @@
## Current ## Current
### `feat/alpine-agents` ### `feat/alpine-api-keys`
Status: completed Status: completed
DoD: DoD:
- `Agents` page читает live catalog агентов из `admin-api` - `API Keys` page читает live keys catalog из `admin-api`
- backend для `Agents` поддерживает `PATCH`, `DELETE` и richer summary для Alpine UI - `API Keys` page использует live `create/revoke/delete` flow вместо `keys.json`
- `Agents` page использует live `create/edit/delete` flow вместо `agents.json` - scope model и тексты в UI выровнены с реальным backend
- `Agents` page использует live operations picker вместо `operations.json`
- `test-ui` остается нетронутым как fallback - `test-ui` остается нетронутым как fallback
## Next ## Next
- `feat/alpine-api-keys` - `feat/alpine-logs-usage`
## Backlog ## Backlog
+2 -1
View File
@@ -27,6 +27,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> <body>
@@ -185,7 +186,7 @@
<div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;"> <div style="font-size:12.5px;font-weight:600;color:var(--text-primary);margin-bottom:4px;">
<span class="badge badge-scope">deploy</span> <span class="badge badge-scope">deploy</span>
</div> </div>
<div style="font-size:12px;color:var(--text-muted);line-height:1.55;">Publish draft operations, rollback deployments, and manage traffic splits.</div> <div style="font-size:12px;color:var(--text-muted);line-height:1.55;">Publish agents and operations and perform deploy-scoped platform actions.</div>
</div> </div>
</div> </div>
</div> </div>
+141 -103
View File
@@ -1,56 +1,55 @@
/* ═══════════════════════════════════════════════════
Crank — API Keys page
Data: GET /api/workspaces/{slug}/keys
═══════════════════════════════════════════════════ */
var KEYS = []; var KEYS = [];
var currentWorkspaceSlug = ''; var currentWorkspaceId = null;
var selectedScopes = new Set(['read']);
var search = '';
var SCOPES = ['read', 'write', 'deploy']; var SCOPES = ['read', 'write', 'deploy'];
var SCOPE_DESC = { var SCOPE_DESC = {
read: 'Read operations, logs, and usage', read: 'Read operations, logs, and usage',
write: 'Create and update operations', write: 'Create and update operations',
deploy: 'Publish and rollback deployments', deploy: 'Publish agents and operations',
}; };
var selectedScopes = new Set(['read']); function mapKeyRecord(record) {
var search = ''; var apiKey = record.api_key || {};
return {
// ── API helpers ────────────────────────────────────────────────────────────── id: apiKey.id,
name: apiKey.name,
function apiBase() { prefix: apiKey.prefix || '',
return '/api/workspaces/' + currentWorkspaceSlug + '/keys'; 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 currentWorkspace() {
function mockUrl() { return window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
return (window.DATA_URL || 'data/') + 'keys.json';
} }
// ── Load keys for current workspace ───────────────────────────────────────── async function loadKeys() {
var workspace = currentWorkspace();
function loadKeys() { currentWorkspaceId = workspace ? workspace.id : null;
currentWorkspaceSlug = getCurrentWs().slug;
setTableLoading(true); setTableLoading(true);
// Production: fetch(apiBase()) if (!currentWorkspaceId || !window.CrankApi) {
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 = []; KEYS = [];
setTableLoading(false); 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(); renderTable();
}); } catch (error) {
KEYS = [];
setTableLoading(false);
renderTable(error.message || 'Failed to load API keys');
}
} }
function setTableLoading(on) { function setTableLoading(on) {
@@ -66,54 +65,50 @@ function setTableLoading(on) {
tbody.appendChild(tr); tbody.appendChild(tr);
} }
// ── Mutations (in production: real fetch calls) ────────────────────────────── async function revokeKey(id) {
function revokeKey(id) {
if (!confirm('Revoke this key? All requests using it will fail immediately.')) return; if (!confirm('Revoke this key? All requests using it will fail immediately.')) return;
// Production: fetch(apiBase() + '/' + id, { method: 'PATCH', body: JSON.stringify({ status: 'revoked' }) }) try {
var k = KEYS.find(function(k) { return k.id === id; }); await window.CrankApi.revokePlatformApiKey(currentWorkspaceId, id);
if (k) { k.status = 'revoked'; renderTable(); } await loadKeys();
} catch (error) {
alert(error.message || 'Failed to revoke key');
}
} }
function deleteKey(id) { async function deleteKey(id) {
// Production: fetch(apiBase() + '/' + id, { method: 'DELETE' }) if (!confirm('Delete this key? This cannot be undone.')) return;
KEYS.splice(KEYS.findIndex(function(k) { return k.id === id; }), 1);
renderTable(); try {
await window.CrankApi.deletePlatformApiKey(currentWorkspaceId, id);
await loadKeys();
} catch (error) {
alert(error.message || 'Failed to delete key');
}
} }
function createKey(name, scopes) { async function createKey(name, scopes) {
// Production: fetch(apiBase(), { method: 'POST', body: JSON.stringify({ name, scopes }) }) var created = await window.CrankApi.createPlatformApiKey(currentWorkspaceId, {
// → 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, name: name,
prefix: prefix,
scopes: scopes, scopes: scopes,
created: new Date().toISOString().slice(0, 10), });
lastUsed: 'Never', return {
status: 'active', rawKey: created.secret,
record: mapKeyRecord(created.api_key),
}; };
KEYS.unshift(newKey);
return rawKey;
} }
// ── Render ───────────────────────────────────────────────────────────────────
function renderScopes() { function renderScopes() {
var el = document.getElementById('scope-checkboxes'); var el = document.getElementById('scope-checkboxes');
var tmpl = document.getElementById('tmpl-scope-checkbox'); var tmpl = document.getElementById('tmpl-scope-checkbox');
el.innerHTML = ''; el.innerHTML = '';
SCOPES.forEach(function(s) { SCOPES.forEach(function(scope) {
var node = tmpl.content.cloneNode(true); var node = tmpl.content.cloneNode(true);
var input = node.querySelector('input'); var input = node.querySelector('input');
input.dataset.scope = s; input.dataset.scope = scope;
if (selectedScopes.has(s)) input.checked = true; if (selectedScopes.has(scope)) input.checked = true;
node.querySelector('.scope-checkbox-name').textContent = s; node.querySelector('.scope-checkbox-name').textContent = scope;
node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[s]; node.querySelector('.scope-checkbox-desc').textContent = SCOPE_DESC[scope];
input.addEventListener('change', function() { input.addEventListener('change', function() {
if (this.checked) selectedScopes.add(this.dataset.scope); if (this.checked) selectedScopes.add(this.dataset.scope);
else selectedScopes.delete(this.dataset.scope); else selectedScopes.delete(this.dataset.scope);
@@ -122,15 +117,33 @@ function renderScopes() {
}); });
} }
function renderTable() { function renderTable(errorMessage) {
var q = search.toLowerCase(); var q = search.toLowerCase();
var tbody = document.getElementById('keys-tbody'); var tbody = document.getElementById('keys-tbody');
var rows = KEYS.filter(function(k) { var rows = KEYS.filter(function(key) {
return !q || k.name.toLowerCase().includes(q) || k.prefix.includes(q); return !q || key.name.toLowerCase().includes(q) || key.prefix.toLowerCase().includes(q);
}); });
var subtitle = document.querySelector('.section-card-subtitle');
tbody.innerHTML = ''; 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) { if (!rows.length) {
var empty = document.createElement('tr'); var empty = document.createElement('tr');
var td = document.createElement('td'); var td = document.createElement('td');
@@ -143,46 +156,47 @@ function renderTable() {
} }
var tmpl = document.getElementById('tmpl-key-row'); var tmpl = document.getElementById('tmpl-key-row');
rows.forEach(function(k) { rows.forEach(function(key) {
var node = tmpl.content.cloneNode(true); var node = tmpl.content.cloneNode(true);
node.querySelector('.col-name').textContent = k.name; node.querySelector('.col-name').textContent = key.name;
node.querySelector('.col-mono').textContent = k.prefix; node.querySelector('.col-mono').textContent = key.prefix;
node.querySelector('.col-created').textContent = k.created; node.querySelector('.col-created').textContent = key.created;
node.querySelector('.col-last-used').textContent = k.lastUsed; node.querySelector('.col-last-used').textContent = key.lastUsed;
var scopesEl = node.querySelector('.col-scopes'); var scopesEl = node.querySelector('.col-scopes');
k.scopes.forEach(function(s) { key.scopes.forEach(function(scope) {
var span = document.createElement('span'); var span = document.createElement('span');
span.className = 'badge badge-scope'; span.className = 'badge badge-scope';
span.textContent = s; span.textContent = scope;
scopesEl.appendChild(span); scopesEl.appendChild(span);
}); });
var badge = document.createElement('span'); var badge = document.createElement('span');
badge.className = k.status === 'active' ? 'badge badge-active' : 'badge badge-revoked'; badge.className = key.status === 'active' ? 'badge badge-active' : 'badge badge-revoked';
badge.textContent = k.status === 'active' ? 'Active' : 'Revoked'; badge.textContent = key.status === 'active' ? 'Active' : 'Revoked';
node.querySelector('.col-status').appendChild(badge); node.querySelector('.col-status').appendChild(badge);
var actionsActive = node.querySelector('.actions-active'); var actionsActive = node.querySelector('.actions-active');
var actionsRevoked = node.querySelector('.actions-revoked'); var actionsRevoked = node.querySelector('.actions-revoked');
if (k.status === 'active') { if (key.status === 'active') {
actionsActive.querySelector('[title="Copy key prefix"]') actionsActive.querySelector('[title=\"Copy key prefix\"]').addEventListener('click', function() {
.addEventListener('click', function() { copyPrefix(k.prefix); }); copyPrefix(key.prefix);
actionsActive.querySelector('[title="Revoke key"]') });
.addEventListener('click', function() { revokeKey(k.id); }); actionsActive.querySelector('[title=\"Revoke key\"]').addEventListener('click', function() {
revokeKey(key.id);
});
} else { } else {
actionsActive.style.display = 'none'; actionsActive.style.display = 'none';
actionsRevoked.style.display = ''; actionsRevoked.style.display = '';
actionsRevoked.querySelector('[title="Delete"]') actionsRevoked.querySelector('[title=\"Delete\"]').addEventListener('click', function() {
.addEventListener('click', function() { deleteKey(k.id); }); deleteKey(key.id);
});
} }
tbody.appendChild(node); tbody.appendChild(node);
}); });
} }
// ── Modal ────────────────────────────────────────────────────────────────────
var modal = document.getElementById('modal-create'); var modal = document.getElementById('modal-create');
function openModal() { function openModal() {
@@ -194,37 +208,55 @@ function openModal() {
selectedScopes = new Set(['read']); selectedScopes = new Set(['read']);
renderScopes(); renderScopes();
modal.classList.add('open'); 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('btn-create-key').addEventListener('click', openModal);
document.getElementById('modal-close-btn').addEventListener('click', closeModal); document.getElementById('modal-close-btn').addEventListener('click', closeModal);
document.getElementById('modal-cancel-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(); var name = document.getElementById('new-key-name').value.trim();
if (!name) { document.getElementById('new-key-name').focus(); return; } if (!name) {
if (!selectedScopes.size) { alert('Select at least one scope.'); return; } document.getElementById('new-key-name').focus();
return;
}
if (!selectedScopes.size) {
alert('Select at least one scope.');
return;
}
var rawKey = createKey(name, Array.from(selectedScopes)); try {
var created = await createKey(name, Array.from(selectedScopes));
document.getElementById('reveal-key-value').textContent = rawKey; KEYS.unshift(created.record);
document.getElementById('reveal-key-value').textContent = created.rawKey;
document.getElementById('modal-form-body').style.display = 'none'; document.getElementById('modal-form-body').style.display = 'none';
document.getElementById('modal-reveal-body').style.display = ''; document.getElementById('modal-reveal-body').style.display = '';
document.getElementById('modal-footer-create').style.display = 'none'; document.getElementById('modal-footer-create').style.display = 'none';
document.getElementById('modal-footer-done').style.display = ''; document.getElementById('modal-footer-done').style.display = '';
renderTable(); renderTable();
} catch (error) {
alert(error.message || 'Failed to create key');
}
}); });
document.getElementById('modal-done-btn').addEventListener('click', closeModal); document.getElementById('modal-done-btn').addEventListener('click', closeModal);
document.getElementById('copy-key-btn').addEventListener('click', function() { document.getElementById('copy-key-btn').addEventListener('click', function() {
var val = document.getElementById('reveal-key-value').textContent; var value = document.getElementById('reveal-key-value').textContent;
navigator.clipboard && navigator.clipboard.writeText(val); if (navigator.clipboard) {
this.innerHTML = '<svg width="13" height="13"><use href="' + (window.APP_BASE||'') + 'icons/general/check.svg#icon"/></svg>'; navigator.clipboard.writeText(value).catch(function() {});
}
this.innerHTML = '<svg width="13" height="13"><use href="' + (window.APP_BASE || '') + 'icons/general/check.svg#icon"/></svg>';
}); });
document.getElementById('key-search').addEventListener('input', function() { document.getElementById('key-search').addEventListener('input', function() {
@@ -232,10 +264,16 @@ document.getElementById('key-search').addEventListener('input', function() {
renderTable(); renderTable();
}); });
// ── Init ─────────────────────────────────────────────────────────────────────
function copyPrefix(prefix) { 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();
});
});
+12
View File
@@ -131,5 +131,17 @@
publishAgent: function(workspaceId, agentId, payload) { publishAgent: function(workspaceId, agentId, payload) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/agents/' + encodeURIComponent(agentId) + '/publish', 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));
},
}; };
}()); }());
+7 -11
View File
@@ -200,23 +200,19 @@ UI-файлы:
Что еще не хватает: Что еще не хватает:
- финальная договоренность по scope model; - отдельный usage/last-used update flow, когда ключи реально начнут использоваться в runtime;
- согласование response shape с UI: - если захотим richer UX, можно добавить server-side pagination и фильтрацию по статусу.
- `secret`
- `prefix`
- `status`
- `last_used_at`
Отдельный конфликт: Отдельный конфликт:
- UI сейчас обещает scope `deploy` и формулировки про rollback/deployments; - одноразовый `secret` доступен только в create-response, а list endpoint возвращает только metadata;
- backend этого продуктово не поддерживает; - это уже совпадает с backend, но UX нельзя случайно перевести в режим “показывать ключ повторно”.
- scope-тексты надо выровнять до интеграции.
Простой итог: Простой итог:
- эта страница почти закрывается текущим backend; - страница уже сажается на текущий backend без новых ручек;
- здесь нужен не новый слой, а выравнивание payload и текстов. - live `list/create/revoke/delete` можно считать закрытым;
- следующий реальный шаг здесь только в polishing вокруг usage и audit trail.
### 4.5. Logs ### 4.5. Logs