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
+6 -1
View File
@@ -19,7 +19,10 @@ use crate::{
auth_profiles::{create_auth_profile, get_auth_profile, list_auth_profiles},
capabilities::get_capabilities,
imports::{create_openapi_import, preview_openapi_import},
observability::{get_agent_usage, get_log, get_operation_usage, get_usage, list_logs},
observability::{
get_agent_usage, get_approval, get_log, get_operation_usage, get_usage, list_approvals,
list_logs,
},
operations::{
analyze_operation_quality, archive_operation, create_operation, create_version,
delete_operation, export_operation, generate_draft, get_operation,
@@ -123,6 +126,8 @@ pub fn build_app(state: AppState) -> Router {
.route("/export", get(export_workspace))
.route("/logs", get(list_logs))
.route("/logs/{log_id}", get(get_log))
.route("/approvals", get(list_approvals))
.route("/approvals/{approval_id}", get(get_approval))
.route("/usage", get(get_usage))
.route("/usage/operations/{operation_id}", get(get_operation_usage))
.route("/usage/agents/{agent_id}", get(get_agent_usage));
+10 -4
View File
@@ -1,8 +1,8 @@
use crank_core::{
AgentId, AgentStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode, GeneratedDraft,
InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel, OperationStatus,
PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target, UsagePeriod,
WizardState, WorkspaceId, WorkspaceStatus,
AgentId, AgentStatus, ApprovalRequestStatus, AuthConfig, AuthKind, ExecutionMode, ExportMode,
GeneratedDraft, InvocationLevel, InvocationSource, InvocationStatus, OperationSecurityLevel,
OperationStatus, PlatformApiKeyKind, PlatformApiKeyScope, Protocol, SecretKind, Target,
UsagePeriod, WizardState, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::MappingSet;
use crank_registry::{
@@ -250,6 +250,12 @@ pub struct LogsQuery {
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct ApprovalsQuery {
pub status: Option<ApprovalRequestStatus>,
pub limit: Option<u32>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct UsageRequestQuery {
pub period: Option<UsagePeriod>,
+33 -1
View File
@@ -7,7 +7,7 @@ use serde_json::{Value, json};
use crate::{
error::ApiError,
routes::access::WorkspacePath,
service::{LogsQuery, UsageRequestQuery},
service::{ApprovalsQuery, LogsQuery, UsageRequestQuery},
state::AppState,
};
@@ -17,6 +17,12 @@ pub struct WorkspaceLogPath {
pub log_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceApprovalPath {
pub workspace_id: String,
pub approval_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceOperationUsagePath {
pub workspace_id: String,
@@ -55,6 +61,32 @@ pub async fn get_log(
Ok(Json(json!(item)))
}
pub async fn list_approvals(
Path(path): Path<WorkspacePath>,
Query(query): Query<ApprovalsQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let items = state
.service
.list_approvals(&path.workspace_id.as_str().into(), query)
.await?;
Ok(Json(json!({ "items": items })))
}
pub async fn get_approval(
Path(path): Path<WorkspaceApprovalPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let item = state
.service
.get_approval(
&path.workspace_id.as_str().into(),
&path.approval_id.as_str().into(),
)
.await?;
Ok(Json(json!(item)))
}
pub async fn get_usage(
Path(path): Path<WorkspacePath>,
Query(query): Query<UsageRequestQuery>,
+83 -3
View File
@@ -1,11 +1,21 @@
use crank_core::{AgentId, InvocationLogId, OperationId, UsagePeriod, WorkspaceId};
use crank_registry::{InvocationLogRecord, ListInvocationLogsQuery, UsageQuery, UsageRollupRecord};
use crank_core::{
AgentId, ApprovalRequestId, ApprovalRequestStatus, InvocationLogId, OperationId, UsagePeriod,
WorkspaceId,
};
use crank_registry::{
ApprovalRequestRecord, ExpireApprovalRequest, InvocationLogRecord, ListApprovalRequestsQuery,
ListInvocationLogsQuery, UsageQuery, UsageRollupRecord,
};
use serde_json::json;
use time::OffsetDateTime;
use tracing::instrument;
use crate::{
error::ApiError,
service::{AdminService, LogsQuery, UsageOverviewResponse, UsageRequestQuery, usage_window},
service::{
AdminService, ApprovalsQuery, LogsQuery, UsageOverviewResponse, UsageRequestQuery,
usage_window,
},
};
impl AdminService {
@@ -52,6 +62,76 @@ impl AdminService {
})
}
#[instrument(skip(self))]
pub async fn list_approvals(
&self,
workspace_id: &WorkspaceId,
query: ApprovalsQuery,
) -> Result<Vec<ApprovalRequestRecord>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let records = self
.registry
.list_approval_requests(ListApprovalRequestsQuery {
workspace_id,
status: query.status,
limit: query.limit.unwrap_or(50).clamp(1, 200),
})
.await?;
let mut normalized = Vec::with_capacity(records.len());
for record in records {
let record = self.normalize_approval_record(record).await?;
if query.status.is_none() || record.approval.status == query.status.unwrap() {
normalized.push(record);
}
}
Ok(normalized)
}
#[instrument(skip(self))]
pub async fn get_approval(
&self,
workspace_id: &WorkspaceId,
approval_id: &ApprovalRequestId,
) -> Result<ApprovalRequestRecord, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let record = self
.registry
.get_approval_request(workspace_id, approval_id)
.await?
.ok_or_else(|| {
ApiError::not_found_with_context(
format!("approval request {} was not found", approval_id.as_str()),
json!({ "approval_id": approval_id.as_str() }),
)
})?;
self.normalize_approval_record(record).await
}
async fn normalize_approval_record(
&self,
record: ApprovalRequestRecord,
) -> Result<ApprovalRequestRecord, ApiError> {
if record.approval.status != ApprovalRequestStatus::Pending
|| record.approval.expires_at > OffsetDateTime::now_utc()
{
return Ok(record);
}
Ok(self
.registry
.expire_approval_request(ExpireApprovalRequest {
workspace_id: &record.approval.workspace_id,
agent_id: &record.approval.agent_id,
approval_id: &record.approval.id,
expired_at: OffsetDateTime::now_utc(),
})
.await?
.unwrap_or(record))
}
#[instrument(skip(self))]
pub async fn get_usage_overview(
&self,
+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();
}
});