From b40daf4f54f869ccd3bf6fb6e286f2f5312673c4 Mon Sep 17 00:00:00 2001 From: "a.tolmachev" Date: Mon, 6 Apr 2026 12:04:49 +0300 Subject: [PATCH] feat: add streaming ui configuration --- TASKS.md | 21 +- apps/admin-api/src/app.rs | 17 +- apps/admin-api/src/routes.rs | 1 + apps/admin-api/src/routes/streaming.rs | 137 +++++++++ apps/admin-api/src/service.rs | 395 ++++++++++++++++++++++++- apps/ui/css/pages.css | 171 +++++++++++ apps/ui/css/wizard.css | 32 ++ apps/ui/html/async-jobs.html | 112 +++++++ apps/ui/html/stream-sessions.html | 117 ++++++++ apps/ui/html/wizard/index.html | 2 + apps/ui/html/wizard/step5.html | 158 ++++++++++ apps/ui/js/api.js | 24 ++ apps/ui/js/async-jobs.js | 181 +++++++++++ apps/ui/js/config.js | 2 + apps/ui/js/i18n.js | 234 +++++++++++++++ apps/ui/js/stream-sessions.js | 166 +++++++++++ apps/ui/js/stream-test-run.js | 47 +++ apps/ui/js/streaming-form.js | 127 ++++++++ apps/ui/js/wizard.js | 314 ++++++++++++++++++++ apps/ui/nginx.conf | 16 + 20 files changed, 2254 insertions(+), 20 deletions(-) create mode 100644 apps/admin-api/src/routes/streaming.rs create mode 100644 apps/ui/html/async-jobs.html create mode 100644 apps/ui/html/stream-sessions.html create mode 100644 apps/ui/js/async-jobs.js create mode 100644 apps/ui/js/stream-sessions.js create mode 100644 apps/ui/js/stream-test-run.js create mode 100644 apps/ui/js/streaming-form.js diff --git a/TASKS.md b/TASKS.md index 5038a98..f19acb4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -2,20 +2,25 @@ ## Current -### `feat/session-and-job-tools` +### `feat/streaming-e2e` -Status: completed +Status: pending DoD: -- session and async_job tool families are published in the MCP tool catalog -- generated `start/poll/stop` and `start/status/result/cancel` tool calls work through JSON-RPC -- stream sessions and async jobs are persisted with explicit status transitions -- session and async_job tool calls are logged through observability -- MCP integration tests cover `session start -> poll -> stop` and `async job start -> status -> result` +- local fixture stack includes: + - REST SSE server + - gRPC server-streaming server + - WebSocket event server +- Playwright covers: + - REST window flow + - gRPC streaming flow + - session tool flow + - async job flow +- CI runs streaming e2e against the local fixture stack ## Next -- `feat/streaming-ui-config` +- `feat/websocket-upstream-adapter` ## Backlog diff --git a/apps/admin-api/src/app.rs b/apps/admin-api/src/app.rs index b975275..2a7ea31 100644 --- a/apps/admin-api/src/app.rs +++ b/apps/admin-api/src/app.rs @@ -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)) diff --git a/apps/admin-api/src/routes.rs b/apps/admin-api/src/routes.rs index f5b1e3b..f53ac88 100644 --- a/apps/admin-api/src/routes.rs +++ b/apps/admin-api/src/routes.rs @@ -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; diff --git a/apps/admin-api/src/routes/streaming.rs b/apps/admin-api/src/routes/streaming.rs new file mode 100644 index 0000000..fae361f --- /dev/null +++ b/apps/admin-api/src/routes/streaming.rs @@ -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, + State(state): State, +) -> Result, ApiError> { + Ok(Json(json!({ + "items": state.service.list_protocol_capabilities().await + }))) +} + +pub async fn list_stream_sessions( + Path(path): Path, + Query(query): Query, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + Query(query): Query, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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, + State(state): State, +) -> Result, 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))) +} diff --git a/apps/admin-api/src/service.rs b/apps/admin-api/src/service.rs index ddb9c7e..afaeab3 100644 --- a/apps/admin-api/src/service.rs +++ b/apps/admin-api/src/service.rs @@ -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, } +#[derive(Clone, Debug, Serialize)] +pub struct ProtocolCapabilityView { + pub protocol: Protocol, + pub supports_execution_modes: Vec, + pub supports_transport_behaviors: Vec, + pub supports_auth_kinds: Vec, + pub supports_upload_artifacts: Vec, + pub supports_cursor_path: bool, + pub supports_done_path: bool, + pub supports_aggregation_mode: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct StreamSessionsQuery { + pub operation_id: Option, + pub agent_id: Option, + pub status: Option, + pub mode: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionSummaryView { + pub id: String, + pub operation_id: String, + pub agent_id: Option, + pub protocol: Protocol, + pub mode: ExecutionMode, + pub status: StreamStatus, + pub expires_at: String, + pub last_poll_at: Option, + pub created_at: String, + pub closed_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct StreamSessionDetailView { + #[serde(flatten)] + pub summary: StreamSessionSummaryView, + pub cursor: Option, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct AsyncJobsQuery { + pub operation_id: Option, + pub agent_id: Option, + pub status: Option, + pub page: Option, + pub page_size: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobSummaryView { + pub id: String, + pub operation_id: String, + pub agent_id: Option, + pub status: JobStatus, + pub progress: Value, + pub created_at: String, + pub updated_at: String, + pub finished_at: Option, + pub expires_at: Option, +} + +#[derive(Clone, Debug, Serialize)] +pub struct AsyncJobDetailView { + #[serde(flatten)] + pub summary: AsyncJobSummaryView, + pub error: Option, +} + #[derive(Clone, Debug, Deserialize)] pub struct GenerateDraftPayload { #[serde(default)] @@ -1273,6 +1346,185 @@ impl AdminService { }) } + pub async fn list_protocol_capabilities(&self) -> Vec { + [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, 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 { + 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 { + 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, 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 { + 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 { + 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 { + 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 { diff --git a/apps/ui/css/pages.css b/apps/ui/css/pages.css index c7316a7..22338dc 100644 --- a/apps/ui/css/pages.css +++ b/apps/ui/css/pages.css @@ -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; +} diff --git a/apps/ui/css/wizard.css b/apps/ui/css/wizard.css index f99a4b3..ec10830 100644 --- a/apps/ui/css/wizard.css +++ b/apps/ui/css/wizard.css @@ -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 ══════════════════════════════════════════════════ */ diff --git a/apps/ui/html/async-jobs.html b/apps/ui/html/async-jobs.html new file mode 100644 index 0000000..401ab4a --- /dev/null +++ b/apps/ui/html/async-jobs.html @@ -0,0 +1,112 @@ + + + + + + + Crank — Async Jobs + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/apps/ui/html/stream-sessions.html b/apps/ui/html/stream-sessions.html new file mode 100644 index 0000000..223fb44 --- /dev/null +++ b/apps/ui/html/stream-sessions.html @@ -0,0 +1,117 @@ + + + + + + + Crank — Stream Sessions + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ + +
+
+ +
+
+
+
+
+
+ + + + + diff --git a/apps/ui/html/wizard/index.html b/apps/ui/html/wizard/index.html index 9f973b4..38e864c 100644 --- a/apps/ui/html/wizard/index.html +++ b/apps/ui/html/wizard/index.html @@ -235,6 +235,8 @@ + + diff --git a/apps/ui/html/wizard/step5.html b/apps/ui/html/wizard/step5.html index ffb37a7..96168f9 100644 --- a/apps/ui/html/wizard/step5.html +++ b/apps/ui/html/wizard/step5.html @@ -87,6 +87,164 @@ tls: +
+
+
+ + + + +
+
+
Streaming execution
+
Bounded stream, session and async job settings for MCP tool families.
+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
Unary keeps the existing request-response model. Other modes create bounded stream-aware MCP tools.
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ + +
+ + +
+
+
Live validation and publishing
diff --git a/apps/ui/js/api.js b/apps/ui/js/api.js index 3ddd516..c5907f9 100644 --- a/apps/ui/js/api.js +++ b/apps/ui/js/api.js @@ -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) diff --git a/apps/ui/js/async-jobs.js b/apps/ui/js/async-jobs.js new file mode 100644 index 0000000..aa17190 --- /dev/null +++ b/apps/ui/js/async-jobs.js @@ -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 = '
' + title + '
' + body + '
'; + } + + 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 [ + '
', + '
', + '
', + '
' + item.id + '
', + '
' + tKey('async_jobs.operation', { operation: item.operation_id }) + '
', + '
', + ' ' + statusLabel + '', + '
', + '
', + '
', + item.status === 'created' || item.status === 'running' + ? ' ' + : '', + ' ', + '
', + '
', + '
', + '
' + tKey('async_jobs.meta.created') + '
' + formatDateTime(item.created_at) + '
', + '
' + tKey('async_jobs.meta.updated') + '
' + formatDateTime(item.updated_at) + '
', + '
' + tKey('async_jobs.meta.finished') + '
' + formatDateTime(item.finished_at) + '
', + '
' + tKey('async_jobs.meta.expires') + '
' + formatDateTime(item.expires_at) + '
', + '
', + open + ? '
' + tKey('async_jobs.progress') + '
' + formatJson(item.progress) + '
' + : '', + open + ? '
' + tKey('async_jobs.error_preview') + '
' + formatJson(item.error) + '
' + : '', + open && (item.status === 'completed' || item.status === 'failed' || item.status === 'cancelled' || item.status === 'expired') + ? '
' + tKey('async_jobs.result') + '
' + tKey('async_jobs.result_loading') + '
' + : '', + '
' + ].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(); +}); diff --git a/apps/ui/js/config.js b/apps/ui/js/config.js index 699747c..1467fff 100644 --- a/apps/ui/js/config.js +++ b/apps/ui/js/config.js @@ -8,6 +8,8 @@ apiKeys: '/api-keys', logs: '/logs', usage: '/usage', + streamSessions: '/stream-sessions', + asyncJobs: '/async-jobs', settings: '/settings', workspaceSetup: '/workspace-setup', wizard: '/wizard/' diff --git a/apps/ui/js/i18n.js b/apps/ui/js/i18n.js index 7d9f7e3..6f8cf8b 100644 --- a/apps/ui/js/i18n.js +++ b/apps/ui/js/i18n.js @@ -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; diff --git a/apps/ui/js/stream-sessions.js b/apps/ui/js/stream-sessions.js new file mode 100644 index 0000000..57e7b0d --- /dev/null +++ b/apps/ui/js/stream-sessions.js @@ -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 = '
' + title + '
' + body + '
'; + } + + 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 [ + '
', + '
', + '
', + '
' + item.id + '
', + '
' + tKey('stream_sessions.operation', { operation: item.operation_id }) + '
', + '
', + ' ' + statusLabel + '', + ' ' + item.mode + '', + ' ' + item.protocol + '', + '
', + '
', + '
', + item.status === 'created' || item.status === 'running' + ? ' ' + : '', + ' ', + '
', + '
', + '
', + '
' + tKey('stream_sessions.meta.created') + '
' + formatDateTime(item.created_at) + '
', + '
' + tKey('stream_sessions.meta.last_poll') + '
' + formatDateTime(item.last_poll_at) + '
', + '
' + tKey('stream_sessions.meta.expires') + '
' + formatDateTime(item.expires_at) + '
', + '
' + tKey('stream_sessions.meta.agent') + '
' + (item.agent_id || '—') + '
', + '
', + open + ? '
' + tKey('stream_sessions.cursor') + '
' + formatJson(item.cursor) + '
' + : '', + '
' + ].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(); +}); diff --git a/apps/ui/js/stream-test-run.js b/apps/ui/js/stream-test-run.js new file mode 100644 index 0000000..9a8e7cd --- /dev/null +++ b/apps/ui/js/stream-test-run.js @@ -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, + }; +}()); diff --git a/apps/ui/js/streaming-form.js b/apps/ui/js/streaming-form.js new file mode 100644 index 0000000..17da8c8 --- /dev/null +++ b/apps/ui/js/streaming-form.js @@ -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, + }; +}()); diff --git a/apps/ui/js/wizard.js b/apps/ui/js/wizard.js index eb95b9b..74a80e1 100644 --- a/apps/ui/js/wizard.js +++ b/apps/ui/js/wizard.js @@ -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) { diff --git a/apps/ui/nginx.conf b/apps/ui/nginx.conf index a14db78..744d223 100644 --- a/apps/ui/nginx.conf +++ b/apps/ui/nginx.conf @@ -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; }