feat: expose operation target summaries for alpine ui

This commit is contained in:
a.tolmachev
2026-03-30 01:15:53 +03:00
parent f45ace378a
commit 896078d99e
8 changed files with 112 additions and 33 deletions
+1
View File
@@ -12,6 +12,7 @@ DoD:
- UI продолжает раздаваться как статический контейнер через текущий deployment path
- legacy React/Vite артефакты удалены из `apps/ui`
- backend для `Operations/Wizard` поддерживает server-side `PATCH`, `DELETE`, `archive`, `category`, `usage_summary`, `agent_refs`
- backend summary для `Operations` отдает `target_url` и `target_action`
- первые экраны Alpine UI можно сажать на реальные catalog/detail/mutation endpoints без расширения backend-контракта
- `Operations` page читает live catalog из `admin-api` и удаляет операции через backend
- `Wizard` использует live create/get/update flow вместо `localStorage` и `sessionStorage`
+10
View File
@@ -210,6 +210,11 @@ mod tests {
.unwrap();
assert_eq!(listed["items"][0]["name"], "crm_create_lead");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
assert_eq!(published["published_version"], 1);
assert_eq!(test_run["ok"], true);
assert_eq!(
@@ -251,6 +256,11 @@ mod tests {
.unwrap();
assert_eq!(listed["total"], 1);
assert_eq!(listed["items"][0]["category"], "sales");
assert_eq!(
listed["items"][0]["target_url"],
format!("{upstream_base_url}/crm/leads")
);
assert_eq!(listed["items"][0]["target_action"], "POST");
let updated = client
.patch(format!("{base_url}/operations/{operation_id}"))
+4
View File
@@ -291,6 +291,8 @@ pub struct OperationSummaryView {
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub target_url: String,
pub target_action: String,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
@@ -2070,6 +2072,8 @@ fn enrich_operation_summary(
display_name: summary.display_name,
category: summary.category,
protocol: summary.protocol,
target_url: summary.target_url,
target_action: summary.target_action,
status: summary.status,
current_draft_version: summary.current_draft_version,
latest_published_version: summary.latest_published_version,
+2 -2
View File
@@ -40,8 +40,8 @@ function mapOperation(item) {
created_at: item.created_at,
updated_at: item.updated_at,
published_at: item.published_at,
target_url: '',
method: '',
target_url: item.target_url || '',
method: item.target_action || '',
usage_summary: item.usage_summary || {
calls_today: 0,
error_rate_pct: 0,
+2
View File
@@ -157,6 +157,8 @@ pub struct OperationSummary {
pub display_name: String,
pub category: String,
pub protocol: Protocol,
pub target_url: String,
pub target_action: String,
pub status: OperationStatus,
pub current_draft_version: u32,
pub latest_published_version: Option<u32>,
+90 -30
View File
@@ -1,7 +1,8 @@
use crank_core::{
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, InvitationId,
InvitationToken, InvocationLog, InvocationLogId, OperationId, OperationStatus, PlatformApiKey,
PlatformApiKeyId, PlatformApiKeyStatus, UsageRollup, User, UserId, Workspace, WorkspaceId,
AgentId, AgentOperationBinding, AgentStatus, AgentVersion, AuthProfile, GraphqlOperationType,
HttpMethod, InvitationId, InvitationToken, InvocationLog, InvocationLogId, OperationId,
OperationStatus, PlatformApiKey, PlatformApiKeyId, PlatformApiKeyStatus, Target, UsageRollup,
User, UserId, Workspace, WorkspaceId,
};
use serde::{Serialize, de::DeserializeOwned};
use serde_json::Value;
@@ -1490,21 +1491,25 @@ impl PostgresRegistry {
) -> Result<Vec<OperationSummary>, RegistryError> {
let rows = sqlx::query(
"select
id,
workspace_id,
name,
display_name,
category,
protocol,
status,
current_draft_version,
latest_published_version,
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
operations.id,
operations.workspace_id,
operations.name,
operations.display_name,
operations.category,
operations.protocol,
ov.target_json,
operations.status,
operations.current_draft_version,
operations.latest_published_version,
to_char(operations.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(operations.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(operations.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
from operations
where workspace_id = $1
order by name asc",
join operation_versions ov
on ov.operation_id = operations.id
and ov.version = operations.current_draft_version
where operations.workspace_id = $1
order by operations.name asc",
)
.bind(workspace_id.as_str())
.fetch_all(&self.pool)
@@ -1571,20 +1576,24 @@ impl PostgresRegistry {
) -> Result<Option<OperationSummary>, RegistryError> {
let row = sqlx::query(
"select
id,
workspace_id,
name,
display_name,
category,
protocol,
status,
current_draft_version,
latest_published_version,
to_char(created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
operations.id,
operations.workspace_id,
operations.name,
operations.display_name,
operations.category,
operations.protocol,
ov.target_json,
operations.status,
operations.current_draft_version,
operations.latest_published_version,
to_char(operations.created_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as created_at,
to_char(operations.updated_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as updated_at,
to_char(operations.published_at at time zone 'UTC', 'YYYY-MM-DD\"T\"HH24:MI:SS\"Z\"') as published_at
from operations
where workspace_id = $1 and id = $2",
join operation_versions ov
on ov.operation_id = operations.id
and ov.version = operations.current_draft_version
where operations.workspace_id = $1 and operations.id = $2",
)
.bind(workspace_id.as_str())
.bind(operation_id.as_str())
@@ -2447,6 +2456,9 @@ fn map_agent_summary(row: &PgRow) -> Result<AgentSummary, RegistryError> {
}
fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError> {
let target: Target = deserialize_json_value(row.try_get::<Json<Value>, _>("target_json")?.0)?;
let (target_url, target_action) = target_summary(&target);
Ok(OperationSummary {
id: OperationId::new(row.try_get::<String, _>("id")?),
workspace_id: WorkspaceId::new(row.try_get::<String, _>("workspace_id")?),
@@ -2454,6 +2466,8 @@ fn map_operation_summary(row: &PgRow) -> Result<OperationSummary, RegistryError>
display_name: row.try_get("display_name")?,
category: row.try_get("category")?,
protocol: deserialize_enum_text(&row.try_get::<String, _>("protocol")?, "protocol")?,
target_url,
target_action,
status: deserialize_enum_text(&row.try_get::<String, _>("status")?, "status")?,
current_draft_version: from_db_version(
row.try_get("current_draft_version")?,
@@ -2478,6 +2492,52 @@ fn map_operation_usage_summary(row: &PgRow) -> Result<OperationUsageSummary, Reg
})
}
fn target_summary(target: &Target) -> (String, String) {
match target {
Target::Rest(rest) => (
join_target_url(&rest.base_url, &rest.path_template),
match rest.method {
HttpMethod::Get => "GET",
HttpMethod::Post => "POST",
HttpMethod::Put => "PUT",
HttpMethod::Patch => "PATCH",
HttpMethod::Delete => "DELETE",
}
.to_owned(),
),
Target::Graphql(graphql) => (
graphql.endpoint.clone(),
match graphql.operation_type {
GraphqlOperationType::Query => "QUERY",
GraphqlOperationType::Mutation => "MUTATION",
}
.to_owned(),
),
Target::Grpc(grpc) => (grpc.server_addr.clone(), grpc.method.clone()),
}
}
fn join_target_url(base: &str, path: &str) -> String {
if path.is_empty() {
return base.to_owned();
}
if path.starts_with("http://") || path.starts_with("https://") || path.starts_with("grpc://") {
return path.to_owned();
}
let trimmed_base = base.trim_end_matches('/');
let trimmed_path = path.trim_start_matches('/');
if trimmed_base.is_empty() {
format!("/{trimmed_path}")
} else if trimmed_path.is_empty() {
trimmed_base.to_owned()
} else {
format!("{trimmed_base}/{trimmed_path}")
}
}
fn map_operation_agent_ref(row: &PgRow) -> Result<OperationAgentRef, RegistryError> {
Ok(OperationAgentRef {
operation_id: OperationId::new(row.try_get::<String, _>("operation_id")?),
+1 -1
View File
@@ -73,7 +73,7 @@ UI-файлы:
- эту страницу можно интегрировать первой;
- backend уже отдает server-side category, usage summary и agent refs;
- каталог уже может работать через реальные `list/delete/edit` вызовы;
- следующий шаг здесь только в richer summary shape, если нужно вернуть `target_url` и server-side paging.
- следующий шаг здесь только в server-side paging и остальных catalog actions; `target_url` и `target_action` теперь должны приходить из backend summary.
### 4.2. Wizard
+2
View File
@@ -183,6 +183,8 @@ Query params:
"protocol": "rest",
"status": "draft",
"category": "sales",
"target_url": "https://api.example.com/v1/leads",
"target_action": "POST",
"current_draft_version": 3,
"latest_published_version": 2,
"updated_at": "2026-03-29T12:00:00Z",