Expose approval requests in admin logs
CI / UI Checks (push) Has been cancelled
CI / Frontend E2E (push) Has been cancelled
CI / Deployment Manifests (push) Has been cancelled
CI / Deploy (push) Has been cancelled
CI / Rust Checks (push) Has been cancelled

This commit is contained in:
github-ops
2026-06-24 13:51:54 +00:00
parent 7aad3b1228
commit 8b8f2fc6c5
14 changed files with 699 additions and 42 deletions
+185
View File
@@ -134,3 +134,188 @@
}
.refresh-btn:hover { color: var(--text-secondary); background: var(--bg-muted); }
.approval-panel {
margin-bottom: 18px;
}
.approval-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 16px 20px;
border-bottom: 1px solid var(--border-subtle);
}
.approval-panel-title {
font-size: 15px;
font-weight: 650;
color: var(--text-primary);
}
.approval-panel-subtitle {
margin-top: 4px;
font-size: 12.5px;
line-height: 1.45;
color: var(--text-muted);
}
.approval-refresh-btn {
margin-left: 0;
}
.approval-list {
display: grid;
gap: 12px;
padding: 16px 20px 20px;
}
.approval-empty {
padding: 20px;
border: 1px dashed var(--border);
border-radius: 10px;
background: var(--bg-canvas);
color: var(--text-muted);
font-size: 13px;
}
.approval-empty-error {
border-color: rgba(248, 81, 73, 0.35);
color: var(--red);
}
.approval-item {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--bg-canvas);
padding: 14px;
}
.approval-pending {
border-color: rgba(210, 153, 34, 0.45);
background: linear-gradient(180deg, rgba(210, 153, 34, 0.08), var(--bg-canvas) 46%);
}
.approval-completed {
border-color: rgba(63, 185, 80, 0.28);
}
.approval-failed,
.approval-denied,
.approval-expired {
border-color: rgba(248, 81, 73, 0.26);
}
.approval-item-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.approval-item-title {
font-size: 14px;
font-weight: 650;
color: var(--text-primary);
}
.approval-item-meta,
.approval-timing,
.approval-note {
margin-top: 5px;
font-size: 11.5px;
color: var(--text-muted);
}
.approval-item-body {
margin: 10px 0 0;
font-size: 13px;
line-height: 1.55;
color: var(--text-secondary);
}
.approval-status {
flex-shrink: 0;
border: 1px solid var(--border);
border-radius: 999px;
padding: 3px 9px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.35px;
color: var(--text-muted);
background: var(--bg-overlay);
}
.approval-status-pending {
color: var(--amber);
border-color: rgba(210, 153, 34, 0.45);
background: rgba(210, 153, 34, 0.1);
}
.approval-status-completed {
color: var(--green);
border-color: rgba(63, 185, 80, 0.35);
background: rgba(63, 185, 80, 0.1);
}
.approval-status-denied,
.approval-status-expired,
.approval-status-failed {
color: var(--red);
border-color: rgba(248, 81, 73, 0.35);
background: rgba(248, 81, 73, 0.09);
}
.approval-payload-grid {
display: grid;
gap: 10px;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
margin-top: 12px;
}
.approval-payload-label {
margin-bottom: 5px;
font-size: 10.5px;
font-weight: 700;
letter-spacing: 0.45px;
text-transform: uppercase;
color: var(--text-muted);
}
.approval-payload-code {
margin: 0;
max-height: 180px;
overflow: auto;
border: 1px solid var(--border-subtle);
border-radius: 8px;
background: #161b22;
padding: 10px;
color: #c9d1d9;
font-family: 'JetBrains Mono', monospace;
font-size: 11.5px;
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
@media (max-width: 720px) {
.approval-panel-header {
align-items: stretch;
flex-direction: column;
}
.approval-refresh-btn {
justify-content: center;
}
.approval-item-header {
align-items: stretch;
flex-direction: column;
}
.approval-status {
align-self: flex-start;
}
}
+14
View File
@@ -78,6 +78,20 @@
</div>
</div>
<div class="section-card approval-panel">
<div class="approval-panel-header">
<div>
<div class="approval-panel-title" data-i18n="approvals.title">Human confirmations</div>
<div class="approval-panel-subtitle" data-i18n="approvals.subtitle">Requests waiting for an external user decision and recent results.</div>
</div>
<button class="refresh-btn approval-refresh-btn" id="approval-refresh-btn" type="button">
<svg width="12" height="12" viewBox="0 0 16 16" fill="currentColor"><path d="M1.705 8.005a.75.75 0 01.834.656 5.5 5.5 0 009.592 2.97l-1.204-1.204a.25.25 0 01.177-.427h3.646a.25.25 0 01.25.25v3.646a.25.25 0 01-.427.177l-1.38-1.38A7.001 7.001 0 011.05 8.84a.75.75 0 01.656-.834zM8 2.5a5.487 5.487 0 00-4.131 1.869l1.204 1.204A.25.25 0 014.896 6H1.25A.25.25 0 011 5.75V2.104a.25.25 0 01.427-.177l1.38 1.38A7.001 7.001 0 0114.95 7.16a.75.75 0 01-1.49.178A5.501 5.501 0 008 2.5z"/></svg>
<span data-i18n="approvals.refresh">Refresh</span>
</button>
</div>
<div class="approval-list" id="approval-list"></div>
</div>
<div class="section-card">
<div class="log-toolbar">
<div class="live-dot"></div>
+6
View File
@@ -327,6 +327,12 @@
getLog: function(workspaceId, logId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/logs/' + encodeURIComponent(logId));
},
listApprovals: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals' + query(params));
},
getApproval: function(workspaceId, approvalId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/approvals/' + encodeURIComponent(approvalId));
},
getUsageOverview: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
},
+42
View File
@@ -281,6 +281,27 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Automatic polling is paused.',
'logs.refresh.title': 'Logs refreshed',
'logs.refresh.body': 'The latest invocation records were loaded for the current workspace.',
'approvals.title': 'Human confirmations',
'approvals.subtitle': 'Requests waiting for an external user decision and recent results.',
'approvals.refresh': 'Refresh',
'approvals.refresh.title': 'Confirmations refreshed',
'approvals.refresh.body': 'The latest confirmation requests were loaded.',
'approvals.loading': 'Loading confirmation requests…',
'approvals.empty': 'There are no confirmation requests yet.',
'approvals.error.load': 'Failed to load confirmation requests',
'approvals.untitled': 'Confirmation request',
'approvals.operation': 'Operation',
'approvals.agent': 'Agent',
'approvals.expires_at': 'Expires',
'approvals.updated_at': 'Updated',
'approvals.request': 'Request',
'approvals.response': 'Result',
'approvals.status.pending': 'Pending',
'approvals.status.approved': 'Approved',
'approvals.status.denied': 'Denied',
'approvals.status.expired': 'Expired',
'approvals.status.completed': 'Completed',
'approvals.status.failed': 'Failed',
// Usage page
'usage.title': 'Usage',
@@ -1127,6 +1148,27 @@ var TRANSLATIONS = {
'logs.live.off.body': 'Автоматический опрос остановлен.',
'logs.refresh.title': 'Логи обновлены',
'logs.refresh.body': 'Получены последние записи вызовов для текущего воркспейса.',
'approvals.title': 'Подтверждения человеком',
'approvals.subtitle': 'Заявки, которые ожидают решения пользователя, и последние результаты.',
'approvals.refresh': 'Обновить',
'approvals.refresh.title': 'Подтверждения обновлены',
'approvals.refresh.body': 'Получены последние заявки на подтверждение.',
'approvals.loading': 'Загрузка заявок на подтверждение…',
'approvals.empty': 'Заявок на подтверждение пока нет.',
'approvals.error.load': 'Не удалось загрузить заявки на подтверждение',
'approvals.untitled': 'Заявка на подтверждение',
'approvals.operation': 'Операция',
'approvals.agent': 'Агент',
'approvals.expires_at': 'Истекает',
'approvals.updated_at': 'Обновлено',
'approvals.request': 'Запрос',
'approvals.response': 'Результат',
'approvals.status.pending': 'Ожидает',
'approvals.status.approved': 'Подтверждено',
'approvals.status.denied': 'Отклонено',
'approvals.status.expired': 'Истекло',
'approvals.status.completed': 'Выполнено',
'approvals.status.failed': 'Ошибка',
// Usage page
'usage.title': 'Использование',
+174 -4
View File
@@ -11,9 +11,14 @@ document.addEventListener('DOMContentLoaded', function () {
workspaceId: null,
loading: false,
loadError: '',
approvals: [],
approvalsLoading: false,
approvalsError: '',
};
var logList = document.getElementById('log-list');
var approvalList = document.getElementById('approval-list');
var approvalRefreshBtn = document.getElementById('approval-refresh-btn');
var logSearch = document.getElementById('log-search');
var refreshBtn = document.getElementById('refresh-btn');
var timeRangeSel = document.getElementById('time-range');
@@ -54,6 +59,19 @@ document.addEventListener('DOMContentLoaded', function () {
return date.toISOString().slice(11, 23);
}
function formatDateTime(timestamp) {
if (!timestamp) {
return '';
}
var date = new Date(timestamp);
return date.toLocaleString(window.CrankLocale || undefined, {
day: '2-digit',
month: 'short',
hour: '2-digit',
minute: '2-digit',
});
}
function element(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
@@ -111,6 +129,115 @@ document.addEventListener('DOMContentLoaded', function () {
};
}
function normalizeApproval(record) {
var approval = record.approval || record;
return {
id: approval.id,
agentId: approval.agent_id,
operationId: approval.operation_id,
operationVersion: approval.operation_version,
status: approval.status,
riskLevel: approval.risk_level,
title: approval.confirmation_title,
body: approval.confirmation_body,
requestPayload: approval.request_payload,
responsePayload: approval.response_payload,
createdAt: approval.created_at,
expiresAt: approval.expires_at,
decidedAt: approval.decided_at,
note: approval.decision_note,
};
}
function approvalStatusLabel(status) {
var key = 'approvals.status.' + status;
var translated = tKey(key);
return translated === key ? status : translated;
}
function renderApprovals() {
if (!approvalList) {
return;
}
approvalList.innerHTML = '';
if (state.approvalsLoading && state.approvals.length === 0) {
var loading = element('div', 'approval-empty', tKey('approvals.loading'));
approvalList.appendChild(loading);
return;
}
if (state.approvalsError) {
var error = element('div', 'approval-empty approval-empty-error', state.approvalsError);
approvalList.appendChild(error);
return;
}
if (!state.approvals.length) {
var empty = element('div', 'approval-empty', tKey('approvals.empty'));
approvalList.appendChild(empty);
return;
}
var fragment = document.createDocumentFragment();
state.approvals.forEach(function (item) {
var card = element('article', 'approval-item approval-' + item.status);
var header = element('div', 'approval-item-header');
var titleWrap = element('div', 'approval-item-title-wrap');
titleWrap.appendChild(element('div', 'approval-item-title', item.title || tKey('approvals.untitled')));
var meta = element('div', 'approval-item-meta');
meta.textContent = [
tKey('approvals.operation') + ': ' + item.operationId + ' v' + item.operationVersion,
tKey('approvals.agent') + ': ' + item.agentId,
].join(' · ');
titleWrap.appendChild(meta);
header.appendChild(titleWrap);
var badge = element('span', 'approval-status approval-status-' + item.status, approvalStatusLabel(item.status));
header.appendChild(badge);
card.appendChild(header);
if (item.body) {
card.appendChild(element('p', 'approval-item-body', item.body));
}
var timing = element('div', 'approval-timing');
timing.textContent = item.status === 'pending'
? tKey('approvals.expires_at') + ': ' + formatDateTime(item.expiresAt)
: tKey('approvals.updated_at') + ': ' + formatDateTime(item.decidedAt || item.createdAt);
card.appendChild(timing);
var payloadGrid = element('div', 'approval-payload-grid');
var requestBlock = element('div', 'approval-payload');
requestBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.request')));
var requestPre = element('pre', 'approval-payload-code');
requestPre.textContent = formatJson(item.requestPayload);
requestBlock.appendChild(requestPre);
payloadGrid.appendChild(requestBlock);
if (item.responsePayload !== null && item.responsePayload !== undefined) {
var responseBlock = element('div', 'approval-payload');
responseBlock.appendChild(element('div', 'approval-payload-label', tKey('approvals.response')));
var responsePre = element('pre', 'approval-payload-code');
responsePre.textContent = formatJson(item.responsePayload);
responseBlock.appendChild(responsePre);
payloadGrid.appendChild(responseBlock);
}
card.appendChild(payloadGrid);
if (item.note) {
card.appendChild(element('div', 'approval-note', item.note));
}
fragment.appendChild(card);
});
approvalList.appendChild(fragment);
}
function renderEmpty(title, message) {
logList.innerHTML = '';
var empty = element('div', 'empty-state');
@@ -306,6 +433,39 @@ document.addEventListener('DOMContentLoaded', function () {
}
}
async function loadApprovals() {
if (!window.CrankApi) {
state.approvalsError = tKey('logs.error.api');
renderApprovals();
return;
}
state.workspaceId = currentWorkspaceId();
if (!state.workspaceId) {
state.approvalsError = tKey('logs.error.workspace');
renderApprovals();
return;
}
state.approvalsLoading = true;
state.approvalsError = '';
renderApprovals();
try {
var response = await window.CrankApi.listApprovals(state.workspaceId, { limit: 20 });
state.approvals = (response && response.items ? response.items : []).map(normalizeApproval);
} catch (error) {
state.approvalsError = error.message || tKey('approvals.error.load');
} finally {
state.approvalsLoading = false;
renderApprovals();
}
}
async function refreshOperationalData() {
await Promise.all([loadLogs(), loadApprovals()]);
}
async function loadLogDetail(logId) {
if (!window.CrankApi || !state.workspaceId || state.details[logId]) {
return;
@@ -343,7 +503,7 @@ document.addEventListener('DOMContentLoaded', function () {
if (!state.liveMode) {
return;
}
state.timer = setInterval(loadLogs, 4000);
state.timer = setInterval(refreshOperationalData, 4000);
}
function toggleLive() {
@@ -386,6 +546,16 @@ document.addEventListener('DOMContentLoaded', function () {
});
}
if (approvalRefreshBtn) {
approvalRefreshBtn.addEventListener('click', function () {
loadApprovals().then(function () {
if (!state.approvalsError && window.CrankUi) {
window.CrankUi.info(tKey('approvals.refresh.body'), tKey('approvals.refresh.title'));
}
});
});
}
if (timeRangeSel) {
timeRangeSel.value = state.period;
timeRangeSel.addEventListener('change', function () {
@@ -405,15 +575,15 @@ document.addEventListener('DOMContentLoaded', function () {
window.addEventListener('crank:workspacechange', function () {
state.details = {};
state.openId = null;
loadLogs();
refreshOperationalData();
});
setLiveState();
startPolling();
if (window.whenWorkspacesReady) {
window.whenWorkspacesReady().finally(loadLogs);
window.whenWorkspacesReady().finally(refreshOperationalData);
} else {
loadLogs();
refreshOperationalData();
}
});