feat: add streaming ui configuration

This commit is contained in:
a.tolmachev
2026-04-06 12:04:49 +03:00
parent fdd0a45124
commit b40daf4f54
20 changed files with 2254 additions and 20 deletions
+16 -1
View File
@@ -28,6 +28,10 @@ use crate::{
upload_descriptor_set, upload_input_json, upload_output_json, upload_proto_descriptor,
},
secrets::{create_secret, delete_secret, get_secret, list_secrets, rotate_secret},
streaming::{
cancel_async_job, get_async_job, get_async_job_result, get_stream_session,
list_async_jobs, list_protocol_capabilities, list_stream_sessions, stop_stream_session,
},
workspaces::{create_workspace, get_workspace, list_workspaces, update_workspace},
},
state::AppState,
@@ -133,7 +137,18 @@ pub fn build_app(state: AppState) -> Router {
.route("/logs/{log_id}", get(get_log))
.route("/usage", get(get_usage))
.route("/usage/operations/{operation_id}", get(get_operation_usage))
.route("/usage/agents/{agent_id}", get(get_agent_usage));
.route("/usage/agents/{agent_id}", get(get_agent_usage))
.route("/protocol-capabilities", get(list_protocol_capabilities))
.route("/stream-sessions", get(list_stream_sessions))
.route("/stream-sessions/{session_id}", get(get_stream_session))
.route(
"/stream-sessions/{session_id}/stop",
post(stop_stream_session),
)
.route("/async-jobs", get(list_async_jobs))
.route("/async-jobs/{job_id}", get(get_async_job))
.route("/async-jobs/{job_id}/cancel", post(cancel_async_job))
.route("/async-jobs/{job_id}/result", get(get_async_job_result));
let workspace_root_router = Router::new()
.route("/workspaces", get(list_workspaces).post(create_workspace))
+1
View File
@@ -5,6 +5,7 @@ pub mod auth_profiles;
pub mod observability;
pub mod operations;
pub mod secrets;
pub mod streaming;
pub mod workspaces;
use axum::Json;
+137
View File
@@ -0,0 +1,137 @@
use axum::{
Json,
extract::{Path, Query, State},
};
use serde_json::{Value, json};
use crate::{
error::ApiError,
routes::access::WorkspacePath,
service::{AsyncJobsQuery, StreamSessionsQuery},
state::AppState,
};
#[derive(serde::Deserialize)]
pub struct WorkspaceStreamSessionPath {
pub workspace_id: String,
pub session_id: String,
}
#[derive(serde::Deserialize)]
pub struct WorkspaceAsyncJobPath {
pub workspace_id: String,
pub job_id: String,
}
pub async fn list_protocol_capabilities(
Path(_path): Path<WorkspacePath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
Ok(Json(json!({
"items": state.service.list_protocol_capabilities().await
})))
}
pub async fn list_stream_sessions(
Path(path): Path<WorkspacePath>,
Query(query): Query<StreamSessionsQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let page = state
.service
.list_stream_sessions(&path.workspace_id.as_str().into(), query.clone())
.await?;
Ok(Json(json!({
"items": page.items,
"page": query.page.unwrap_or(1),
"page_size": query.page_size.unwrap_or(20),
"total": page.total,
})))
}
pub async fn get_stream_session(
Path(path): Path<WorkspaceStreamSessionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let session = state
.service
.get_stream_session(
&path.workspace_id.as_str().into(),
&path.session_id.as_str().into(),
)
.await?;
Ok(Json(json!(session)))
}
pub async fn stop_stream_session(
Path(path): Path<WorkspaceStreamSessionPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let session = state
.service
.stop_stream_session(
&path.workspace_id.as_str().into(),
&path.session_id.as_str().into(),
)
.await?;
Ok(Json(json!(session)))
}
pub async fn list_async_jobs(
Path(path): Path<WorkspacePath>,
Query(query): Query<AsyncJobsQuery>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let page = state
.service
.list_async_jobs(&path.workspace_id.as_str().into(), query.clone())
.await?;
Ok(Json(json!({
"items": page.items,
"page": query.page.unwrap_or(1),
"page_size": query.page_size.unwrap_or(20),
"total": page.total,
})))
}
pub async fn get_async_job(
Path(path): Path<WorkspaceAsyncJobPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let job = state
.service
.get_async_job(
&path.workspace_id.as_str().into(),
&path.job_id.as_str().into(),
)
.await?;
Ok(Json(json!(job)))
}
pub async fn cancel_async_job(
Path(path): Path<WorkspaceAsyncJobPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let job = state
.service
.cancel_async_job(
&path.workspace_id.as_str().into(),
&path.job_id.as_str().into(),
)
.await?;
Ok(Json(json!(job)))
}
pub async fn get_async_job_result(
Path(path): Path<WorkspaceAsyncJobPath>,
State(state): State<AppState>,
) -> Result<Json<Value>, ApiError> {
let result = state
.service
.get_async_job_result(
&path.workspace_id.as_str().into(),
&path.job_id.as_str().into(),
)
.await?;
Ok(Json(json!(result)))
}
+384 -11
View File
@@ -7,26 +7,27 @@ use base64::{
};
use crank_adapter_grpc::test_support as grpc_test_support;
use crank_core::{
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthConfig, AuthKind,
AuthProfile, AuthProfileId, ConfigExport, ExportMode, GeneratedDraft, GeneratedDraftStatus,
InvitationId, InvitationStatus, InvitationToken, InvocationLevel, InvocationLog,
InvocationLogId, InvocationSource, InvocationStatus, MembershipRole, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus,
Protocol, SampleId, Samples, Secret, SecretId, SecretKind, SecretStatus, Target, UsagePeriod,
UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus,
Agent, AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AggregationMode,
AsyncJobHandle, AuthConfig, AuthKind, AuthProfile, AuthProfileId, ConfigExport, ExecutionMode,
ExportMode, GeneratedDraft, GeneratedDraftStatus, InvitationId, InvitationStatus,
InvitationToken, InvocationLevel, InvocationLog, InvocationLogId, InvocationSource,
InvocationStatus, JobStatus, MembershipRole, OperationId, OperationStatus, PlatformApiKey,
PlatformApiKeyId, PlatformApiKeyScope, PlatformApiKeyStatus, Protocol, SampleId, Samples,
Secret, SecretId, SecretKind, SecretStatus, StreamSession, StreamStatus, Target,
TransportBehavior, UsagePeriod, UserId, UserSessionId, Workspace, WorkspaceId, WorkspaceStatus,
};
use crank_mapping::{JsonPathRoot, MappingSet, infer_mapping_from_samples};
use crank_proto::{ProtoService, services_from_descriptor_set_bytes};
use crank_registry::{
AgentSummary, AgentVersionRecord, CreateAgentRequest, CreateInvitationRequest,
AgentSummary, AgentVersionRecord, AsyncJobFilter, CreateAgentRequest, CreateInvitationRequest,
CreateInvocationLogRequest, CreatePlatformApiKeyRequest, CreateSecretRequest,
CreateVersionRequest, CreateWorkspaceRequest, InvitationRecord, InvocationLogRecord,
ListInvocationLogsQuery, MembershipRecord, OperationAgentRef, OperationSampleMetadata,
OperationSummary, OperationUsageSummary, OperationVersionRecord, PlatformApiKeyRecord,
OperationSummary, OperationUsageSummary, OperationVersionRecord, Page, PlatformApiKeyRecord,
PostgresRegistry, PublishAgentRequest, PublishRequest, RegistryOperation, RotateSecretRequest,
SampleKind, SaveAgentBindingsRequest, SaveAuthProfileRequest, SaveDescriptorMetadataRequest,
SaveSampleMetadataRequest, UpdateWorkspaceRequest, UsageAgentBreakdown, UsageBucket,
UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint,
SaveSampleMetadataRequest, StreamSessionFilter, UpdateWorkspaceRequest, UsageAgentBreakdown,
UsageBucket, UsageOperationBreakdown, UsageQuery, UsageSummary, UsageTimelinePoint,
WorkspaceMembershipRecord, WorkspaceRecord,
};
use crank_runtime::{PreparedRequest, RuntimeError, RuntimeExecutor, RuntimeOperation};
@@ -303,6 +304,78 @@ pub struct UsageOverviewResponse {
pub agents: Vec<UsageAgentBreakdown>,
}
#[derive(Clone, Debug, Serialize)]
pub struct ProtocolCapabilityView {
pub protocol: Protocol,
pub supports_execution_modes: Vec<ExecutionMode>,
pub supports_transport_behaviors: Vec<TransportBehavior>,
pub supports_auth_kinds: Vec<String>,
pub supports_upload_artifacts: Vec<String>,
pub supports_cursor_path: bool,
pub supports_done_path: bool,
pub supports_aggregation_mode: Vec<AggregationMode>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct StreamSessionsQuery {
pub operation_id: Option<String>,
pub agent_id: Option<String>,
pub status: Option<StreamStatus>,
pub mode: Option<ExecutionMode>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, Serialize)]
pub struct StreamSessionSummaryView {
pub id: String,
pub operation_id: String,
pub agent_id: Option<String>,
pub protocol: Protocol,
pub mode: ExecutionMode,
pub status: StreamStatus,
pub expires_at: String,
pub last_poll_at: Option<String>,
pub created_at: String,
pub closed_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct StreamSessionDetailView {
#[serde(flatten)]
pub summary: StreamSessionSummaryView,
pub cursor: Option<Value>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct AsyncJobsQuery {
pub operation_id: Option<String>,
pub agent_id: Option<String>,
pub status: Option<JobStatus>,
pub page: Option<u32>,
pub page_size: Option<u32>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AsyncJobSummaryView {
pub id: String,
pub operation_id: String,
pub agent_id: Option<String>,
pub status: JobStatus,
pub progress: Value,
pub created_at: String,
pub updated_at: String,
pub finished_at: Option<String>,
pub expires_at: Option<String>,
}
#[derive(Clone, Debug, Serialize)]
pub struct AsyncJobDetailView {
#[serde(flatten)]
pub summary: AsyncJobSummaryView,
pub error: Option<Value>,
}
#[derive(Clone, Debug, Deserialize)]
pub struct GenerateDraftPayload {
#[serde(default)]
@@ -1273,6 +1346,185 @@ impl AdminService {
})
}
pub async fn list_protocol_capabilities(&self) -> Vec<ProtocolCapabilityView> {
[Protocol::Rest, Protocol::Graphql, Protocol::Grpc]
.into_iter()
.map(protocol_capability_view)
.collect()
}
#[instrument(skip(self))]
pub async fn list_stream_sessions(
&self,
workspace_id: &WorkspaceId,
query: StreamSessionsQuery,
) -> Result<Page<StreamSessionSummaryView>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let operation_id = query.operation_id.as_deref().map(OperationId::new);
let agent_id = query.agent_id.as_deref().map(AgentId::new);
let limit = query.page_size.unwrap_or(20).max(1);
let page = self
.registry
.list_stream_sessions(StreamSessionFilter {
workspace_id,
agent_id: agent_id.as_ref(),
operation_id: operation_id.as_ref(),
status: query.status,
mode: query.mode,
limit,
})
.await?;
Ok(Page {
items: page
.items
.into_iter()
.map(stream_session_summary_view)
.collect(),
total: page.total,
})
}
#[instrument(skip(self))]
pub async fn get_stream_session(
&self,
workspace_id: &WorkspaceId,
session_id: &crank_core::StreamSessionId,
) -> Result<StreamSessionDetailView, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let session = self
.registry
.get_stream_session(session_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"stream session {} was not found",
session_id.as_str()
))
})?;
ensure_stream_session_workspace(&session, workspace_id)?;
Ok(stream_session_detail_view(session))
}
#[instrument(skip(self))]
pub async fn stop_stream_session(
&self,
workspace_id: &WorkspaceId,
session_id: &crank_core::StreamSessionId,
) -> Result<StreamSessionDetailView, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let session = self
.registry
.get_stream_session(session_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"stream session {} was not found",
session_id.as_str()
))
})?;
ensure_stream_session_workspace(&session, workspace_id)?;
self.registry
.close_stream_session(session_id, &now_string()?)
.await?;
let updated = self
.registry
.get_stream_session(session_id)
.await?
.ok_or_else(|| {
ApiError::not_found(format!(
"stream session {} was not found",
session_id.as_str()
))
})?;
Ok(stream_session_detail_view(updated))
}
#[instrument(skip(self))]
pub async fn list_async_jobs(
&self,
workspace_id: &WorkspaceId,
query: AsyncJobsQuery,
) -> Result<Page<AsyncJobSummaryView>, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let operation_id = query.operation_id.as_deref().map(OperationId::new);
let agent_id = query.agent_id.as_deref().map(AgentId::new);
let limit = query.page_size.unwrap_or(20).max(1);
let page = self
.registry
.list_async_jobs(AsyncJobFilter {
workspace_id,
agent_id: agent_id.as_ref(),
operation_id: operation_id.as_ref(),
status: query.status,
limit,
})
.await?;
Ok(Page {
items: page.items.into_iter().map(async_job_summary_view).collect(),
total: page.total,
})
}
#[instrument(skip(self))]
pub async fn get_async_job(
&self,
workspace_id: &WorkspaceId,
job_id: &crank_core::AsyncJobId,
) -> Result<AsyncJobDetailView, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
})?;
ensure_async_job_workspace(&job, workspace_id)?;
Ok(async_job_detail_view(job))
}
#[instrument(skip(self))]
pub async fn cancel_async_job(
&self,
workspace_id: &WorkspaceId,
job_id: &crank_core::AsyncJobId,
) -> Result<AsyncJobDetailView, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
})?;
ensure_async_job_workspace(&job, workspace_id)?;
self.registry
.cancel_async_job(job_id, &now_string()?)
.await?;
let updated = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
})?;
Ok(async_job_detail_view(updated))
}
#[instrument(skip(self))]
pub async fn get_async_job_result(
&self,
workspace_id: &WorkspaceId,
job_id: &crank_core::AsyncJobId,
) -> Result<Value, ApiError> {
self.ensure_workspace_exists(workspace_id).await?;
let job = self.registry.get_async_job(job_id).await?.ok_or_else(|| {
ApiError::not_found(format!("async job {} was not found", job_id.as_str()))
})?;
ensure_async_job_workspace(&job, workspace_id)?;
match job.status {
JobStatus::Completed => Ok(job.result.unwrap_or(Value::Null)),
JobStatus::Failed => Err(ApiError::conflict("async job failed")),
JobStatus::Cancelled => Err(ApiError::conflict("async job was cancelled")),
_ => Err(ApiError::conflict("async job result is not ready")),
}
}
pub async fn list_operations(
&self,
workspace_id: &WorkspaceId,
@@ -3762,6 +4014,127 @@ fn runtime_error_code(error: &RuntimeError) -> &'static str {
}
}
fn protocol_capability_view(protocol: Protocol) -> ProtocolCapabilityView {
let supports_execution_modes = [
ExecutionMode::Unary,
ExecutionMode::Window,
ExecutionMode::Session,
ExecutionMode::AsyncJob,
]
.into_iter()
.filter(|mode| protocol.supports_execution_mode(*mode))
.collect();
let supports_transport_behaviors = [
TransportBehavior::RequestResponse,
TransportBehavior::ServerStream,
TransportBehavior::StatefulSession,
TransportBehavior::DeferredResult,
]
.into_iter()
.filter(|behavior| protocol.supports_transport_behavior(*behavior))
.collect();
let supports_upload_artifacts = match protocol {
Protocol::Rest | Protocol::Graphql => Vec::new(),
Protocol::Grpc => vec!["proto".to_owned(), "descriptor_set".to_owned()],
};
ProtocolCapabilityView {
protocol,
supports_execution_modes,
supports_transport_behaviors,
supports_auth_kinds: vec![
"none".to_owned(),
"bearer".to_owned(),
"basic".to_owned(),
"api_key_header".to_owned(),
"api_key_query".to_owned(),
],
supports_upload_artifacts,
supports_cursor_path: !matches!(protocol, Protocol::Graphql),
supports_done_path: !matches!(protocol, Protocol::Graphql),
supports_aggregation_mode: vec![
AggregationMode::RawItems,
AggregationMode::SummaryOnly,
AggregationMode::SummaryPlusSamples,
AggregationMode::Stats,
AggregationMode::LatestState,
],
}
}
fn stream_session_summary_view(session: StreamSession) -> StreamSessionSummaryView {
StreamSessionSummaryView {
id: session.id.as_str().to_owned(),
operation_id: session.operation_id.as_str().to_owned(),
agent_id: session.agent_id.map(|value| value.as_str().to_owned()),
protocol: session.protocol,
mode: session.mode,
status: session.status,
expires_at: session.expires_at,
last_poll_at: session.last_poll_at,
created_at: session.created_at,
closed_at: session.closed_at,
}
}
fn stream_session_detail_view(session: StreamSession) -> StreamSessionDetailView {
StreamSessionDetailView {
cursor: session.cursor.clone(),
summary: stream_session_summary_view(session),
}
}
fn async_job_summary_view(job: AsyncJobHandle) -> AsyncJobSummaryView {
AsyncJobSummaryView {
id: job.id.as_str().to_owned(),
operation_id: job.operation_id.as_str().to_owned(),
agent_id: job.agent_id.map(|value| value.as_str().to_owned()),
status: job.status,
progress: job.progress,
created_at: job.created_at,
updated_at: job.updated_at,
finished_at: job.finished_at,
expires_at: job.expires_at,
}
}
fn async_job_detail_view(job: AsyncJobHandle) -> AsyncJobDetailView {
AsyncJobDetailView {
error: job.error.clone(),
summary: async_job_summary_view(job),
}
}
fn ensure_stream_session_workspace(
session: &StreamSession,
workspace_id: &WorkspaceId,
) -> Result<(), ApiError> {
if &session.workspace_id == workspace_id {
return Ok(());
}
Err(ApiError::not_found(format!(
"stream session {} was not found in workspace {}",
session.id.as_str(),
workspace_id.as_str()
)))
}
fn ensure_async_job_workspace(
job: &AsyncJobHandle,
workspace_id: &WorkspaceId,
) -> Result<(), ApiError> {
if &job.workspace_id == workspace_id {
return Ok(());
}
Err(ApiError::not_found(format!(
"async job {} was not found in workspace {}",
job.id.as_str(),
workspace_id.as_str()
)))
}
fn usage_window(period: UsagePeriod) -> Result<(UsagePeriod, String, UsageBucket), ApiError> {
let now = OffsetDateTime::now_utc();
let (start, bucket) = match period {
+171
View File
@@ -1550,3 +1550,174 @@
width: auto;
}
}
.list-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 16px;
flex-wrap: wrap;
}
.list-toolbar-group {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.page-select {
min-height: 36px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-overlay);
color: var(--text-secondary);
font-size: 13px;
font-family: 'Inter', sans-serif;
}
.resource-list {
display: grid;
gap: 14px;
}
.resource-card {
background: var(--bg-overlay);
border: 1px solid var(--border);
border-radius: 14px;
padding: 16px;
}
.resource-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 14px;
margin-bottom: 12px;
}
.resource-card-title {
font-size: 14px;
font-weight: 600;
color: var(--text-primary);
}
.resource-card-subtitle {
margin-top: 4px;
font-size: 12px;
color: var(--text-muted);
word-break: break-all;
}
.resource-card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.resource-meta-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 12px;
margin-bottom: 14px;
}
.resource-meta-item {
min-width: 0;
}
.resource-meta-label {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--text-muted);
margin-bottom: 4px;
}
.resource-meta-value {
font-size: 13px;
color: var(--text-primary);
line-height: 1.5;
word-break: break-word;
}
.resource-detail-block {
margin-top: 12px;
border-top: 1px solid var(--border-subtle);
padding-top: 12px;
}
.resource-detail-title {
font-size: 12px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: 8px;
}
.resource-detail-pre {
margin: 0;
padding: 12px 14px;
border-radius: 12px;
background: rgba(12, 17, 24, 0.8);
border: 1px solid var(--border-subtle);
color: var(--text-secondary);
font-size: 12px;
line-height: 1.5;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
.resource-pill-row {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
}
.resource-status-pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 5px 9px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
border: 1px solid var(--border);
background: rgba(255, 255, 255, 0.04);
color: var(--text-secondary);
}
.resource-status-pill.active,
.resource-status-pill.running,
.resource-status-pill.succeeded {
border-color: rgba(63, 185, 80, 0.28);
background: rgba(63, 185, 80, 0.12);
color: #71dd8a;
}
.resource-status-pill.idle,
.resource-status-pill.pending {
border-color: rgba(110, 118, 129, 0.28);
background: rgba(110, 118, 129, 0.12);
color: var(--text-secondary);
}
.resource-status-pill.failed,
.resource-status-pill.cancelled,
.resource-status-pill.stopped {
border-color: rgba(248, 81, 73, 0.28);
background: rgba(248, 81, 73, 0.12);
color: #ff8a83;
}
.resource-status-pill.completed {
border-color: rgba(56, 189, 248, 0.28);
background: rgba(56, 189, 248, 0.12);
color: #79d8ff;
}
+32
View File
@@ -137,6 +137,38 @@
align-items: flex-start;
}
.checkbox-pill {
display: inline-flex;
align-items: center;
gap: 10px;
min-height: 38px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--bg-overlay);
color: var(--text-secondary);
font-size: 13px;
cursor: pointer;
transition: border-color 0.15s ease, background 0.15s ease, color 0.15s ease;
}
.checkbox-pill:hover {
border-color: var(--bg-muted);
color: var(--text-primary);
}
.checkbox-pill input {
width: 15px;
height: 15px;
accent-color: var(--accent);
}
.checkbox-pill:has(input:checked) {
border-color: rgba(13, 148, 136, 0.35);
background: rgba(13, 148, 136, 0.12);
color: var(--text-primary);
}
/*
STEP SIDEBAR
*/
+112
View File
@@ -0,0 +1,112 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Async Jobs</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body>
<nav class="navbar">
<a href="/" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
<span data-i18n="nav.create_workspace">Create workspace</span>
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd"><svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg><span>⌘K</span></div>
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header"><div class="user-dropdown-name">Crank</div><div class="user-dropdown-role"></div></div>
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="page">
<div class="page-header">
<div class="page-header-text">
<h1 class="page-title" data-i18n="async_jobs.title">Async jobs</h1>
<p class="page-subtitle" data-i18n="async_jobs.subtitle">Inspect deferred jobs started by streaming MCP tool families.</p>
</div>
<div class="page-header-actions">
<button class="btn-secondary" id="async-jobs-refresh" type="button" data-i18n="async_jobs.refresh">Refresh</button>
</div>
</div>
<div class="section-card">
<div class="section-card-body">
<div class="list-toolbar">
<div class="list-toolbar-group">
<select class="page-select" id="async-jobs-status">
<option value="" data-i18n="async_jobs.filter.all_statuses">All statuses</option>
<option value="created" data-i18n="async_jobs.status.created">Created</option>
<option value="running" data-i18n="async_jobs.status.running">Running</option>
<option value="completed" data-i18n="async_jobs.status.completed">Completed</option>
<option value="failed" data-i18n="async_jobs.status.failed">Failed</option>
<option value="cancelled" data-i18n="async_jobs.status.cancelled">Cancelled</option>
<option value="expired" data-i18n="async_jobs.status.expired">Expired</option>
</select>
</div>
<div class="list-toolbar-group">
<span class="section-card-subtitle" id="async-jobs-summary"></span>
</div>
</div>
<div class="resource-list" id="async-jobs-list"></div>
</div>
</div>
</div>
<script src="js/nav.js"></script>
<script src="js/async-jobs.js"></script>
</body>
</html>
+117
View File
@@ -0,0 +1,117 @@
<!DOCTYPE html>
<html lang="en">
<head>
<base href="../">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Crank — Stream Sessions</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="css/variables.css">
<link rel="stylesheet" href="css/layout.css">
<link rel="stylesheet" href="css/pages.css">
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/ui-feedback.js"></script>
<script src="js/workspace.js"></script>
<script src="js/auth.js"></script>
<script>window.CrankAuth.guardProtectedPage();</script>
</head>
<body>
<nav class="navbar">
<a href="/" class="nav-logo">
<div class="nav-logo-mark"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 28 28" fill="white"><path fill-rule="evenodd" d="M 10.000,1.800 L 12.287,3.962 L 15.408,3.557 L 15.987,6.650 L 18.750,8.157 L 17.400,11.000 L 18.750,13.843 L 15.987,15.350 L 15.408,18.443 L 12.287,18.038 L 10.000,20.200 L 7.713,18.038 L 4.592,18.443 L 4.013,15.350 L 1.250,13.843 L 2.600,11.000 L 1.250,8.157 L 4.013,6.650 L 4.592,3.557 L 7.713,3.962 Z M 12.800,11.000 A 2.8 2.8 0 1 0 7.200,11.000 A 2.8 2.8 0 1 0 12.800,11.000 Z"/><path fill-rule="evenodd" d="M 11.791,15.604 Q 16.083,17.179 21.375,21.154 A 2.00 2.00 0 1 1 23.625,17.846 Q 19.782,15.610 14.940,10.973 Z M 24.300,19.500 A 1.8 1.8 0 1 0 20.700,19.500 A 1.8 1.8 0 1 0 24.300,19.500 Z"/><path d="M 10.0,9.7 L 11.3,11.0 L 10.0,12.3 L 8.7,11.0 Z" fill="rgba(0,0,0,0.3)"/></svg></div>
<span class="nav-logo-text">Crank</span>
</a>
<div class="ws-switcher" id="ws-switcher">
<button class="ws-switcher-trigger" onclick="toggleWsSwitcher(event)">
<div class="ws-dot" id="ws-dot">A</div>
<span class="ws-current-name" id="ws-current-name">workspace</span>
<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6l4 4 4-4"/></svg>
</button>
<div class="ws-dropdown" id="ws-dropdown" style="display:none">
<div class="ws-dropdown-label" data-i18n="nav.workspaces">Your workspaces</div>
<div id="ws-dropdown-list"></div>
<div class="ws-dropdown-footer">
<button class="ws-dropdown-create" onclick="window.location.href='/workspace-setup?mode=create'">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M8 3v10M3 8h10"/></svg>
<span data-i18n="nav.create_workspace">Create workspace</span>
</button>
</div>
</div>
</div>
<div class="nav-links">
<a href="/" class="nav-link" data-i18n="nav.operations">Operations</a>
<a href="/agents" class="nav-link" data-i18n="nav.agents">Agents</a>
<a href="/api-keys" class="nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="/logs" class="nav-link" data-i18n="nav.logs">Logs</a>
<a href="/usage" class="nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="nav-right">
<button class="nav-hamburger" data-i18n-title="nav.menu" data-i18n-aria-label="nav.menu" aria-label="Menu"><span></span><span></span><span></span></button>
<div class="nav-kbd"><svg width="12" height="12"><use href="icons/general/grid.svg#icon"/></svg><span>⌘K</span></div>
<button class="nav-icon-btn" data-i18n-title="nav.notifications" title="Notifications"><svg width="15" height="15"><use href="icons/general/bell.svg#icon"/></svg></button>
<div class="nav-divider"></div>
<div class="user-menu">
<div class="nav-avatar" data-i18n-title="nav.account" title="Account">AT</div>
<div class="user-dropdown">
<div class="user-dropdown-header"><div class="user-dropdown-name">Crank</div><div class="user-dropdown-role"></div></div>
<button class="user-dropdown-item" data-action="settings-link"><svg width="13" height="13"><use href="icons/general/settings.svg#icon"/></svg><span data-i18n="nav.settings">Settings</span></button>
<div class="dropdown-divider"></div>
<button class="user-dropdown-item danger" data-action="logout"><svg width="13" height="13"><use href="icons/general/logout.svg#icon"/></svg><span data-i18n="nav.logout">Log out</span></button>
</div>
</div>
</div>
</nav>
<div class="mobile-nav">
<a href="/" class="mobile-nav-link" data-i18n="nav.operations">Operations</a>
<a href="/agents" class="mobile-nav-link" data-i18n="nav.agents">Agents</a>
<a href="/api-keys" class="mobile-nav-link" data-i18n="nav.apikeys">API Keys</a>
<a href="/logs" class="mobile-nav-link" data-i18n="nav.logs">Logs</a>
<a href="/usage" class="mobile-nav-link" data-i18n="nav.usage">Usage</a>
</div>
<div class="page">
<div class="page-header">
<div class="page-header-text">
<h1 class="page-title" data-i18n="stream_sessions.title">Stream sessions</h1>
<p class="page-subtitle" data-i18n="stream_sessions.subtitle">Inspect stateful streaming sessions created by generated MCP tool families.</p>
</div>
<div class="page-header-actions">
<button class="btn-secondary" id="stream-sessions-refresh" type="button" data-i18n="stream_sessions.refresh">Refresh</button>
</div>
</div>
<div class="section-card">
<div class="section-card-body">
<div class="list-toolbar">
<div class="list-toolbar-group">
<select class="page-select" id="stream-sessions-status">
<option value="" data-i18n="stream_sessions.filter.all_statuses">All statuses</option>
<option value="created" data-i18n="stream_sessions.status.created">Created</option>
<option value="running" data-i18n="stream_sessions.status.running">Running</option>
<option value="failed" data-i18n="stream_sessions.status.failed">Failed</option>
<option value="stopped" data-i18n="stream_sessions.status.stopped">Stopped</option>
<option value="expired" data-i18n="stream_sessions.status.expired">Expired</option>
</select>
<select class="page-select" id="stream-sessions-mode">
<option value="" data-i18n="stream_sessions.filter.all_modes">All modes</option>
<option value="window">window</option>
<option value="session">session</option>
<option value="async_job">async_job</option>
</select>
</div>
<div class="list-toolbar-group">
<span class="section-card-subtitle" id="stream-sessions-summary"></span>
</div>
</div>
<div class="resource-list" id="stream-sessions-list"></div>
</div>
</div>
</div>
<script src="js/nav.js"></script>
<script src="js/stream-sessions.js"></script>
</body>
</html>
+2
View File
@@ -235,6 +235,8 @@
</div><!-- /wizard-shell -->
<script src="../../js/streaming-form.js"></script>
<script src="../../js/stream-test-run.js"></script>
<script src="../../js/wizard.js"></script>
<script src="../../js/nav.js"></script>
+158
View File
@@ -87,6 +87,164 @@ tls:
</div>
</div>
<div id="wizard-streaming-config-card" class="config-card" style="margin-bottom: 20px;">
<div class="config-card-header">
<div class="config-card-header-icon">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" stroke="var(--text-secondary)" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round">
<path d="M3 4h10M3 8h10M3 12h6"/>
<circle cx="12" cy="12" r="1.5"/>
</svg>
</div>
<div>
<div class="config-card-title" data-i18n="wizard.streaming.title">Streaming execution</div>
<div class="config-card-subtitle" data-i18n="wizard.streaming.subtitle">Bounded stream, session and async job settings for MCP tool families.</div>
</div>
</div>
<div class="config-card-body" style="gap: 16px;">
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.execution_mode">Execution mode</label>
<select id="streaming-mode" class="form-select">
<option value="unary">Unary</option>
</select>
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.transport_behavior">Transport behavior</label>
<select id="streaming-transport-behavior" class="form-select">
<option value="request_response">Request / response</option>
</select>
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.aggregation_mode">Aggregation mode</label>
<select id="streaming-aggregation-mode" class="form-select">
<option value="raw_items" data-i18n="wizard.streaming.aggregation.raw_items">Raw items</option>
<option value="summary_only" data-i18n="wizard.streaming.aggregation.summary_only">Summary only</option>
<option value="summary_plus_samples" data-i18n="wizard.streaming.aggregation.summary_plus_samples">Summary + samples</option>
<option value="stats" data-i18n="wizard.streaming.aggregation.stats">Stats</option>
<option value="latest_state" data-i18n="wizard.streaming.aggregation.latest_state">Latest state</option>
</select>
</div>
</div>
<div id="streaming-mode-help" class="form-hint" data-i18n="wizard.streaming.help.unary">Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.</div>
<div id="streaming-limits-grid" class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.window_duration">Window duration (ms)</label>
<input id="streaming-window-duration-ms" class="form-input" type="number" min="1" step="1" placeholder="5000">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.poll_interval">Poll interval (ms)</label>
<input id="streaming-poll-interval-ms" class="form-input" type="number" min="1" step="1" placeholder="2000">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.upstream_timeout">Upstream timeout (ms)</label>
<input id="streaming-upstream-timeout-ms" class="form-input" type="number" min="1" step="1" placeholder="10000">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.idle_timeout">Idle timeout (ms)</label>
<input id="streaming-idle-timeout-ms" class="form-input" type="number" min="1" step="1" placeholder="30000">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.session_lifetime">Session lifetime (ms)</label>
<input id="streaming-session-lifetime-ms" class="form-input" type="number" min="1" step="1" placeholder="300000">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.max_items">Max items</label>
<input id="streaming-max-items" class="form-input" type="number" min="1" step="1" placeholder="100">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.max_bytes">Max bytes</label>
<input id="streaming-max-bytes" class="form-input" type="number" min="1" step="1" placeholder="65536">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.max_field_length">Max field length</label>
<input id="streaming-max-field-length" class="form-input" type="number" min="1" step="1" placeholder="512">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.sampling_rate">Sampling rate</label>
<input id="streaming-sampling-rate" class="form-input" type="number" min="0" max="1" step="0.1" placeholder="1.0">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.items_path">Items path</label>
<input id="streaming-items-path" class="form-input input-mono" type="text" placeholder="$.items">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.summary_path">Summary path</label>
<input id="streaming-summary-path" class="form-input input-mono" type="text" placeholder="$.summary">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.done_path">Done path</label>
<input id="streaming-done-path" class="form-input input-mono" type="text" placeholder="$.done">
</div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.cursor_path">Cursor path</label>
<input id="streaming-cursor-path" class="form-input input-mono" type="text" placeholder="$.cursor">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.status_path">Status path</label>
<input id="streaming-status-path" class="form-input input-mono" type="text" placeholder="$.status">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.redacted_paths">Redacted paths</label>
<textarea id="streaming-redacted-paths" class="form-textarea code-textarea" rows="4" spellcheck="false" placeholder="$.items[*].token&#10;$.summary.secret"></textarea>
</div>
</div>
<div class="form-row">
<label class="checkbox-pill"><input id="streaming-truncate-item-fields" type="checkbox"> <span data-i18n="wizard.streaming.truncate_item_fields">Truncate item fields</span></label>
<label class="checkbox-pill"><input id="streaming-drop-duplicates" type="checkbox"> <span data-i18n="wizard.streaming.drop_duplicates">Drop duplicates</span></label>
</div>
<div id="streaming-tool-family-block" style="display:none;">
<div class="section-divider" style="margin: 8px 0 16px;">
<span class="section-divider-label" data-i18n="wizard.streaming.tool_family">Tool family</span>
<div class="section-divider-line"></div>
</div>
<div class="form-row">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.start_tool_name">Start tool name</label>
<input id="streaming-start-tool-name" class="form-input input-mono" type="text" placeholder="logs_follow_start">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.poll_tool_name">Poll tool name</label>
<input id="streaming-poll-tool-name" class="form-input input-mono" type="text" placeholder="logs_follow_poll">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.stop_tool_name">Stop tool name</label>
<input id="streaming-stop-tool-name" class="form-input input-mono" type="text" placeholder="logs_follow_stop">
</div>
</div>
<div class="form-row" id="streaming-async-tool-family-row" style="display:none;">
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.status_tool_name">Status tool name</label>
<input id="streaming-status-tool-name" class="form-input input-mono" type="text" placeholder="deploy_status">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.result_tool_name">Result tool name</label>
<input id="streaming-result-tool-name" class="form-input input-mono" type="text" placeholder="deploy_result">
</div>
<div class="form-group">
<label class="form-label" data-i18n="wizard.streaming.cancel_tool_name">Cancel tool name</label>
<input id="streaming-cancel-tool-name" class="form-input input-mono" type="text" placeholder="deploy_cancel">
</div>
</div>
</div>
</div>
</div>
<div class="section-divider" style="margin-bottom: 16px;">
<span class="section-divider-label" data-i18n="wizard.step5.live_title">Live validation and publishing</span>
<div class="section-divider-line"></div>
+24
View File
@@ -332,6 +332,30 @@
getUsageOverview: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/usage' + query(params));
},
getProtocolCapabilities: function(workspaceId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/protocol-capabilities');
},
listStreamSessions: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/stream-sessions' + query(params));
},
getStreamSession: function(workspaceId, sessionId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/stream-sessions/' + encodeURIComponent(sessionId));
},
stopStreamSession: function(workspaceId, sessionId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/stream-sessions/' + encodeURIComponent(sessionId) + '/stop', {});
},
listAsyncJobs: function(workspaceId, params) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs' + query(params));
},
getAsyncJob: function(workspaceId, jobId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs/' + encodeURIComponent(jobId));
},
cancelAsyncJob: function(workspaceId, jobId) {
return post('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs/' + encodeURIComponent(jobId) + '/cancel', {});
},
getAsyncJobResult: function(workspaceId, jobId) {
return get('/workspaces/' + encodeURIComponent(workspaceId) + '/async-jobs/' + encodeURIComponent(jobId) + '/result');
},
getOperationUsage: function(workspaceId, operationId, params) {
return get(
'/workspaces/' + encodeURIComponent(workspaceId) + '/usage/operations/' + encodeURIComponent(operationId) + query(params)
+181
View File
@@ -0,0 +1,181 @@
document.addEventListener('DOMContentLoaded', function () {
var state = {
items: [],
workspaceId: null,
loading: false,
error: '',
openId: null,
};
var list = document.getElementById('async-jobs-list');
var summary = document.getElementById('async-jobs-summary');
var refreshButton = document.getElementById('async-jobs-refresh');
var statusFilter = document.getElementById('async-jobs-status');
function tKey(key, vars) {
if (!window.t) return key;
return t(key, vars);
}
function currentWorkspaceId() {
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
return workspace ? workspace.id : null;
}
function formatJson(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value, null, 2);
}
function formatDateTime(value) {
if (!value) return '—';
return new Date(value).toLocaleString();
}
function renderEmpty(title, body) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + title + '</div><div class="empty-state-text">' + body + '</div></div>';
}
function render() {
summary.textContent = tKey('async_jobs.summary', { count: state.items.length });
if (state.loading && state.items.length === 0) {
renderEmpty(tKey('async_jobs.loading.title'), tKey('async_jobs.loading.body'));
return;
}
if (state.error) {
renderEmpty(tKey('async_jobs.error.title'), state.error);
return;
}
if (!state.items.length) {
renderEmpty(tKey('async_jobs.empty.title'), tKey('async_jobs.empty.body'));
return;
}
list.innerHTML = state.items.map(function(item) {
var open = state.openId === item.id;
var statusLabel = tKey('async_jobs.status.' + item.status);
return [
'<div class="resource-card" data-job-id="' + item.id + '">',
' <div class="resource-card-header">',
' <div>',
' <div class="resource-card-title">' + item.id + '</div>',
' <div class="resource-card-subtitle">' + tKey('async_jobs.operation', { operation: item.operation_id }) + '</div>',
' <div class="resource-pill-row">',
' <span class="resource-status-pill ' + String(item.status || '').toLowerCase() + '">' + statusLabel + '</span>',
' </div>',
' </div>',
' <div class="resource-card-actions">',
item.status === 'created' || item.status === 'running'
? ' <button class="btn-secondary" data-action="cancel" data-job-id="' + item.id + '">' + tKey('async_jobs.cancel') + '</button>'
: '',
' <button class="btn-secondary" data-action="toggle" data-job-id="' + item.id + '">' + (open ? tKey('async_jobs.hide_details') : tKey('async_jobs.show_details')) + '</button>',
' </div>',
' </div>',
' <div class="resource-meta-grid">',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.created') + '</div><div class="resource-meta-value">' + formatDateTime(item.created_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.updated') + '</div><div class="resource-meta-value">' + formatDateTime(item.updated_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.finished') + '</div><div class="resource-meta-value">' + formatDateTime(item.finished_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('async_jobs.meta.expires') + '</div><div class="resource-meta-value">' + formatDateTime(item.expires_at) + '</div></div>',
' </div>',
open
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.progress') + '</div><pre class="resource-detail-pre">' + formatJson(item.progress) + '</pre></div>'
: '',
open
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.error_preview') + '</div><pre class="resource-detail-pre">' + formatJson(item.error) + '</pre></div>'
: '',
open && (item.status === 'completed' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'expired')
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('async_jobs.result') + '</div><pre class="resource-detail-pre" data-result-for="' + item.id + '">' + tKey('async_jobs.result_loading') + '</pre></div>'
: '',
'</div>'
].join('');
}).join('');
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
button.addEventListener('click', function () {
var jobId = this.getAttribute('data-job-id');
state.openId = state.openId === jobId ? null : jobId;
if (state.openId) {
void loadDetail(jobId);
} else {
render();
}
});
});
list.querySelectorAll('[data-action="cancel"]').forEach(function(button) {
button.addEventListener('click', async function () {
try {
var jobId = this.getAttribute('data-job-id');
await window.CrankApi.cancelAsyncJob(state.workspaceId, jobId);
if (window.CrankUi) {
window.CrankUi.success(tKey('async_jobs.toast.cancel.body'), tKey('async_jobs.toast.cancel.title'));
}
await load();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('async_jobs.toast.cancel_error.body'), tKey('async_jobs.toast.cancel_error.title'));
}
}
});
});
}
async function loadResult(jobId) {
try {
var result = await window.CrankApi.getAsyncJobResult(state.workspaceId, jobId);
var node = document.querySelector('[data-result-for="' + jobId + '"]');
if (node) node.textContent = formatJson(result);
} catch (error) {
var failedNode = document.querySelector('[data-result-for="' + jobId + '"]');
if (failedNode) failedNode.textContent = error.message || tKey('async_jobs.result_error');
}
}
async function loadDetail(jobId) {
var detail = await window.CrankApi.getAsyncJob(state.workspaceId, jobId);
state.items = state.items.map(function(item) {
return item.id === jobId ? detail : item;
});
render();
if (detail.status === 'completed' || detail.status === 'failed' || detail.status === 'cancelled' || detail.status === 'expired') {
await loadResult(jobId);
}
}
async function load() {
state.workspaceId = currentWorkspaceId();
if (!state.workspaceId) {
state.items = [];
state.error = tKey('async_jobs.error.workspace');
render();
return;
}
state.loading = true;
state.error = '';
render();
try {
var response = await window.CrankApi.listAsyncJobs(state.workspaceId, {
status: statusFilter.value || null,
page: 1,
page_size: 50,
});
state.items = response.items || [];
} catch (error) {
state.error = error.message || tKey('async_jobs.error.load');
} finally {
state.loading = false;
render();
}
}
refreshButton.addEventListener('click', load);
statusFilter.addEventListener('change', load);
document.addEventListener('workspace:changed', load);
void load();
});
+2
View File
@@ -8,6 +8,8 @@
apiKeys: '/api-keys',
logs: '/logs',
usage: '/usage',
streamSessions: '/stream-sessions',
asyncJobs: '/async-jobs',
settings: '/settings',
workspaceSetup: '/workspace-setup',
wizard: '/wizard/'
+234
View File
@@ -1569,6 +1569,240 @@ var TRANSLATIONS = {
}
};
Object.assign(TRANSLATIONS.en, {
'wizard.streaming.title': 'Streaming execution',
'wizard.streaming.subtitle': 'Bounded stream, session and async job settings for MCP tool families.',
'wizard.streaming.execution_mode': 'Execution mode',
'wizard.streaming.transport_behavior': 'Transport behavior',
'wizard.streaming.aggregation_mode': 'Aggregation mode',
'wizard.streaming.window_duration': 'Window duration (ms)',
'wizard.streaming.poll_interval': 'Poll interval (ms)',
'wizard.streaming.upstream_timeout': 'Upstream timeout (ms)',
'wizard.streaming.idle_timeout': 'Idle timeout (ms)',
'wizard.streaming.session_lifetime': 'Session lifetime (ms)',
'wizard.streaming.max_items': 'Max items',
'wizard.streaming.max_bytes': 'Max bytes',
'wizard.streaming.max_field_length': 'Max field length',
'wizard.streaming.sampling_rate': 'Sampling rate',
'wizard.streaming.items_path': 'Items path',
'wizard.streaming.summary_path': 'Summary path',
'wizard.streaming.done_path': 'Done path',
'wizard.streaming.cursor_path': 'Cursor path',
'wizard.streaming.status_path': 'Status path',
'wizard.streaming.redacted_paths': 'Redacted paths',
'wizard.streaming.truncate_item_fields': 'Truncate item fields',
'wizard.streaming.drop_duplicates': 'Drop duplicates',
'wizard.streaming.tool_family': 'Tool family',
'wizard.streaming.start_tool_name': 'Start tool name',
'wizard.streaming.poll_tool_name': 'Poll tool name',
'wizard.streaming.stop_tool_name': 'Stop tool name',
'wizard.streaming.status_tool_name': 'Status tool name',
'wizard.streaming.result_tool_name': 'Result tool name',
'wizard.streaming.cancel_tool_name': 'Cancel tool name',
'wizard.streaming.mode.unary': 'Unary',
'wizard.streaming.mode.window': 'Window',
'wizard.streaming.mode.session': 'Session',
'wizard.streaming.mode.async_job': 'Async job',
'wizard.streaming.transport.request_response': 'Request / response',
'wizard.streaming.transport.server_stream': 'Server stream',
'wizard.streaming.transport.stateful_session': 'Stateful session',
'wizard.streaming.transport.deferred_result': 'Deferred result',
'wizard.streaming.aggregation.raw_items': 'Raw items',
'wizard.streaming.aggregation.summary_only': 'Summary only',
'wizard.streaming.aggregation.summary_plus_samples': 'Summary + samples',
'wizard.streaming.aggregation.stats': 'Stats',
'wizard.streaming.aggregation.latest_state': 'Latest state',
'wizard.streaming.help.unary': 'Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.',
'wizard.streaming.help.window': 'Window mode collects a bounded slice of data and returns a compact result.',
'wizard.streaming.help.session': 'Session mode publishes start/poll/stop tool families backed by persisted stream sessions.',
'wizard.streaming.help.async_job': 'Async job mode publishes start/status/result/cancel tools and stores runtime job state.',
'stream_sessions.title': 'Stream sessions',
'stream_sessions.subtitle': 'Inspect stateful streaming sessions created by generated MCP tool families.',
'stream_sessions.refresh': 'Refresh',
'stream_sessions.summary': '{count} sessions',
'stream_sessions.filter.all_statuses': 'All statuses',
'stream_sessions.filter.all_modes': 'All modes',
'stream_sessions.loading.title': 'Loading stream sessions…',
'stream_sessions.loading.body': 'Fetching persisted session state for the current workspace.',
'stream_sessions.error.title': 'Unable to load stream sessions',
'stream_sessions.error.workspace': 'Workspace is not selected',
'stream_sessions.error.load': 'Failed to load stream sessions',
'stream_sessions.empty.title': 'No stream sessions found',
'stream_sessions.empty.body': 'Session-mode MCP tools will create entries here after start/poll/stop flows.',
'stream_sessions.operation': 'Operation: {operation}',
'stream_sessions.meta.created': 'Created',
'stream_sessions.meta.last_poll': 'Last poll',
'stream_sessions.meta.expires': 'Expires',
'stream_sessions.meta.agent': 'Agent',
'stream_sessions.cursor': 'Cursor',
'stream_sessions.stop': 'Stop session',
'stream_sessions.show_details': 'Show details',
'stream_sessions.hide_details': 'Hide details',
'stream_sessions.toast.stop.title': 'Session stopped',
'stream_sessions.toast.stop.body': 'The stream session was stopped.',
'stream_sessions.toast.stop_error.title': 'Stop failed',
'stream_sessions.toast.stop_error.body': 'Failed to stop stream session',
'stream_sessions.status.created': 'Created',
'stream_sessions.status.running': 'Running',
'stream_sessions.status.failed': 'Failed',
'stream_sessions.status.stopped': 'Stopped',
'stream_sessions.status.expired': 'Expired',
'async_jobs.title': 'Async jobs',
'async_jobs.subtitle': 'Inspect deferred jobs started by streaming MCP tool families.',
'async_jobs.refresh': 'Refresh',
'async_jobs.summary': '{count} jobs',
'async_jobs.filter.all_statuses': 'All statuses',
'async_jobs.loading.title': 'Loading async jobs…',
'async_jobs.loading.body': 'Fetching persisted job state for the current workspace.',
'async_jobs.error.title': 'Unable to load async jobs',
'async_jobs.error.workspace': 'Workspace is not selected',
'async_jobs.error.load': 'Failed to load async jobs',
'async_jobs.empty.title': 'No async jobs found',
'async_jobs.empty.body': 'Async job MCP tools will create entries here after start/status/result flows.',
'async_jobs.operation': 'Operation: {operation}',
'async_jobs.meta.created': 'Created',
'async_jobs.meta.updated': 'Updated',
'async_jobs.meta.finished': 'Finished',
'async_jobs.meta.expires': 'Expires',
'async_jobs.progress': 'Progress payload',
'async_jobs.error_preview': 'Error preview',
'async_jobs.result': 'Result payload',
'async_jobs.result_loading': 'Loading result…',
'async_jobs.result_error': 'Failed to load result',
'async_jobs.cancel': 'Cancel job',
'async_jobs.show_details': 'Show details',
'async_jobs.hide_details': 'Hide details',
'async_jobs.toast.cancel.title': 'Job cancelled',
'async_jobs.toast.cancel.body': 'The async job was cancelled.',
'async_jobs.toast.cancel_error.title': 'Cancel failed',
'async_jobs.toast.cancel_error.body': 'Failed to cancel async job',
'async_jobs.status.created': 'Created',
'async_jobs.status.running': 'Running',
'async_jobs.status.completed': 'Completed',
'async_jobs.status.failed': 'Failed',
'async_jobs.status.cancelled': 'Cancelled',
'async_jobs.status.expired': 'Expired',
});
Object.assign(TRANSLATIONS.ru, {
'wizard.streaming.title': 'Потоковое выполнение',
'wizard.streaming.subtitle': 'Ограниченные настройки стрима, сессий и async job для семейств MCP-инструментов.',
'wizard.streaming.execution_mode': 'Режим выполнения',
'wizard.streaming.transport_behavior': 'Поведение транспорта',
'wizard.streaming.aggregation_mode': 'Режим агрегации',
'wizard.streaming.window_duration': 'Длительность окна (мс)',
'wizard.streaming.poll_interval': 'Интервал poll (мс)',
'wizard.streaming.upstream_timeout': 'Таймаут upstream (мс)',
'wizard.streaming.idle_timeout': 'Таймаут простоя (мс)',
'wizard.streaming.session_lifetime': 'Время жизни сессии (мс)',
'wizard.streaming.max_items': 'Максимум элементов',
'wizard.streaming.max_bytes': 'Максимум байт',
'wizard.streaming.max_field_length': 'Максимальная длина поля',
'wizard.streaming.sampling_rate': 'Частота выборки',
'wizard.streaming.items_path': 'Путь к items',
'wizard.streaming.summary_path': 'Путь к summary',
'wizard.streaming.done_path': 'Путь к done',
'wizard.streaming.cursor_path': 'Путь к cursor',
'wizard.streaming.status_path': 'Путь к status',
'wizard.streaming.redacted_paths': 'Скрываемые пути',
'wizard.streaming.truncate_item_fields': 'Обрезать поля элементов',
'wizard.streaming.drop_duplicates': 'Удалять дубликаты',
'wizard.streaming.tool_family': 'Семейство инструментов',
'wizard.streaming.start_tool_name': 'Имя start-инструмента',
'wizard.streaming.poll_tool_name': 'Имя poll-инструмента',
'wizard.streaming.stop_tool_name': 'Имя stop-инструмента',
'wizard.streaming.status_tool_name': 'Имя status-инструмента',
'wizard.streaming.result_tool_name': 'Имя result-инструмента',
'wizard.streaming.cancel_tool_name': 'Имя cancel-инструмента',
'wizard.streaming.mode.unary': 'Unary',
'wizard.streaming.mode.window': 'Window',
'wizard.streaming.mode.session': 'Session',
'wizard.streaming.mode.async_job': 'Async job',
'wizard.streaming.transport.request_response': 'Запрос / ответ',
'wizard.streaming.transport.server_stream': 'Серверный стрим',
'wizard.streaming.transport.stateful_session': 'Состояние сессии',
'wizard.streaming.transport.deferred_result': 'Отложенный результат',
'wizard.streaming.aggregation.raw_items': 'Сырые элементы',
'wizard.streaming.aggregation.summary_only': 'Только summary',
'wizard.streaming.aggregation.summary_plus_samples': 'Summary + примеры',
'wizard.streaming.aggregation.stats': 'Статистика',
'wizard.streaming.aggregation.latest_state': 'Последнее состояние',
'wizard.streaming.help.unary': 'Unary сохраняет текущую модель запрос-ответ. Остальные режимы создают ограниченные потоковые MCP-инструменты.',
'wizard.streaming.help.window': 'Режим window собирает ограниченный срез данных и возвращает компактный результат.',
'wizard.streaming.help.session': 'Режим session публикует семейство start/poll/stop и опирается на сохраненные stream sessions.',
'wizard.streaming.help.async_job': 'Режим async job публикует start/status/result/cancel и хранит состояние фоновой задачи.',
'stream_sessions.title': 'Потоковые сессии',
'stream_sessions.subtitle': 'Просмотр stateful streaming-сессий, созданных сгенерированными семействами MCP-инструментов.',
'stream_sessions.refresh': 'Обновить',
'stream_sessions.summary': '{count} сессий',
'stream_sessions.filter.all_statuses': 'Все статусы',
'stream_sessions.filter.all_modes': 'Все режимы',
'stream_sessions.loading.title': 'Загрузка потоковых сессий…',
'stream_sessions.loading.body': 'Получаем сохраненное состояние сессий для текущего workspace.',
'stream_sessions.error.title': 'Не удалось загрузить потоковые сессии',
'stream_sessions.error.workspace': 'Workspace не выбран',
'stream_sessions.error.load': 'Не удалось загрузить потоковые сессии',
'stream_sessions.empty.title': 'Потоковых сессий пока нет',
'stream_sessions.empty.body': 'Сессии появятся здесь после вызовов start/poll/stop для session-mode инструментов.',
'stream_sessions.operation': 'Операция: {operation}',
'stream_sessions.meta.created': 'Создана',
'stream_sessions.meta.last_poll': 'Последний poll',
'stream_sessions.meta.expires': 'Истекает',
'stream_sessions.meta.agent': 'Агент',
'stream_sessions.cursor': 'Cursor',
'stream_sessions.stop': 'Остановить сессию',
'stream_sessions.show_details': 'Показать детали',
'stream_sessions.hide_details': 'Скрыть детали',
'stream_sessions.toast.stop.title': 'Сессия остановлена',
'stream_sessions.toast.stop.body': 'Потоковая сессия остановлена.',
'stream_sessions.toast.stop_error.title': 'Не удалось остановить',
'stream_sessions.toast.stop_error.body': 'Не удалось остановить потоковую сессию',
'stream_sessions.status.created': 'Создана',
'stream_sessions.status.running': 'Выполняется',
'stream_sessions.status.failed': 'Ошибка',
'stream_sessions.status.stopped': 'Остановлена',
'stream_sessions.status.expired': 'Истекла',
'async_jobs.title': 'Асинхронные задачи',
'async_jobs.subtitle': 'Просмотр отложенных задач, запущенных потоковыми семействами MCP-инструментов.',
'async_jobs.refresh': 'Обновить',
'async_jobs.summary': '{count} задач',
'async_jobs.filter.all_statuses': 'Все статусы',
'async_jobs.loading.title': 'Загрузка async job…',
'async_jobs.loading.body': 'Получаем сохраненное состояние задач для текущего workspace.',
'async_jobs.error.title': 'Не удалось загрузить async job',
'async_jobs.error.workspace': 'Workspace не выбран',
'async_jobs.error.load': 'Не удалось загрузить async job',
'async_jobs.empty.title': 'Асинхронных задач пока нет',
'async_jobs.empty.body': 'Задачи появятся здесь после вызовов start/status/result для async job инструментов.',
'async_jobs.operation': 'Операция: {operation}',
'async_jobs.meta.created': 'Создана',
'async_jobs.meta.updated': 'Обновлена',
'async_jobs.meta.finished': 'Завершена',
'async_jobs.meta.expires': 'Истекает',
'async_jobs.progress': 'Payload прогресса',
'async_jobs.error_preview': 'Предпросмотр ошибки',
'async_jobs.result': 'Payload результата',
'async_jobs.result_loading': 'Загрузка результата…',
'async_jobs.result_error': 'Не удалось загрузить результат',
'async_jobs.cancel': 'Отменить задачу',
'async_jobs.show_details': 'Показать детали',
'async_jobs.hide_details': 'Скрыть детали',
'async_jobs.toast.cancel.title': 'Задача отменена',
'async_jobs.toast.cancel.body': 'Асинхронная задача отменена.',
'async_jobs.toast.cancel_error.title': 'Не удалось отменить',
'async_jobs.toast.cancel_error.body': 'Не удалось отменить async job',
'async_jobs.status.created': 'Создана',
'async_jobs.status.running': 'Выполняется',
'async_jobs.status.completed': 'Завершена',
'async_jobs.status.failed': 'Ошибка',
'async_jobs.status.cancelled': 'Отменена',
'async_jobs.status.expired': 'Истекла',
});
function t(key) {
var lang = localStorage.getItem('crank_lang') || 'en';
var tr = TRANSLATIONS[lang] || TRANSLATIONS.en;
+166
View File
@@ -0,0 +1,166 @@
document.addEventListener('DOMContentLoaded', function () {
var state = {
items: [],
workspaceId: null,
loading: false,
error: '',
openId: null,
};
var list = document.getElementById('stream-sessions-list');
var summary = document.getElementById('stream-sessions-summary');
var refreshButton = document.getElementById('stream-sessions-refresh');
var statusFilter = document.getElementById('stream-sessions-status');
var modeFilter = document.getElementById('stream-sessions-mode');
function tKey(key, vars) {
if (!window.t) return key;
return t(key, vars);
}
function currentWorkspaceId() {
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
return workspace ? workspace.id : null;
}
function formatJson(value) {
if (value === null || value === undefined) return '';
if (typeof value === 'string') return value;
return JSON.stringify(value, null, 2);
}
function formatDateTime(value) {
if (!value) return '—';
return new Date(value).toLocaleString();
}
function renderEmpty(title, body) {
list.innerHTML = '<div class="empty-state"><div class="empty-state-title">' + title + '</div><div class="empty-state-text">' + body + '</div></div>';
}
function render() {
summary.textContent = tKey('stream_sessions.summary', { count: state.items.length });
if (state.loading && state.items.length === 0) {
renderEmpty(tKey('stream_sessions.loading.title'), tKey('stream_sessions.loading.body'));
return;
}
if (state.error) {
renderEmpty(tKey('stream_sessions.error.title'), state.error);
return;
}
if (!state.items.length) {
renderEmpty(tKey('stream_sessions.empty.title'), tKey('stream_sessions.empty.body'));
return;
}
list.innerHTML = state.items.map(function(item) {
var open = state.openId === item.id;
var statusLabel = tKey('stream_sessions.status.' + item.status);
return [
'<div class="resource-card" data-session-id="' + item.id + '">',
' <div class="resource-card-header">',
' <div>',
' <div class="resource-card-title">' + item.id + '</div>',
' <div class="resource-card-subtitle">' + tKey('stream_sessions.operation', { operation: item.operation_id }) + '</div>',
' <div class="resource-pill-row">',
' <span class="resource-status-pill ' + String(item.status || '').toLowerCase() + '">' + statusLabel + '</span>',
' <span class="resource-status-pill">' + item.mode + '</span>',
' <span class="resource-status-pill">' + item.protocol + '</span>',
' </div>',
' </div>',
' <div class="resource-card-actions">',
item.status === 'created' || item.status === 'running'
? ' <button class="btn-secondary" data-action="stop" data-session-id="' + item.id + '">' + tKey('stream_sessions.stop') + '</button>'
: '',
' <button class="btn-secondary" data-action="toggle" data-session-id="' + item.id + '">' + (open ? tKey('stream_sessions.hide_details') : tKey('stream_sessions.show_details')) + '</button>',
' </div>',
' </div>',
' <div class="resource-meta-grid">',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.created') + '</div><div class="resource-meta-value">' + formatDateTime(item.created_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.last_poll') + '</div><div class="resource-meta-value">' + formatDateTime(item.last_poll_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.expires') + '</div><div class="resource-meta-value">' + formatDateTime(item.expires_at) + '</div></div>',
' <div class="resource-meta-item"><div class="resource-meta-label">' + tKey('stream_sessions.meta.agent') + '</div><div class="resource-meta-value">' + (item.agent_id || '—') + '</div></div>',
' </div>',
open
? ' <div class="resource-detail-block"><div class="resource-detail-title">' + tKey('stream_sessions.cursor') + '</div><pre class="resource-detail-pre">' + formatJson(item.cursor) + '</pre></div>'
: '',
'</div>'
].join('');
}).join('');
list.querySelectorAll('[data-action="toggle"]').forEach(function(button) {
button.addEventListener('click', function () {
var sessionId = this.getAttribute('data-session-id');
state.openId = state.openId === sessionId ? null : sessionId;
if (state.openId) {
void loadDetail(sessionId);
} else {
render();
}
});
});
list.querySelectorAll('[data-action="stop"]').forEach(function(button) {
button.addEventListener('click', async function () {
try {
var sessionId = this.getAttribute('data-session-id');
await window.CrankApi.stopStreamSession(state.workspaceId, sessionId);
if (window.CrankUi) {
window.CrankUi.success(tKey('stream_sessions.toast.stop.body'), tKey('stream_sessions.toast.stop.title'));
}
await load();
} catch (error) {
if (window.CrankUi) {
window.CrankUi.error(error.message || tKey('stream_sessions.toast.stop_error.body'), tKey('stream_sessions.toast.stop_error.title'));
}
}
});
});
}
async function loadDetail(sessionId) {
var detail = await window.CrankApi.getStreamSession(state.workspaceId, sessionId);
state.items = state.items.map(function(item) {
return item.id === sessionId ? detail : item;
});
render();
}
async function load() {
state.workspaceId = currentWorkspaceId();
if (!state.workspaceId) {
state.items = [];
state.error = tKey('stream_sessions.error.workspace');
render();
return;
}
state.loading = true;
state.error = '';
render();
try {
var response = await window.CrankApi.listStreamSessions(state.workspaceId, {
status: statusFilter.value || null,
mode: modeFilter.value || null,
page: 1,
page_size: 50,
});
state.items = response.items || [];
} catch (error) {
state.error = error.message || tKey('stream_sessions.error.load');
} finally {
state.loading = false;
render();
}
}
refreshButton.addEventListener('click', load);
statusFilter.addEventListener('change', load);
modeFilter.addEventListener('change', load);
document.addEventListener('workspace:changed', load);
void load();
});
+47
View File
@@ -0,0 +1,47 @@
(function() {
function currentWorkspaceId() {
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
return workspace ? workspace.id : null;
}
async function startWindowTest(operationId, payload) {
if (!window.CrankApi || !operationId) {
throw new Error('Window test requires an operation id.');
}
return window.CrankApi.runOperationTest(currentWorkspaceId(), operationId, payload);
}
async function startSessionTest(workspaceId, operationId, payload) {
return window.CrankApi.runOperationTest(workspaceId || currentWorkspaceId(), operationId, payload);
}
async function pollSessionTest(workspaceId, sessionId) {
return window.CrankApi.getStreamSession(workspaceId || currentWorkspaceId(), sessionId);
}
async function stopSessionTest(workspaceId, sessionId) {
return window.CrankApi.stopStreamSession(workspaceId || currentWorkspaceId(), sessionId);
}
async function startAsyncJobTest(workspaceId, operationId, payload) {
return window.CrankApi.runOperationTest(workspaceId || currentWorkspaceId(), operationId, payload);
}
async function refreshAsyncJobStatus(workspaceId, jobId) {
return window.CrankApi.getAsyncJob(workspaceId || currentWorkspaceId(), jobId);
}
async function loadAsyncJobResult(workspaceId, jobId) {
return window.CrankApi.getAsyncJobResult(workspaceId || currentWorkspaceId(), jobId);
}
window.CrankStreamTestRun = {
startWindowTest: startWindowTest,
startSessionTest: startSessionTest,
pollSessionTest: pollSessionTest,
stopSessionTest: stopSessionTest,
startAsyncJobTest: startAsyncJobTest,
refreshAsyncJobStatus: refreshAsyncJobStatus,
loadAsyncJobResult: loadAsyncJobResult,
};
}());
+127
View File
@@ -0,0 +1,127 @@
(function() {
function text(id) {
var element = document.getElementById(id);
return element ? String(element.value || '').trim() : '';
}
function number(id) {
var value = text(id);
if (!value) return null;
var parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}
function lines(id) {
var value = text(id);
return value
? value.split('\n').map(function(item) { return item.trim(); }).filter(Boolean)
: [];
}
function checked(id) {
var element = document.getElementById(id);
return !!(element && element.checked);
}
function selectedMode() {
return text('streaming-mode') || 'unary';
}
function serializeStreamingConfig() {
if (selectedMode() === 'unary') {
return null;
}
var config = {
mode: selectedMode(),
transport_behavior: text('streaming-transport-behavior') || 'request_response',
aggregation_mode: text('streaming-aggregation-mode') || 'summary_plus_samples',
window_duration_ms: number('streaming-window-duration-ms'),
poll_interval_ms: number('streaming-poll-interval-ms'),
upstream_timeout_ms: number('streaming-upstream-timeout-ms'),
idle_timeout_ms: number('streaming-idle-timeout-ms'),
max_session_lifetime_ms: number('streaming-session-lifetime-ms'),
max_items: number('streaming-max-items'),
max_bytes: number('streaming-max-bytes'),
max_field_length: number('streaming-max-field-length'),
sampling_rate: number('streaming-sampling-rate'),
items_path: text('streaming-items-path') || null,
summary_path: text('streaming-summary-path') || null,
done_path: text('streaming-done-path') || null,
cursor_path: text('streaming-cursor-path') || null,
status_path: text('streaming-status-path') || null,
redacted_paths: lines('streaming-redacted-paths'),
truncate_item_fields: checked('streaming-truncate-item-fields'),
drop_duplicates: checked('streaming-drop-duplicates'),
tool_family: {},
};
if (config.mode === 'session') {
config.tool_family.start_tool_name = text('streaming-start-tool-name') || null;
config.tool_family.poll_tool_name = text('streaming-poll-tool-name') || null;
config.tool_family.stop_tool_name = text('streaming-stop-tool-name') || null;
} else if (config.mode === 'async_job') {
config.tool_family.start_tool_name = text('streaming-start-tool-name') || null;
config.tool_family.status_tool_name = text('streaming-status-tool-name') || null;
config.tool_family.result_tool_name = text('streaming-result-tool-name') || null;
config.tool_family.cancel_tool_name = text('streaming-cancel-tool-name') || null;
}
return config;
}
function validateStreamingConfig(capabilities) {
var config = serializeStreamingConfig();
if (!config) {
return { valid: true, errors: [] };
}
var errors = [];
var capability = (capabilities || []).find(function(item) {
return item.protocol === window.wizardProtocol;
});
if (capability && Array.isArray(capability.supports_execution_modes)) {
var supported = capability.supports_execution_modes.map(String);
if (supported.indexOf(config.mode) === -1) {
errors.push('Selected execution mode is not supported by the current protocol.');
}
}
if (!config.max_items || config.max_items <= 0) {
errors.push('Max items must be a positive integer.');
}
if (!config.max_bytes || config.max_bytes <= 0) {
errors.push('Max bytes must be a positive integer.');
}
if (config.mode === 'window' && (!config.window_duration_ms || config.window_duration_ms <= 0)) {
errors.push('Window duration must be set for window mode.');
}
if (config.mode === 'session' && (!config.poll_interval_ms || config.poll_interval_ms <= 0)) {
errors.push('Poll interval must be set for session mode.');
}
if (config.mode === 'async_job' && (!config.max_session_lifetime_ms || config.max_session_lifetime_ms <= 0)) {
errors.push('Session lifetime must be set for async job mode.');
}
return {
valid: errors.length === 0,
errors: errors,
config: config,
};
}
window.CrankStreamingForm = {
serializeStreamingConfig: serializeStreamingConfig,
deserializeStreamingConfig: function(streaming) {
if (typeof window.applyStreamingConfig === 'function') {
window.applyStreamingConfig(streaming);
}
},
validateStreamingConfig: validateStreamingConfig,
};
}());
+314
View File
@@ -10,6 +10,7 @@ var wizardProtoUpload = null;
var wizardDescriptorSetUpload = null;
var wizardTestResponsePreview = null;
var grpcDescriptorServices = [];
var wizardProtocolCapabilities = null;
function tKey(key) {
return typeof t === 'function' ? t(key) : key;
}
@@ -17,6 +18,48 @@ function tfKey(key, vars) {
return typeof tf === 'function' ? tf(key, vars) : key;
}
function defaultProtocolCapabilities() {
return {
rest: {
supports_execution_modes: ['unary', 'window', 'session', 'async_job'],
supports_transport_behaviors: ['request_response', 'server_stream', 'deferred_result'],
},
graphql: {
supports_execution_modes: ['unary'],
supports_transport_behaviors: ['request_response'],
},
grpc: {
supports_execution_modes: ['unary', 'window', 'session', 'async_job'],
supports_transport_behaviors: ['request_response', 'server_stream'],
},
};
}
function currentProtocolCapabilities() {
var capabilities = wizardProtocolCapabilities || defaultProtocolCapabilities();
return capabilities[wizardProtocol] || capabilities.rest;
}
async function loadProtocolCapabilities() {
if (!wizardWorkspaceId || !window.CrankApi || typeof window.CrankApi.getProtocolCapabilities !== 'function') {
wizardProtocolCapabilities = defaultProtocolCapabilities();
return wizardProtocolCapabilities;
}
try {
var response = await window.CrankApi.getProtocolCapabilities(wizardWorkspaceId);
var mapped = {};
(response.items || []).forEach(function(item) {
mapped[item.protocol] = item;
});
wizardProtocolCapabilities = mapped;
} catch (_error) {
wizardProtocolCapabilities = defaultProtocolCapabilities();
}
return wizardProtocolCapabilities;
}
/* ── Dynamic step loading ── */
function _stepFile(n) {
return (n === 3) ? 'step3-' + wizardProtocol + '.html' : 'step' + n + '.html';
@@ -222,10 +265,12 @@ document.addEventListener('DOMContentLoaded', async function() {
await (window.whenWorkspacesReady ? window.whenWorkspacesReady() : Promise.resolve());
var workspace = window.getCurrentWorkspace ? window.getCurrentWorkspace() : null;
wizardWorkspaceId = workspace ? workspace.id : null;
await loadProtocolCapabilities();
await loadWizardPanels([1, 2, 3, 4, 5]);
bindProtocolCards();
bindWizardLiveActions();
bindStreamingConfigControls();
var params = new URLSearchParams(window.location.search);
if (params.get('mode') === 'edit' && params.get('operationId')) {
@@ -1092,6 +1137,12 @@ function buildMappingSet(rawValue, mode) {
}
function parseExecutionConfig(text) {
if (window.CrankStreamingForm && typeof window.CrankStreamingForm.validateStreamingConfig === 'function') {
var validation = window.CrankStreamingForm.validateStreamingConfig([currentProtocolCapabilities()]);
if (!validation.valid) {
throw new Error(validation.errors[0]);
}
}
var value = parseStructuredText(text);
var retry = value.retry || value.retry_policy || null;
var headers = value.headers && typeof value.headers === 'object' ? value.headers : {};
@@ -1113,6 +1164,8 @@ function parseExecutionConfig(text) {
config.protocol_options = { grpc: { use_tls: useTls } };
}
config.streaming = collectStreamingConfig();
return config;
}
@@ -1236,6 +1289,264 @@ function parseHeaderMap(text) {
return value && typeof value === 'object' ? value : {};
}
function numericValueOrNull(id) {
var value = textValue(id);
if (!value) return null;
var parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
}
function stringValueOrNull(id) {
var value = textValue(id);
return value ? value : null;
}
function textareaLines(id) {
var value = textValue(id);
if (!value) return [];
return value
.split(/\r?\n/)
.map(function(line) { return line.trim(); })
.filter(Boolean);
}
function selectedStreamingMode() {
var element = document.getElementById('streaming-mode');
return element ? element.value : 'unary';
}
function transportOptionsForMode(mode) {
var capabilities = currentProtocolCapabilities();
var behaviors = (capabilities.supports_transport_behaviors || []).slice();
if (mode === 'unary') return behaviors.filter(function(value) { return value === 'request_response'; });
if (mode === 'window') return behaviors.filter(function(value) {
return value === 'request_response' || value === 'server_stream';
});
if (mode === 'session') return behaviors.filter(function(value) {
return value === 'server_stream' || value === 'stateful_session';
});
if (mode === 'async_job') return behaviors.filter(function(value) {
return value === 'deferred_result';
});
return behaviors;
}
function modeOptionsForProtocol() {
var capabilities = currentProtocolCapabilities();
return (capabilities.supports_execution_modes || ['unary']).filter(function(mode) {
return transportOptionsForMode(mode).length > 0 || mode === 'unary';
});
}
function updateStreamingTransportOptions(preferredValue) {
var element = document.getElementById('streaming-transport-behavior');
if (!element) return;
var options = transportOptionsForMode(selectedStreamingMode());
element.innerHTML = '';
options.forEach(function(value) {
var option = document.createElement('option');
option.value = value;
option.textContent = tKey('wizard.streaming.transport.' + value);
element.appendChild(option);
});
if (preferredValue && options.indexOf(preferredValue) >= 0) {
element.value = preferredValue;
} else if (options.length) {
element.value = options[0];
}
}
function updateStreamingModeOptions(preferredValue) {
var element = document.getElementById('streaming-mode');
if (!element) return;
var options = modeOptionsForProtocol();
element.innerHTML = '';
options.forEach(function(value) {
var option = document.createElement('option');
option.value = value;
option.textContent = tKey('wizard.streaming.mode.' + value);
element.appendChild(option);
});
if (preferredValue && options.indexOf(preferredValue) >= 0) {
element.value = preferredValue;
} else {
element.value = options.indexOf('unary') >= 0 ? 'unary' : (options[0] || 'unary');
}
updateStreamingTransportOptions();
}
function ensureStreamingToolFamilyDefaults(mode) {
var base = textValue('tool-name') || 'stream_tool';
if (mode === 'session') {
if (!textValue('streaming-start-tool-name')) setValue('streaming-start-tool-name', base + '_start');
if (!textValue('streaming-poll-tool-name')) setValue('streaming-poll-tool-name', base + '_poll');
if (!textValue('streaming-stop-tool-name')) setValue('streaming-stop-tool-name', base + '_stop');
}
if (mode === 'async_job') {
if (!textValue('streaming-start-tool-name')) setValue('streaming-start-tool-name', base + '_start');
if (!textValue('streaming-status-tool-name')) setValue('streaming-status-tool-name', base + '_status');
if (!textValue('streaming-result-tool-name')) setValue('streaming-result-tool-name', base + '_result');
if (!textValue('streaming-cancel-tool-name')) setValue('streaming-cancel-tool-name', base + '_cancel');
}
}
function updateStreamingConfigVisibility() {
var mode = selectedStreamingMode();
var card = document.getElementById('wizard-streaming-config-card');
var help = document.getElementById('streaming-mode-help');
var toolFamily = document.getElementById('streaming-tool-family-block');
var asyncRow = document.getElementById('streaming-async-tool-family-row');
if (!card) return;
var isUnary = mode === 'unary';
var isWindow = mode === 'window';
var isSession = mode === 'session';
var isAsyncJob = mode === 'async_job';
[
'streaming-window-duration-ms',
'streaming-poll-interval-ms',
'streaming-upstream-timeout-ms',
'streaming-idle-timeout-ms',
'streaming-session-lifetime-ms',
'streaming-max-items',
'streaming-max-bytes',
'streaming-max-field-length',
'streaming-sampling-rate',
'streaming-items-path',
'streaming-summary-path',
'streaming-done-path',
'streaming-cursor-path',
'streaming-status-path',
'streaming-redacted-paths',
'streaming-truncate-item-fields',
'streaming-drop-duplicates'
].forEach(function(id) {
var node = document.getElementById(id);
if (!node) return;
var group = node.closest('.form-group') || node.closest('.checkbox-pill');
if (group) {
group.style.display = isUnary ? 'none' : '';
}
});
if (help) {
help.textContent = isUnary
? tKey('wizard.streaming.help.unary')
: (isSession
? tKey('wizard.streaming.help.session')
: (isAsyncJob
? tKey('wizard.streaming.help.async_job')
: tKey('wizard.streaming.help.window')));
}
if (toolFamily) toolFamily.style.display = (isSession || isAsyncJob) ? '' : 'none';
if (asyncRow) asyncRow.style.display = isAsyncJob ? '' : 'none';
ensureStreamingToolFamilyDefaults(mode);
}
function bindStreamingConfigControls() {
updateStreamingModeOptions();
updateStreamingConfigVisibility();
var mode = document.getElementById('streaming-mode');
if (mode) {
mode.addEventListener('change', function() {
updateStreamingTransportOptions();
updateStreamingConfigVisibility();
});
}
}
function collectStreamingConfig() {
var mode = selectedStreamingMode();
if (mode === 'unary') return null;
var config = {
mode: mode,
transport_behavior: stringValueOrNull('streaming-transport-behavior') || 'request_response',
window_duration_ms: numericValueOrNull('streaming-window-duration-ms'),
poll_interval_ms: numericValueOrNull('streaming-poll-interval-ms'),
upstream_timeout_ms: numericValueOrNull('streaming-upstream-timeout-ms'),
idle_timeout_ms: numericValueOrNull('streaming-idle-timeout-ms'),
max_session_lifetime_ms: numericValueOrNull('streaming-session-lifetime-ms'),
max_items: numericValueOrNull('streaming-max-items'),
max_bytes: numericValueOrNull('streaming-max-bytes'),
aggregation_mode: stringValueOrNull('streaming-aggregation-mode') || 'summary_plus_samples',
summary_path: stringValueOrNull('streaming-summary-path'),
items_path: stringValueOrNull('streaming-items-path'),
cursor_path: stringValueOrNull('streaming-cursor-path'),
status_path: stringValueOrNull('streaming-status-path'),
done_path: stringValueOrNull('streaming-done-path'),
redacted_paths: textareaLines('streaming-redacted-paths'),
truncate_item_fields: !!document.getElementById('streaming-truncate-item-fields').checked,
max_field_length: numericValueOrNull('streaming-max-field-length'),
drop_duplicates: !!document.getElementById('streaming-drop-duplicates').checked,
sampling_rate: (function() {
var value = textValue('streaming-sampling-rate');
if (!value) return null;
var parsed = Number(value);
return Number.isFinite(parsed) ? parsed : null;
}()),
tool_family: {},
};
if (mode === 'session') {
config.tool_family.start_tool_name = stringValueOrNull('streaming-start-tool-name');
config.tool_family.poll_tool_name = stringValueOrNull('streaming-poll-tool-name');
config.tool_family.stop_tool_name = stringValueOrNull('streaming-stop-tool-name');
} else if (mode === 'async_job') {
config.tool_family.start_tool_name = stringValueOrNull('streaming-start-tool-name');
config.tool_family.status_tool_name = stringValueOrNull('streaming-status-tool-name');
config.tool_family.result_tool_name = stringValueOrNull('streaming-result-tool-name');
config.tool_family.cancel_tool_name = stringValueOrNull('streaming-cancel-tool-name');
}
Object.keys(config).forEach(function(key) {
if (config[key] === null) delete config[key];
});
if (!config.redacted_paths.length) delete config.redacted_paths;
return config;
}
function applyStreamingConfig(streaming) {
updateStreamingModeOptions(streaming && streaming.mode ? streaming.mode : 'unary');
if (!streaming) {
updateStreamingConfigVisibility();
return;
}
setValue('streaming-window-duration-ms', streaming.window_duration_ms || '');
setValue('streaming-poll-interval-ms', streaming.poll_interval_ms || '');
setValue('streaming-upstream-timeout-ms', streaming.upstream_timeout_ms || '');
setValue('streaming-idle-timeout-ms', streaming.idle_timeout_ms || '');
setValue('streaming-session-lifetime-ms', streaming.max_session_lifetime_ms || '');
setValue('streaming-max-items', streaming.max_items || '');
setValue('streaming-max-bytes', streaming.max_bytes || '');
setValue('streaming-max-field-length', streaming.max_field_length || '');
setValue('streaming-sampling-rate', streaming.sampling_rate || '');
setValue('streaming-items-path', streaming.items_path || '');
setValue('streaming-summary-path', streaming.summary_path || '');
setValue('streaming-done-path', streaming.done_path || '');
setValue('streaming-cursor-path', streaming.cursor_path || '');
setValue('streaming-status-path', streaming.status_path || '');
setValue('streaming-redacted-paths', (streaming.redacted_paths || []).join('\n'));
document.getElementById('streaming-truncate-item-fields').checked = !!streaming.truncate_item_fields;
document.getElementById('streaming-drop-duplicates').checked = !!streaming.drop_duplicates;
updateStreamingTransportOptions(streaming.transport_behavior);
setValue('streaming-start-tool-name', streaming.tool_family && streaming.tool_family.start_tool_name ? streaming.tool_family.start_tool_name : '');
setValue('streaming-poll-tool-name', streaming.tool_family && streaming.tool_family.poll_tool_name ? streaming.tool_family.poll_tool_name : '');
setValue('streaming-stop-tool-name', streaming.tool_family && streaming.tool_family.stop_tool_name ? streaming.tool_family.stop_tool_name : '');
setValue('streaming-status-tool-name', streaming.tool_family && streaming.tool_family.status_tool_name ? streaming.tool_family.status_tool_name : '');
setValue('streaming-result-tool-name', streaming.tool_family && streaming.tool_family.result_tool_name ? streaming.tool_family.result_tool_name : '');
setValue('streaming-cancel-tool-name', streaming.tool_family && streaming.tool_family.cancel_tool_name ? streaming.tool_family.cancel_tool_name : '');
var aggregation = document.getElementById('streaming-aggregation-mode');
if (aggregation && streaming.aggregation_mode) aggregation.value = streaming.aggregation_mode;
updateStreamingConfigVisibility();
}
function buildToolDescription() {
return {
title: textValue('tool-title') || textValue('tool-display-name') || textValue('tool-name'),
@@ -1439,6 +1750,7 @@ function prefillWizardFromEdit(detail, versionDocument) {
setValue('tool-input-mapping', mappingSetToEditorValue(versionDocument.input_mapping, 'input', detail.protocol));
setValue('tool-output-mapping', mappingSetToEditorValue(versionDocument.output_mapping, 'output', detail.protocol));
setValue('tool-exec-config', executionConfigToEditorValue(versionDocument.execution_config));
applyStreamingConfig(versionDocument.execution_config ? versionDocument.execution_config.streaming : null);
var toolNameInput = document.getElementById('tool-name');
if (toolNameInput) toolNameInput.disabled = true;
@@ -1525,6 +1837,8 @@ async function runWizardLiveAction(button, busyLabel, handler) {
function updateWizardProtocolVisibility() {
var grpcTools = document.getElementById('wizard-grpc-live-tools');
if (grpcTools) grpcTools.style.display = wizardProtocol === 'grpc' ? '' : 'none';
updateStreamingModeOptions();
updateStreamingConfigVisibility();
}
function showWizardLiveStatus(title, text, isError) {
+16
View File
@@ -34,6 +34,14 @@ server {
try_files /html/usage.html =404;
}
location = /stream-sessions {
try_files /html/stream-sessions.html =404;
}
location = /async-jobs {
try_files /html/async-jobs.html =404;
}
location = /settings {
try_files /html/settings.html =404;
}
@@ -75,6 +83,14 @@ server {
return 302 /usage;
}
location = /html/stream-sessions.html {
return 302 /stream-sessions;
}
location = /html/async-jobs.html {
return 302 /async-jobs;
}
location = /html/settings.html {
return 302 /settings;
}