feat: polish protocol-aware operator UI
This commit is contained in:
@@ -2,26 +2,22 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/deployment-cd-polish`
|
||||
### `feat/ui-polish`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
|
||||
- CI проверяет deployment artifacts
|
||||
- CD запускается только после успешного CI на `main` или вручную
|
||||
- compose не публикует внутренние сервисы шире, чем нужно
|
||||
- deploy workflow проверяет health сервисов после запуска
|
||||
- deployment documentation синхронизирована с фактическим pipeline
|
||||
- UI больше не выглядит `REST-only`, если backend уже поддерживает `REST`, `GraphQL` и `gRPC`
|
||||
- create flow позволяет собрать operation для всех поддерживаемых протоколов
|
||||
- detail screen показывает protocol-specific metadata и не сводится к одному длинному сырому столбцу
|
||||
- для `gRPC` доступен descriptor upload и просмотр unary services/methods
|
||||
- UI build и UI tests проходят в CI-эквивалентном режиме
|
||||
|
||||
## Next
|
||||
|
||||
- `feat/ui-polish`
|
||||
- `feat/demo-assets`
|
||||
|
||||
## Backlog
|
||||
|
||||
- `feat/admin-api-v1`
|
||||
- `feat/ui-v1`
|
||||
- `feat/mcp-server`
|
||||
- `feat/graphql-support`
|
||||
- `feat/grpc-support`
|
||||
- `feat/demo-assets`
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { getJson, getText, postJson, postText } from "../../shared/api/client";
|
||||
import {
|
||||
getJson,
|
||||
getText,
|
||||
postBytes,
|
||||
postJson,
|
||||
postText,
|
||||
} from "../../shared/api/client";
|
||||
import type {
|
||||
AuthProfile,
|
||||
DraftGenerationResult,
|
||||
GrpcServiceSummary,
|
||||
OperationRecord,
|
||||
OperationSummary,
|
||||
TestRunResult,
|
||||
@@ -82,3 +89,40 @@ export function importOperationYaml(yamlText: string, mode: "create" | "upsert")
|
||||
export function listAuthProfiles() {
|
||||
return getJson<{ items: AuthProfile[] }>("/api/admin/auth-profiles");
|
||||
}
|
||||
|
||||
export function uploadProtoDescriptor(
|
||||
operationId: string,
|
||||
fileName: string,
|
||||
bytes: ArrayBuffer,
|
||||
) {
|
||||
return postBytes<{ descriptor_id: string; version: number }>(
|
||||
`/api/admin/operations/${operationId}/descriptors/proto`,
|
||||
bytes,
|
||||
{
|
||||
"Content-Type": "application/octet-stream",
|
||||
"x-file-name": fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function uploadDescriptorSet(
|
||||
operationId: string,
|
||||
fileName: string,
|
||||
bytes: ArrayBuffer,
|
||||
) {
|
||||
return postBytes<{ descriptor_id: string; version: number }>(
|
||||
`/api/admin/operations/${operationId}/descriptors/descriptor-set`,
|
||||
bytes,
|
||||
{
|
||||
"Content-Type": "application/octet-stream",
|
||||
"x-file-name": fileName,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export function listGrpcServices(operationId: string, version?: number) {
|
||||
const query = version === undefined ? "" : `?version=${version}`;
|
||||
return getJson<{ services: GrpcServiceSummary[] }>(
|
||||
`/api/admin/operations/${operationId}/grpc/services${query}`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,35 @@
|
||||
export type Protocol = "rest" | "graphql" | "grpc";
|
||||
export type OperationStatus = "draft" | "testing" | "published" | "archived";
|
||||
|
||||
export type RestTarget = {
|
||||
kind: "rest";
|
||||
base_url: string;
|
||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
||||
path_template: string;
|
||||
static_headers?: Record<string, string>;
|
||||
};
|
||||
|
||||
export type GraphqlTarget = {
|
||||
kind: "graphql";
|
||||
endpoint: string;
|
||||
operation_type: "query" | "mutation";
|
||||
operation_name: string;
|
||||
query_template: string;
|
||||
response_path: string;
|
||||
};
|
||||
|
||||
export type GrpcTarget = {
|
||||
kind: "grpc";
|
||||
server_addr: string;
|
||||
package: string;
|
||||
service: string;
|
||||
method: string;
|
||||
descriptor_ref: string;
|
||||
descriptor_set_b64: string;
|
||||
};
|
||||
|
||||
export type OperationTarget = RestTarget | GraphqlTarget | GrpcTarget;
|
||||
|
||||
export type OperationSummary = {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -31,13 +60,7 @@ export type OperationSnapshot = {
|
||||
protocol: Protocol;
|
||||
status: OperationStatus;
|
||||
version: number;
|
||||
target: {
|
||||
kind: "rest" | "graphql" | "grpc";
|
||||
base_url?: string;
|
||||
method?: string;
|
||||
path_template?: string;
|
||||
static_headers?: Record<string, string>;
|
||||
};
|
||||
target: OperationTarget;
|
||||
input_schema: unknown;
|
||||
output_schema: unknown;
|
||||
input_mapping: unknown;
|
||||
@@ -83,3 +106,16 @@ export type AuthProfile = {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
};
|
||||
|
||||
export type GrpcMethodSummary = {
|
||||
name: string;
|
||||
kind: string;
|
||||
input_schema: unknown;
|
||||
output_schema: unknown;
|
||||
};
|
||||
|
||||
export type GrpcServiceSummary = {
|
||||
package: string;
|
||||
service: string;
|
||||
methods: GrpcMethodSummary[];
|
||||
};
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
listGrpcServices,
|
||||
uploadDescriptorSet,
|
||||
uploadProtoDescriptor,
|
||||
} from "../../entities/operation/api";
|
||||
import type { GrpcServiceSummary } from "../../entities/operation/types";
|
||||
import { ApiError } from "../../shared/api/client";
|
||||
|
||||
type GrpcDescriptorPanelProps = {
|
||||
operationId: string;
|
||||
version: number;
|
||||
};
|
||||
|
||||
type UploadKind = "proto" | "descriptor-set";
|
||||
|
||||
function descriptorSubtitle(service: GrpcServiceSummary) {
|
||||
return service.package.length > 0
|
||||
? `${service.package}.${service.service}`
|
||||
: service.service;
|
||||
}
|
||||
|
||||
export function GrpcDescriptorPanel({
|
||||
operationId,
|
||||
version,
|
||||
}: GrpcDescriptorPanelProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
||||
|
||||
const servicesQuery = useQuery({
|
||||
queryKey: ["grpc-services", operationId, version],
|
||||
queryFn: () => listGrpcServices(operationId, version),
|
||||
enabled: operationId.length > 0 && version > 0,
|
||||
});
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: async ({
|
||||
file,
|
||||
kind,
|
||||
}: {
|
||||
file: File;
|
||||
kind: UploadKind;
|
||||
}) => {
|
||||
const bytes = await file.arrayBuffer();
|
||||
if (kind === "proto") {
|
||||
return uploadProtoDescriptor(operationId, file.name, bytes);
|
||||
}
|
||||
|
||||
return uploadDescriptorSet(operationId, file.name, bytes);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
setUploadError(null);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: ["grpc-services", operationId, version],
|
||||
});
|
||||
},
|
||||
onError: (error) => {
|
||||
setUploadError(
|
||||
error instanceof ApiError ? error.message : "Descriptor upload failed",
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
async function handleFileUpload(
|
||||
file: File | undefined,
|
||||
kind: UploadKind,
|
||||
) {
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUploadError(null);
|
||||
uploadMutation.mutate({ file, kind });
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack-layout">
|
||||
<div className="upload-grid">
|
||||
<label className="field-block">
|
||||
<span>Upload `.proto`</span>
|
||||
<small className="field-hint">
|
||||
Stores the source artifact alongside the current draft version.
|
||||
</small>
|
||||
<input
|
||||
type="file"
|
||||
accept=".proto"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files?.[0], "proto");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label className="field-block">
|
||||
<span>Upload descriptor set</span>
|
||||
<small className="field-hint">
|
||||
Enables unary service discovery and schema extraction for the current version.
|
||||
</small>
|
||||
<input
|
||||
type="file"
|
||||
accept=".bin,.pb,.desc"
|
||||
onChange={(event) => {
|
||||
void handleFileUpload(event.target.files?.[0], "descriptor-set");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{uploadMutation.isPending ? (
|
||||
<div className="feedback-card">Uploading descriptor artifact...</div>
|
||||
) : null}
|
||||
{uploadError ? (
|
||||
<div className="feedback-card feedback-error">{uploadError}</div>
|
||||
) : null}
|
||||
|
||||
{servicesQuery.data?.services.length ? (
|
||||
<div className="service-grid">
|
||||
{servicesQuery.data.services.map((service) => (
|
||||
<article className="service-card" key={descriptorSubtitle(service)}>
|
||||
<div className="service-card-header">
|
||||
<div>
|
||||
<h3>{service.service}</h3>
|
||||
<p>{descriptorSubtitle(service)}</p>
|
||||
</div>
|
||||
<span className="status-pill status-testing">
|
||||
{service.methods.length} methods
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="service-method-list">
|
||||
{service.methods.map((method) => (
|
||||
<div className="service-method" key={method.name}>
|
||||
<div className="service-method-top">
|
||||
<strong>{method.name}</strong>
|
||||
<span className="protocol-pill protocol-grpc">{method.kind}</span>
|
||||
</div>
|
||||
<div className="result-grid">
|
||||
<div className="result-panel">
|
||||
<h3>Input schema</h3>
|
||||
<pre>{JSON.stringify(method.input_schema, null, 2)}</pre>
|
||||
</div>
|
||||
<div className="result-panel">
|
||||
<h3>Output schema</h3>
|
||||
<pre>{JSON.stringify(method.output_schema, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="feedback-card">
|
||||
Upload a descriptor set to inspect unary services and message schemas.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,15 +1,13 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import {
|
||||
buildOperationPayload,
|
||||
defaultOperationFormValues,
|
||||
} from "./model";
|
||||
import { buildOperationPayload, defaultOperationFormValues } from "./model";
|
||||
|
||||
describe("buildOperationPayload", () => {
|
||||
it("creates a REST operation payload from form values", () => {
|
||||
const payload = buildOperationPayload(defaultOperationFormValues);
|
||||
|
||||
expect(payload.protocol).toBe("rest");
|
||||
expect(payload.target.kind).toBe("rest");
|
||||
expect(payload.target.method).toBe("POST");
|
||||
expect(payload.input_mapping).toEqual({
|
||||
rules: [
|
||||
@@ -22,6 +20,89 @@ describe("buildOperationPayload", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("creates a GraphQL operation payload from form values", () => {
|
||||
const payload = buildOperationPayload({
|
||||
...defaultOperationFormValues,
|
||||
protocol: "graphql",
|
||||
name: "crm_create_lead_graphql",
|
||||
displayName: "Create Lead (GraphQL)",
|
||||
toolTitle: "Create CRM lead through GraphQL",
|
||||
inputMappingText: JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.mcp.email",
|
||||
target: "$.request.variables.email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
outputMappingText: JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.response.body.data.createLead.id",
|
||||
target: "$.output.id",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
|
||||
expect(payload.protocol).toBe("graphql");
|
||||
expect(payload.target.kind).toBe("graphql");
|
||||
expect(payload.target.operation_type).toBe("mutation");
|
||||
expect(payload.target.response_path).toBe("$.response.body.data.createLead");
|
||||
});
|
||||
|
||||
it("creates a gRPC operation payload from form values", () => {
|
||||
const payload = buildOperationPayload({
|
||||
...defaultOperationFormValues,
|
||||
protocol: "grpc",
|
||||
name: "crm_create_lead_grpc",
|
||||
displayName: "Create Lead (gRPC)",
|
||||
toolTitle: "Create CRM lead through gRPC",
|
||||
grpcDescriptorSetB64: "ZGVzY3JpcHRvcg==",
|
||||
inputMappingText: JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.mcp.email",
|
||||
target: "$.request.grpc.email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
outputMappingText: JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.response.body.id",
|
||||
target: "$.output.id",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
});
|
||||
|
||||
expect(payload.protocol).toBe("grpc");
|
||||
expect(payload.target.kind).toBe("grpc");
|
||||
expect(payload.target.descriptor_ref).toBe("desc_lead_service");
|
||||
expect(payload.target.descriptor_set_b64).toBe("ZGVzY3JpcHRvcg==");
|
||||
});
|
||||
|
||||
it("throws when JSON fields are invalid", () => {
|
||||
expect(() =>
|
||||
buildOperationPayload({
|
||||
|
||||
@@ -2,35 +2,117 @@ import { z } from "zod";
|
||||
|
||||
import { safeParseJson } from "../../shared/lib/json";
|
||||
|
||||
const operationFormSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
.min(3, "Name is too short")
|
||||
.regex(/^[a-z0-9_]+$/, "Use lowercase snake_case"),
|
||||
displayName: z.string().min(3, "Display name is too short"),
|
||||
baseUrl: z.string().url("Base URL must be valid"),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string().min(1, "Path is required"),
|
||||
toolTitle: z.string().min(3, "Tool title is too short"),
|
||||
toolDescription: z.string().min(8, "Tool description is too short"),
|
||||
inputSchemaText: z.string().min(2, "Input schema is required"),
|
||||
outputSchemaText: z.string().min(2, "Output schema is required"),
|
||||
inputMappingText: z.string().min(2, "Input mapping is required"),
|
||||
outputMappingText: z.string().min(2, "Output mapping is required"),
|
||||
executionConfigText: z.string().min(2, "Execution config is required"),
|
||||
});
|
||||
const jsonTextSchema = z.string().min(2, "JSON payload is required");
|
||||
|
||||
export const operationFormSchema = z
|
||||
.object({
|
||||
protocol: z.enum(["rest", "graphql", "grpc"]),
|
||||
name: z
|
||||
.string()
|
||||
.min(3, "Name is too short")
|
||||
.regex(/^[a-z0-9_]+$/, "Use lowercase snake_case"),
|
||||
displayName: z.string().min(3, "Display name is too short"),
|
||||
toolTitle: z.string().min(3, "Tool title is too short"),
|
||||
toolDescription: z.string().min(8, "Tool description is too short"),
|
||||
restBaseUrl: z.string(),
|
||||
restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
restPathTemplate: z.string(),
|
||||
graphqlEndpoint: z.string(),
|
||||
graphqlOperationType: z.enum(["query", "mutation"]),
|
||||
graphqlOperationName: z.string(),
|
||||
graphqlQueryTemplate: z.string(),
|
||||
graphqlResponsePath: z.string(),
|
||||
grpcServerAddr: z.string(),
|
||||
grpcPackage: z.string(),
|
||||
grpcService: z.string(),
|
||||
grpcMethod: z.string(),
|
||||
grpcDescriptorRef: z.string(),
|
||||
grpcDescriptorSetB64: z.string(),
|
||||
inputSchemaText: jsonTextSchema,
|
||||
outputSchemaText: jsonTextSchema,
|
||||
inputMappingText: jsonTextSchema,
|
||||
outputMappingText: jsonTextSchema,
|
||||
executionConfigText: jsonTextSchema,
|
||||
})
|
||||
.superRefine((values, context) => {
|
||||
if (values.protocol === "rest") {
|
||||
if (!z.string().url().safeParse(values.restBaseUrl).success) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["restBaseUrl"],
|
||||
message: "Base URL must be valid",
|
||||
});
|
||||
}
|
||||
|
||||
if (values.restPathTemplate.trim().length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["restPathTemplate"],
|
||||
message: "Path is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (values.protocol === "graphql") {
|
||||
if (!z.string().url().safeParse(values.graphqlEndpoint).success) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["graphqlEndpoint"],
|
||||
message: "Endpoint must be valid",
|
||||
});
|
||||
}
|
||||
|
||||
if (values.graphqlOperationName.trim().length < 2) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["graphqlOperationName"],
|
||||
message: "Operation name is required",
|
||||
});
|
||||
}
|
||||
|
||||
if (values.graphqlQueryTemplate.trim().length < 8) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["graphqlQueryTemplate"],
|
||||
message: "Query template is too short",
|
||||
});
|
||||
}
|
||||
|
||||
if (values.graphqlResponsePath.trim().length < 4) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["graphqlResponsePath"],
|
||||
message: "Response path is required",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (values.protocol === "grpc") {
|
||||
const requiredFields: Array<[keyof typeof values, string]> = [
|
||||
["grpcServerAddr", "Server address is required"],
|
||||
["grpcPackage", "Package is required"],
|
||||
["grpcService", "Service is required"],
|
||||
["grpcMethod", "Method is required"],
|
||||
["grpcDescriptorRef", "Descriptor reference is required"],
|
||||
["grpcDescriptorSetB64", "Descriptor set base64 is required"],
|
||||
];
|
||||
|
||||
for (const [fieldName, message] of requiredFields) {
|
||||
if (values[fieldName].trim().length === 0) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: [fieldName],
|
||||
message,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export type OperationFormValues = z.infer<typeof operationFormSchema>;
|
||||
|
||||
export const defaultOperationFormValues: OperationFormValues = {
|
||||
name: "crm_create_lead",
|
||||
displayName: "Create Lead",
|
||||
baseUrl: "https://api.example.com",
|
||||
method: "POST",
|
||||
pathTemplate: "/v1/leads",
|
||||
toolTitle: "Create CRM lead",
|
||||
toolDescription: "Creates a CRM lead from MCP input fields.",
|
||||
inputSchemaText: JSON.stringify(
|
||||
function defaultInputSchemaText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "object",
|
||||
required: true,
|
||||
@@ -45,8 +127,11 @@ export const defaultOperationFormValues: OperationFormValues = {
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
outputSchemaText: JSON.stringify(
|
||||
);
|
||||
}
|
||||
|
||||
function defaultOutputSchemaText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
type: "object",
|
||||
required: true,
|
||||
@@ -61,8 +146,11 @@ export const defaultOperationFormValues: OperationFormValues = {
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
inputMappingText: JSON.stringify(
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRestInputMappingText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
@@ -74,8 +162,11 @@ export const defaultOperationFormValues: OperationFormValues = {
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
outputMappingText: JSON.stringify(
|
||||
);
|
||||
}
|
||||
|
||||
function defaultRestOutputMappingText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
@@ -87,44 +178,218 @@ export const defaultOperationFormValues: OperationFormValues = {
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
executionConfigText: JSON.stringify(
|
||||
);
|
||||
}
|
||||
|
||||
function defaultGraphqlInputMappingText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.mcp.email",
|
||||
target: "$.request.variables.email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function defaultGraphqlOutputMappingText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.response.body.data.createLead.id",
|
||||
target: "$.output.id",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function defaultGrpcInputMappingText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.mcp.email",
|
||||
target: "$.request.grpc.email",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function defaultGrpcOutputMappingText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
rules: [
|
||||
{
|
||||
source: "$.response.body.id",
|
||||
target: "$.output.id",
|
||||
required: true,
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
}
|
||||
|
||||
function defaultExecutionConfigText() {
|
||||
return JSON.stringify(
|
||||
{
|
||||
timeout_ms: 10000,
|
||||
headers: {},
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export const defaultOperationFormValues: OperationFormValues = {
|
||||
protocol: "rest",
|
||||
name: "crm_create_lead",
|
||||
displayName: "Create Lead",
|
||||
toolTitle: "Create CRM lead",
|
||||
toolDescription: "Creates a CRM lead from MCP input fields.",
|
||||
restBaseUrl: "https://api.example.com",
|
||||
restMethod: "POST",
|
||||
restPathTemplate: "/v1/leads",
|
||||
graphqlEndpoint: "https://api.example.com/graphql",
|
||||
graphqlOperationType: "mutation",
|
||||
graphqlOperationName: "CreateLead",
|
||||
graphqlQueryTemplate: `mutation CreateLead($email: String!) {
|
||||
createLead(email: $email) {
|
||||
id
|
||||
status
|
||||
}
|
||||
}`,
|
||||
graphqlResponsePath: "$.response.body.data.createLead",
|
||||
grpcServerAddr: "https://grpc.example.com",
|
||||
grpcPackage: "crm.v1",
|
||||
grpcService: "LeadService",
|
||||
grpcMethod: "CreateLead",
|
||||
grpcDescriptorRef: "desc_lead_service",
|
||||
grpcDescriptorSetB64: "",
|
||||
inputSchemaText: defaultInputSchemaText(),
|
||||
outputSchemaText: defaultOutputSchemaText(),
|
||||
inputMappingText: defaultRestInputMappingText(),
|
||||
outputMappingText: defaultRestOutputMappingText(),
|
||||
executionConfigText: defaultExecutionConfigText(),
|
||||
};
|
||||
|
||||
export function getProtocolPreset(
|
||||
protocol: OperationFormValues["protocol"],
|
||||
): Partial<OperationFormValues> {
|
||||
switch (protocol) {
|
||||
case "rest":
|
||||
return {
|
||||
name: "crm_create_lead",
|
||||
displayName: "Create Lead",
|
||||
toolTitle: "Create CRM lead",
|
||||
toolDescription: "Creates a CRM lead from MCP input fields.",
|
||||
inputMappingText: defaultRestInputMappingText(),
|
||||
outputMappingText: defaultRestOutputMappingText(),
|
||||
};
|
||||
case "graphql":
|
||||
return {
|
||||
name: "crm_create_lead_graphql",
|
||||
displayName: "Create Lead (GraphQL)",
|
||||
toolTitle: "Create CRM lead through GraphQL",
|
||||
toolDescription:
|
||||
"Executes a fixed GraphQL mutation and returns the selected lead payload.",
|
||||
inputMappingText: defaultGraphqlInputMappingText(),
|
||||
outputMappingText: defaultGraphqlOutputMappingText(),
|
||||
};
|
||||
case "grpc":
|
||||
return {
|
||||
name: "crm_create_lead_grpc",
|
||||
displayName: "Create Lead (gRPC)",
|
||||
toolTitle: "Create CRM lead through gRPC",
|
||||
toolDescription:
|
||||
"Executes a unary gRPC method using a descriptor-driven request contract.",
|
||||
inputMappingText: defaultGrpcInputMappingText(),
|
||||
outputMappingText: defaultGrpcOutputMappingText(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function buildOperationPayload(rawValues: OperationFormValues) {
|
||||
const values = operationFormSchema.parse(rawValues);
|
||||
const inputSchema = safeParseJson(values.inputSchemaText, "Input schema");
|
||||
const outputSchema = safeParseJson(values.outputSchemaText, "Output schema");
|
||||
const inputMapping = safeParseJson(values.inputMappingText, "Input mapping");
|
||||
const outputMapping = safeParseJson(values.outputMappingText, "Output mapping");
|
||||
const executionConfig = safeParseJson(
|
||||
values.executionConfigText,
|
||||
"Execution config",
|
||||
);
|
||||
|
||||
return {
|
||||
const basePayload = {
|
||||
name: values.name,
|
||||
display_name: values.displayName,
|
||||
protocol: "rest",
|
||||
target: {
|
||||
kind: "rest",
|
||||
base_url: values.baseUrl,
|
||||
method: values.method,
|
||||
path_template: values.pathTemplate,
|
||||
static_headers: {},
|
||||
},
|
||||
input_schema: safeParseJson(values.inputSchemaText, "Input schema"),
|
||||
output_schema: safeParseJson(values.outputSchemaText, "Output schema"),
|
||||
input_mapping: safeParseJson(values.inputMappingText, "Input mapping"),
|
||||
output_mapping: safeParseJson(values.outputMappingText, "Output mapping"),
|
||||
execution_config: safeParseJson(
|
||||
values.executionConfigText,
|
||||
"Execution config",
|
||||
),
|
||||
input_schema: inputSchema,
|
||||
output_schema: outputSchema,
|
||||
input_mapping: inputMapping,
|
||||
output_mapping: outputMapping,
|
||||
execution_config: executionConfig,
|
||||
tool_description: {
|
||||
title: values.toolTitle,
|
||||
description: values.toolDescription,
|
||||
tags: ["rest"],
|
||||
tags: [values.protocol],
|
||||
examples: [],
|
||||
},
|
||||
};
|
||||
|
||||
switch (values.protocol) {
|
||||
case "rest":
|
||||
return {
|
||||
...basePayload,
|
||||
protocol: "rest",
|
||||
target: {
|
||||
kind: "rest",
|
||||
base_url: values.restBaseUrl,
|
||||
method: values.restMethod,
|
||||
path_template: values.restPathTemplate,
|
||||
static_headers: {},
|
||||
},
|
||||
};
|
||||
case "graphql":
|
||||
return {
|
||||
...basePayload,
|
||||
protocol: "graphql",
|
||||
target: {
|
||||
kind: "graphql",
|
||||
endpoint: values.graphqlEndpoint,
|
||||
operation_type: values.graphqlOperationType,
|
||||
operation_name: values.graphqlOperationName,
|
||||
query_template: values.graphqlQueryTemplate,
|
||||
response_path: values.graphqlResponsePath,
|
||||
},
|
||||
};
|
||||
case "grpc":
|
||||
return {
|
||||
...basePayload,
|
||||
protocol: "grpc",
|
||||
target: {
|
||||
kind: "grpc",
|
||||
server_addr: values.grpcServerAddr,
|
||||
package: values.grpcPackage,
|
||||
service: values.grpcService,
|
||||
method: values.grpcMethod,
|
||||
descriptor_ref: values.grpcDescriptorRef,
|
||||
descriptor_set_b64: values.grpcDescriptorSetB64,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,155 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useMutation } from "@tanstack/react-query";
|
||||
import { useMemo } from "react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import { z } from "zod";
|
||||
|
||||
import { createOperation } from "../../entities/operation/api";
|
||||
import { ApiError } from "../../shared/api/client";
|
||||
import {
|
||||
buildOperationPayload,
|
||||
defaultOperationFormValues,
|
||||
getProtocolPreset,
|
||||
operationFormSchema,
|
||||
type OperationFormValues,
|
||||
} from "./model";
|
||||
|
||||
const formResolverSchema = z.object({
|
||||
name: z.string().min(3),
|
||||
displayName: z.string().min(3),
|
||||
baseUrl: z.string().url(),
|
||||
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||
pathTemplate: z.string().min(1),
|
||||
toolTitle: z.string().min(3),
|
||||
toolDescription: z.string().min(8),
|
||||
inputSchemaText: z.string().min(2),
|
||||
outputSchemaText: z.string().min(2),
|
||||
inputMappingText: z.string().min(2),
|
||||
outputMappingText: z.string().min(2),
|
||||
executionConfigText: z.string().min(2),
|
||||
});
|
||||
type OperationFieldName = keyof OperationFormValues;
|
||||
|
||||
type FieldConfig = {
|
||||
name: OperationFieldName;
|
||||
label: string;
|
||||
description?: string;
|
||||
wide?: boolean;
|
||||
rows?: number;
|
||||
};
|
||||
|
||||
const commonFields: FieldConfig[] = [
|
||||
{ name: "name", label: "Tool name" },
|
||||
{ name: "displayName", label: "Display name" },
|
||||
{ name: "toolTitle", label: "Tool title" },
|
||||
{
|
||||
name: "toolDescription",
|
||||
label: "Tool description",
|
||||
description: "LLM-facing description for the MCP tool.",
|
||||
rows: 4,
|
||||
wide: true,
|
||||
},
|
||||
];
|
||||
|
||||
const restFields: FieldConfig[] = [
|
||||
{ name: "restBaseUrl", label: "Base URL" },
|
||||
{ name: "restMethod", label: "HTTP method" },
|
||||
{ name: "restPathTemplate", label: "Path template" },
|
||||
];
|
||||
|
||||
const graphqlFields: FieldConfig[] = [
|
||||
{ name: "graphqlEndpoint", label: "GraphQL endpoint" },
|
||||
{ name: "graphqlOperationType", label: "Operation type" },
|
||||
{ name: "graphqlOperationName", label: "Operation name" },
|
||||
{
|
||||
name: "graphqlQueryTemplate",
|
||||
label: "Query template",
|
||||
description: "A fixed query or mutation document exposed as a single MCP tool.",
|
||||
rows: 10,
|
||||
wide: true,
|
||||
},
|
||||
{
|
||||
name: "graphqlResponsePath",
|
||||
label: "Response path",
|
||||
description: "JSONPath-like extraction root inside response preview.",
|
||||
wide: true,
|
||||
},
|
||||
];
|
||||
|
||||
const grpcFields: FieldConfig[] = [
|
||||
{ name: "grpcServerAddr", label: "Server address" },
|
||||
{ name: "grpcPackage", label: "Package" },
|
||||
{ name: "grpcService", label: "Service" },
|
||||
{ name: "grpcMethod", label: "Method" },
|
||||
{
|
||||
name: "grpcDescriptorRef",
|
||||
label: "Descriptor reference",
|
||||
description: "Stable descriptor identifier stored together with the operation.",
|
||||
},
|
||||
{
|
||||
name: "grpcDescriptorSetB64",
|
||||
label: "Descriptor set base64",
|
||||
description: "Use a descriptor-set file to prefill this field before creation.",
|
||||
rows: 8,
|
||||
wide: true,
|
||||
},
|
||||
];
|
||||
|
||||
const schemaFields: FieldConfig[] = [
|
||||
{ name: "inputSchemaText", label: "Input schema", rows: 12, wide: true },
|
||||
{ name: "outputSchemaText", label: "Output schema", rows: 12, wide: true },
|
||||
];
|
||||
|
||||
const mappingFields: FieldConfig[] = [
|
||||
{ name: "inputMappingText", label: "Input mapping", rows: 12, wide: true },
|
||||
{ name: "outputMappingText", label: "Output mapping", rows: 12, wide: true },
|
||||
{
|
||||
name: "executionConfigText",
|
||||
label: "Execution config",
|
||||
rows: 10,
|
||||
wide: true,
|
||||
},
|
||||
];
|
||||
|
||||
function protocolSummary(protocol: OperationFormValues["protocol"]) {
|
||||
switch (protocol) {
|
||||
case "rest":
|
||||
return "One MCP tool maps to one HTTP method and one path template.";
|
||||
case "graphql":
|
||||
return "One MCP tool maps to one fixed GraphQL query or mutation with stable variables and response shape.";
|
||||
case "grpc":
|
||||
return "One MCP tool maps to one unary gRPC method backed by a descriptor set embedded at creation time.";
|
||||
}
|
||||
}
|
||||
|
||||
function descriptorRefFromFileName(fileName: string) {
|
||||
const normalized = fileName
|
||||
.replace(/\.[^/.]+$/, "")
|
||||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.toLowerCase();
|
||||
|
||||
return normalized.length === 0 ? "desc_uploaded" : `desc_${normalized}`;
|
||||
}
|
||||
|
||||
function bytesToBase64(buffer: ArrayBuffer) {
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = "";
|
||||
|
||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
||||
}
|
||||
|
||||
return window.btoa(binary);
|
||||
}
|
||||
|
||||
export function OperationForm() {
|
||||
const navigate = useNavigate();
|
||||
const form = useForm<OperationFormValues>({
|
||||
defaultValues: defaultOperationFormValues,
|
||||
resolver: zodResolver(formResolverSchema),
|
||||
resolver: zodResolver(operationFormSchema),
|
||||
});
|
||||
const activeProtocol = form.watch("protocol");
|
||||
const previousProtocolRef = useRef(activeProtocol);
|
||||
|
||||
useEffect(() => {
|
||||
if (previousProtocolRef.current === activeProtocol) {
|
||||
return;
|
||||
}
|
||||
|
||||
const preset = getProtocolPreset(activeProtocol);
|
||||
for (const [fieldName, value] of Object.entries(preset)) {
|
||||
form.setValue(fieldName as OperationFieldName, value);
|
||||
}
|
||||
|
||||
previousProtocolRef.current = activeProtocol;
|
||||
}, [activeProtocol, form]);
|
||||
|
||||
const creationMutation = useMutation({
|
||||
mutationFn: async (values: OperationFormValues) =>
|
||||
@@ -43,17 +159,99 @@ export function OperationForm() {
|
||||
},
|
||||
});
|
||||
|
||||
type OperationFieldName = keyof OperationFormValues;
|
||||
const protocolFields = useMemo(() => {
|
||||
switch (activeProtocol) {
|
||||
case "rest":
|
||||
return restFields;
|
||||
case "graphql":
|
||||
return graphqlFields;
|
||||
case "grpc":
|
||||
return grpcFields;
|
||||
}
|
||||
}, [activeProtocol]);
|
||||
|
||||
const fieldGroups = useMemo<OperationFieldName[][]>(
|
||||
() => [
|
||||
["name", "displayName", "baseUrl", "method", "pathTemplate"],
|
||||
["toolTitle", "toolDescription"],
|
||||
["inputSchemaText", "outputSchemaText"],
|
||||
["inputMappingText", "outputMappingText", "executionConfigText"],
|
||||
],
|
||||
[],
|
||||
);
|
||||
async function handleDescriptorFileChange(file: File | undefined) {
|
||||
if (file === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const encoded = bytesToBase64(await file.arrayBuffer());
|
||||
form.setValue("grpcDescriptorSetB64", encoded, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
if (form.getValues("grpcDescriptorRef").trim().length === 0) {
|
||||
form.setValue("grpcDescriptorRef", descriptorRefFromFileName(file.name), {
|
||||
shouldDirty: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderField(field: FieldConfig) {
|
||||
const fieldError = form.formState.errors[field.name];
|
||||
const message =
|
||||
typeof fieldError?.message === "string" ? fieldError.message : undefined;
|
||||
|
||||
if (
|
||||
field.name === "restMethod" ||
|
||||
field.name === "graphqlOperationType" ||
|
||||
field.name === "protocol"
|
||||
) {
|
||||
return (
|
||||
<label
|
||||
className={field.wide ? "field-block field-block-wide" : "field-block"}
|
||||
key={field.name}
|
||||
>
|
||||
<span>{field.label}</span>
|
||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||
<select {...form.register(field.name)}>
|
||||
{field.name === "protocol" ? (
|
||||
<>
|
||||
<option value="rest">REST</option>
|
||||
<option value="graphql">GraphQL</option>
|
||||
<option value="grpc">gRPC</option>
|
||||
</>
|
||||
) : null}
|
||||
{field.name === "restMethod" ? (
|
||||
<>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</>
|
||||
) : null}
|
||||
{field.name === "graphqlOperationType" ? (
|
||||
<>
|
||||
<option value="query">query</option>
|
||||
<option value="mutation">mutation</option>
|
||||
</>
|
||||
) : null}
|
||||
</select>
|
||||
{message ? <small className="field-error">{message}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
const isTextarea = field.rows !== undefined;
|
||||
|
||||
return (
|
||||
<label
|
||||
className={field.wide ? "field-block field-block-wide" : "field-block"}
|
||||
key={field.name}
|
||||
>
|
||||
<span>{field.label}</span>
|
||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||
{isTextarea ? (
|
||||
<textarea rows={field.rows} {...form.register(field.name)} />
|
||||
) : (
|
||||
<input type="text" {...form.register(field.name)} />
|
||||
)}
|
||||
{message ? <small className="field-error">{message}</small> : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
@@ -62,60 +260,51 @@ export function OperationForm() {
|
||||
creationMutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
{fieldGroups.map((fieldGroup, index) => (
|
||||
<div className="form-grid" key={index}>
|
||||
{fieldGroup.map((fieldName) => {
|
||||
const fieldError = form.formState.errors[fieldName];
|
||||
const isTextarea = fieldName.endsWith("Text") || fieldName === "toolDescription";
|
||||
const label = fieldName
|
||||
.replace(/([A-Z])/g, " $1")
|
||||
.replace(/^./, (character) => character.toUpperCase());
|
||||
|
||||
if (fieldName === "method") {
|
||||
return (
|
||||
<label className="field-block" key={fieldName}>
|
||||
<span>{label}</span>
|
||||
<select {...form.register("method")}>
|
||||
<option value="GET">GET</option>
|
||||
<option value="POST">POST</option>
|
||||
<option value="PUT">PUT</option>
|
||||
<option value="PATCH">PATCH</option>
|
||||
<option value="DELETE">DELETE</option>
|
||||
</select>
|
||||
{fieldError ? (
|
||||
<small className="field-error">{fieldError.message}</small>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
if (isTextarea) {
|
||||
return (
|
||||
<label className="field-block field-block-wide" key={fieldName}>
|
||||
<span>{label}</span>
|
||||
<textarea
|
||||
rows={fieldName === "toolDescription" ? 4 : 12}
|
||||
{...form.register(fieldName)}
|
||||
/>
|
||||
{fieldError ? (
|
||||
<small className="field-error">{fieldError.message}</small>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<label className="field-block" key={fieldName}>
|
||||
<span>{label}</span>
|
||||
<input type="text" {...form.register(fieldName)} />
|
||||
{fieldError ? (
|
||||
<small className="field-error">{fieldError.message}</small>
|
||||
) : null}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
<div className="protocol-selector-card">
|
||||
<div className="form-grid">
|
||||
{renderField({ name: "protocol", label: "Protocol" })}
|
||||
<div className="protocol-brief">
|
||||
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
|
||||
<p>{protocolSummary(activeProtocol)}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="form-grid">{commonFields.map(renderField)}</div>
|
||||
|
||||
<div className="page-subsection">
|
||||
<div className="page-subsection-header">
|
||||
<h3>Target</h3>
|
||||
<p>Protocol-specific transport configuration exposed through one MCP tool.</p>
|
||||
</div>
|
||||
<div className="form-grid">
|
||||
{protocolFields.map(renderField)}
|
||||
{activeProtocol === "grpc" ? (
|
||||
<label className="field-block field-block-wide">
|
||||
<span>Descriptor set file</span>
|
||||
<small className="field-hint">
|
||||
Upload a compiled descriptor-set file to autofill the base64 payload.
|
||||
</small>
|
||||
<input
|
||||
type="file"
|
||||
accept=".bin,.pb,.desc"
|
||||
onChange={(event) => {
|
||||
void handleDescriptorFileChange(event.target.files?.[0]);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="page-subsection">
|
||||
<div className="page-subsection-header">
|
||||
<h3>Contract</h3>
|
||||
<p>Schemas and mapping stay JSON-based regardless of the upstream protocol.</p>
|
||||
</div>
|
||||
<div className="form-grid">{schemaFields.map(renderField)}</div>
|
||||
<div className="form-grid">{mappingFields.map(renderField)}</div>
|
||||
</div>
|
||||
|
||||
{creationMutation.error ? (
|
||||
<div className="feedback-card feedback-error">
|
||||
|
||||
@@ -6,7 +6,7 @@ export function OperationCreatePage() {
|
||||
<div className="page-stack">
|
||||
<PageSection
|
||||
title="Create Operation"
|
||||
subtitle="REST-first creation flow for the first production UI slice. GraphQL and gRPC will reuse the same operator surface later."
|
||||
subtitle="Create one MCP tool from one fixed REST, GraphQL or unary gRPC contract."
|
||||
>
|
||||
<OperationForm />
|
||||
</PageSection>
|
||||
|
||||
@@ -6,15 +6,51 @@ import {
|
||||
generateDraft,
|
||||
getOperation,
|
||||
getOperationVersion,
|
||||
listAuthProfiles,
|
||||
importOperationYaml,
|
||||
listAuthProfiles,
|
||||
} from "../../entities/operation/api";
|
||||
import type { OperationTarget } from "../../entities/operation/types";
|
||||
import { GrpcDescriptorPanel } from "../../features/grpc-descriptor/panel";
|
||||
import { PublishOperationPanel } from "../../features/publish-operation/panel";
|
||||
import { SampleUploadPanel } from "../../features/sample-upload/panel";
|
||||
import { SchemaViewerPanel } from "../../features/schema-viewer/panel";
|
||||
import { TestRunPanel } from "../../features/test-run/panel";
|
||||
import { PageSection } from "../../shared/ui/page-section";
|
||||
|
||||
function describeTarget(target: OperationTarget) {
|
||||
switch (target.kind) {
|
||||
case "rest":
|
||||
return [
|
||||
["Base URL", target.base_url],
|
||||
["Method", target.method],
|
||||
["Path", target.path_template],
|
||||
] as const;
|
||||
case "graphql":
|
||||
return [
|
||||
["Endpoint", target.endpoint],
|
||||
["Operation", `${target.operation_type} ${target.operation_name}`],
|
||||
["Response path", target.response_path],
|
||||
] as const;
|
||||
case "grpc":
|
||||
return [
|
||||
["Server", target.server_addr],
|
||||
["Service", `${target.package}.${target.service}/${target.method}`],
|
||||
["Descriptor ref", target.descriptor_ref],
|
||||
] as const;
|
||||
}
|
||||
}
|
||||
|
||||
function draftSubtitle(target: OperationTarget) {
|
||||
switch (target.kind) {
|
||||
case "rest":
|
||||
return "Upload request and response JSON samples, then infer schema and mapping drafts.";
|
||||
case "graphql":
|
||||
return "Upload stable request and response samples for the fixed GraphQL operation contract.";
|
||||
case "grpc":
|
||||
return "Upload JSON samples or inspect descriptor-driven schemas before refining mappings.";
|
||||
}
|
||||
}
|
||||
|
||||
export function OperationDetailPage() {
|
||||
const { operationId = "" } = useParams();
|
||||
const summaryQuery = useQuery({
|
||||
@@ -22,18 +58,11 @@ export function OperationDetailPage() {
|
||||
queryFn: () => getOperation(operationId),
|
||||
enabled: operationId.length > 0,
|
||||
});
|
||||
const currentVersion = summaryQuery.data?.current_draft_version ?? 0;
|
||||
const versionQuery = useQuery({
|
||||
queryKey: [
|
||||
"operation-version",
|
||||
operationId,
|
||||
summaryQuery.data?.current_draft_version,
|
||||
],
|
||||
queryFn: () =>
|
||||
getOperationVersion(operationId, summaryQuery.data!.current_draft_version),
|
||||
enabled:
|
||||
operationId.length > 0 &&
|
||||
summaryQuery.data !== undefined &&
|
||||
summaryQuery.data.current_draft_version > 0,
|
||||
queryKey: ["operation-version", operationId, currentVersion],
|
||||
queryFn: () => getOperationVersion(operationId, currentVersion),
|
||||
enabled: operationId.length > 0 && currentVersion > 0,
|
||||
});
|
||||
const authProfilesQuery = useQuery({
|
||||
queryKey: ["auth-profiles"],
|
||||
@@ -43,15 +72,15 @@ export function OperationDetailPage() {
|
||||
mutationFn: async () => generateDraft(operationId),
|
||||
});
|
||||
const exportMutation = useMutation({
|
||||
mutationFn: async () =>
|
||||
exportOperationYaml(operationId, summaryQuery.data?.current_draft_version),
|
||||
mutationFn: async () => exportOperationYaml(operationId, currentVersion),
|
||||
});
|
||||
const importMutation = useMutation({
|
||||
mutationFn: async (yamlText: string) => importOperationYaml(yamlText, "upsert"),
|
||||
});
|
||||
|
||||
const currentVersion = summaryQuery.data?.current_draft_version ?? 0;
|
||||
const snapshot = versionQuery.data?.snapshot;
|
||||
const exportedYaml = exportMutation.data;
|
||||
const targetRows = snapshot ? describeTarget(snapshot.target) : [];
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
@@ -64,26 +93,76 @@ export function OperationDetailPage() {
|
||||
}
|
||||
>
|
||||
{summaryQuery.data ? (
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<span>Status</span>
|
||||
<strong>{summaryQuery.data.status}</strong>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span>Published version</span>
|
||||
<strong>{summaryQuery.data.latest_published_version ?? "none"}</strong>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span>Updated</span>
|
||||
<strong>{new Date(summaryQuery.data.updated_at).toLocaleString()}</strong>
|
||||
<div className="detail-hero">
|
||||
<div className="summary-grid">
|
||||
<div className="summary-card">
|
||||
<span>Status</span>
|
||||
<strong>{summaryQuery.data.status}</strong>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span>Published version</span>
|
||||
<strong>{summaryQuery.data.latest_published_version ?? "none"}</strong>
|
||||
</div>
|
||||
<div className="summary-card">
|
||||
<span>Updated</span>
|
||||
<strong>{new Date(summaryQuery.data.updated_at).toLocaleString()}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{snapshot ? (
|
||||
<div className="target-grid">
|
||||
{targetRows.map(([label, value]) => (
|
||||
<div className="target-card" key={label}>
|
||||
<span>{label}</span>
|
||||
<strong>{value}</strong>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</PageSection>
|
||||
|
||||
{snapshot ? (
|
||||
<PageSection
|
||||
title="Target Contract"
|
||||
subtitle="The runtime transport details below are the fixed contract exposed as one MCP tool."
|
||||
>
|
||||
<div className="metadata-grid">
|
||||
<div className="result-panel">
|
||||
<h3>Tool description</h3>
|
||||
<dl className="key-value-list">
|
||||
<div>
|
||||
<dt>Title</dt>
|
||||
<dd>{snapshot.tool_description.title}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Tags</dt>
|
||||
<dd>{snapshot.tool_description.tags.join(", ") || "none"}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<p className="body-copy">{snapshot.tool_description.description}</p>
|
||||
</div>
|
||||
<div className="result-panel">
|
||||
<h3>Execution config</h3>
|
||||
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
|
||||
</div>
|
||||
</div>
|
||||
</PageSection>
|
||||
) : null}
|
||||
|
||||
{snapshot?.target.kind === "grpc" ? (
|
||||
<PageSection
|
||||
title="gRPC Discovery"
|
||||
subtitle="Descriptor artifacts and discovered unary methods for the current draft version."
|
||||
>
|
||||
<GrpcDescriptorPanel operationId={operationId} version={currentVersion} />
|
||||
</PageSection>
|
||||
) : null}
|
||||
|
||||
<PageSection
|
||||
title="Samples And Draft"
|
||||
subtitle="Upload input/output samples first, then generate a draft schema and mapping set."
|
||||
subtitle={snapshot ? draftSubtitle(snapshot.target) : "Prepare schema and mapping drafts."}
|
||||
actions={
|
||||
<button
|
||||
className="button-primary"
|
||||
@@ -101,76 +180,82 @@ export function OperationDetailPage() {
|
||||
/>
|
||||
</PageSection>
|
||||
|
||||
<PageSection
|
||||
title="Test Run"
|
||||
subtitle="Run the current draft version through the backend runtime before publishing."
|
||||
>
|
||||
<TestRunPanel operationId={operationId} version={currentVersion} />
|
||||
</PageSection>
|
||||
<div className="detail-columns">
|
||||
<PageSection
|
||||
title="Test Run"
|
||||
subtitle="Run the current draft version through the runtime before publishing."
|
||||
>
|
||||
<TestRunPanel operationId={operationId} version={currentVersion} />
|
||||
</PageSection>
|
||||
|
||||
<PageSection
|
||||
title="Publish"
|
||||
subtitle="Move the current draft version into the published tool set exposed by MCP."
|
||||
>
|
||||
<PublishOperationPanel operationId={operationId} version={currentVersion} />
|
||||
</PageSection>
|
||||
<PageSection
|
||||
title="Publish"
|
||||
subtitle="Promote the current draft version into the active MCP tool catalog."
|
||||
>
|
||||
<PublishOperationPanel operationId={operationId} version={currentVersion} />
|
||||
</PageSection>
|
||||
</div>
|
||||
|
||||
<PageSection
|
||||
title="YAML Export And Reimport"
|
||||
subtitle="Export the current version, inspect the YAML, then upsert it back through the same backend contract."
|
||||
actions={
|
||||
<button
|
||||
className="button-secondary"
|
||||
type="button"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
>
|
||||
{exportMutation.isPending ? "Exporting..." : "Export YAML"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="stack-layout">
|
||||
{exportedYaml ? (
|
||||
<>
|
||||
<pre className="yaml-panel">{exportedYaml}</pre>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="button-primary"
|
||||
type="button"
|
||||
onClick={() => importMutation.mutate(exportedYaml)}
|
||||
>
|
||||
{importMutation.isPending ? "Importing..." : "Upsert from YAML"}
|
||||
</button>
|
||||
<div className="detail-columns">
|
||||
<PageSection
|
||||
title="YAML Export And Reimport"
|
||||
subtitle="Export the current version, inspect the YAML, then upsert it through the same backend contract."
|
||||
actions={
|
||||
<button
|
||||
className="button-secondary"
|
||||
type="button"
|
||||
onClick={() => exportMutation.mutate()}
|
||||
>
|
||||
{exportMutation.isPending ? "Exporting..." : "Export YAML"}
|
||||
</button>
|
||||
}
|
||||
>
|
||||
<div className="stack-layout">
|
||||
{exportedYaml ? (
|
||||
<>
|
||||
<pre className="yaml-panel">{exportedYaml}</pre>
|
||||
<div className="button-row">
|
||||
<button
|
||||
className="button-primary"
|
||||
type="button"
|
||||
onClick={() => importMutation.mutate(exportedYaml)}
|
||||
>
|
||||
{importMutation.isPending ? "Importing..." : "Upsert from YAML"}
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p className="body-copy">
|
||||
Export YAML to inspect the current operation snapshot and transport contract.
|
||||
</p>
|
||||
)}
|
||||
{importMutation.data ? (
|
||||
<div className="feedback-card feedback-success">
|
||||
Imported version {String(importMutation.data.version)} in{" "}
|
||||
{String(importMutation.data.import_mode)} mode.
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<p>Export YAML to inspect the current operation snapshot.</p>
|
||||
)}
|
||||
{importMutation.data ? (
|
||||
<div className="feedback-card feedback-success">
|
||||
Imported version {String(importMutation.data.version)} in{" "}
|
||||
{String(importMutation.data.import_mode)} mode.
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</PageSection>
|
||||
) : null}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection
|
||||
title="Auth Profiles"
|
||||
subtitle="Currently available auth profiles exposed by the admin backend."
|
||||
>
|
||||
<div className="operation-grid">
|
||||
{(authProfilesQuery.data?.items ?? []).map((profile) => (
|
||||
<div className="operation-card" key={profile.id}>
|
||||
<div className="operation-card-top">
|
||||
<span className="protocol-pill protocol-rest">{profile.kind}</span>
|
||||
<PageSection
|
||||
title="Auth Profiles"
|
||||
subtitle="Profiles currently available to the admin backend and execution config."
|
||||
>
|
||||
<div className="operation-grid">
|
||||
{(authProfilesQuery.data?.items ?? []).map((profile) => (
|
||||
<div className="operation-card" key={profile.id}>
|
||||
<div className="operation-card-top">
|
||||
<span className="status-pill status-testing">{profile.kind}</span>
|
||||
</div>
|
||||
<h3>{profile.name}</h3>
|
||||
<p className="operation-name">{profile.id}</p>
|
||||
<pre>{JSON.stringify(profile.config, null, 2)}</pre>
|
||||
</div>
|
||||
<h3>{profile.name}</h3>
|
||||
<p className="operation-name">{profile.id}</p>
|
||||
<pre>{JSON.stringify(profile.config, null, 2)}</pre>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -77,3 +77,15 @@ export function postText<TResponse>(input: string, text: string) {
|
||||
body: text,
|
||||
});
|
||||
}
|
||||
|
||||
export function postBytes<TResponse>(
|
||||
input: string,
|
||||
bytes: ArrayBuffer,
|
||||
headers?: Record<string, string>,
|
||||
) {
|
||||
return request<TResponse>(input, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: bytes,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,10 +11,10 @@ export function AppShell({ children }: AppShellProps) {
|
||||
<aside className="layout-sidebar">
|
||||
<div className="brand-block">
|
||||
<p className="eyebrow">RMCP Console</p>
|
||||
<h1>Operator Surface</h1>
|
||||
<h1>Tool Console</h1>
|
||||
<p className="brand-copy">
|
||||
REST-first operator console for MCP tool onboarding, sample-based
|
||||
draft generation and publish flow.
|
||||
Low-code operator surface for REST, GraphQL and unary gRPC tool
|
||||
onboarding, draft generation and publish flow.
|
||||
</p>
|
||||
</div>
|
||||
<nav className="sidebar-nav">
|
||||
|
||||
+124
-1
@@ -171,6 +171,11 @@ button {
|
||||
color: #52453e;
|
||||
}
|
||||
|
||||
.field-hint {
|
||||
color: #796b63;
|
||||
font-size: 0.84rem;
|
||||
}
|
||||
|
||||
.field-block input,
|
||||
.field-block textarea,
|
||||
.field-block select {
|
||||
@@ -201,6 +206,11 @@ button {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.body-copy {
|
||||
margin: 0;
|
||||
color: #5c4f48;
|
||||
}
|
||||
|
||||
.button-primary,
|
||||
.button-secondary {
|
||||
display: inline-flex;
|
||||
@@ -336,6 +346,91 @@ button {
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.detail-hero {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.detail-columns {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.target-grid,
|
||||
.metadata-grid,
|
||||
.upload-grid,
|
||||
.service-grid {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.target-grid,
|
||||
.metadata-grid,
|
||||
.upload-grid {
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
}
|
||||
|
||||
.target-card {
|
||||
border-radius: 22px;
|
||||
padding: 18px;
|
||||
background: linear-gradient(180deg, rgba(245, 239, 231, 0.95), rgba(255, 251, 246, 0.92));
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.target-card span {
|
||||
color: #75675f;
|
||||
font-size: 0.84rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.target-card strong {
|
||||
font-size: 0.96rem;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.page-subsection {
|
||||
display: grid;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.page-subsection-header {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.page-subsection-header h3,
|
||||
.service-card h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.page-subsection-header p,
|
||||
.service-card p {
|
||||
margin: 0;
|
||||
color: #6b5d55;
|
||||
}
|
||||
|
||||
.protocol-selector-card {
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
background: linear-gradient(135deg, rgba(15, 117, 103, 0.08), rgba(234, 165, 91, 0.12));
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
}
|
||||
|
||||
.protocol-brief {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.protocol-brief p {
|
||||
margin: 0;
|
||||
color: #4e443f;
|
||||
}
|
||||
|
||||
.summary-card {
|
||||
padding: 18px;
|
||||
display: grid;
|
||||
@@ -376,6 +471,33 @@ button {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.service-card {
|
||||
border-radius: 24px;
|
||||
padding: 20px;
|
||||
background: rgba(255, 252, 247, 0.92);
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.service-card-header,
|
||||
.service-method-top {
|
||||
display: flex;
|
||||
align-items: start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.service-method-list {
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.service-method {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.result-panel h3 {
|
||||
margin-top: 0;
|
||||
}
|
||||
@@ -418,7 +540,8 @@ pre {
|
||||
|
||||
.form-grid,
|
||||
.split-layout,
|
||||
.result-grid {
|
||||
.result-grid,
|
||||
.detail-columns {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user