Merge branch 'feat/ui-concept-alignment'
This commit is contained in:
@@ -2,17 +2,17 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/ui-ux-pass`
|
### `feat/ui-concept-alignment`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
|
|
||||||
- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций
|
- shell и create flow визуально опираются на утвержденный concept
|
||||||
- operation list показывает обзор состояния системы, а не только список карточек
|
- sidebar, topbar и page header выглядят как единая продуктовая система
|
||||||
- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC`
|
- create page больше не выглядит как длинная сырая форма, а как step-based contract builder
|
||||||
- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию
|
- кодовые JSON и mapping поля имеют отдельную визуальную подачу
|
||||||
- UI build и tests остаются зелеными после UX-pass
|
- UI build и tests остаются зелеными после выравнивания под concept
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
|
|||||||
@@ -4,11 +4,33 @@ import { buildOperationPayload, defaultOperationFormValues } from "./model";
|
|||||||
|
|
||||||
describe("buildOperationPayload", () => {
|
describe("buildOperationPayload", () => {
|
||||||
it("creates a REST operation payload from form values", () => {
|
it("creates a REST operation payload from form values", () => {
|
||||||
const payload = buildOperationPayload(defaultOperationFormValues);
|
const payload = buildOperationPayload({
|
||||||
|
...defaultOperationFormValues,
|
||||||
|
restStaticHeadersText: JSON.stringify(
|
||||||
|
{
|
||||||
|
"x-app-source": "rmcp",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
executionHeadersText: JSON.stringify(
|
||||||
|
{
|
||||||
|
"x-trace-id": "trace-123",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
expect(payload.protocol).toBe("rest");
|
expect(payload.protocol).toBe("rest");
|
||||||
expect(payload.target.kind).toBe("rest");
|
expect(payload.target.kind).toBe("rest");
|
||||||
expect(payload.target.method).toBe("POST");
|
expect(payload.target.method).toBe("POST");
|
||||||
|
expect(payload.target.static_headers).toEqual({
|
||||||
|
"x-app-source": "rmcp",
|
||||||
|
});
|
||||||
|
expect(payload.execution_config.headers).toEqual({
|
||||||
|
"x-trace-id": "trace-123",
|
||||||
|
});
|
||||||
expect(payload.input_mapping).toEqual({
|
expect(payload.input_mapping).toEqual({
|
||||||
rules: [
|
rules: [
|
||||||
{
|
{
|
||||||
@@ -111,4 +133,13 @@ describe("buildOperationPayload", () => {
|
|||||||
}),
|
}),
|
||||||
).toThrow(/Input schema contains invalid JSON/);
|
).toThrow(/Input schema contains invalid JSON/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("throws when header values are not string maps", () => {
|
||||||
|
expect(() =>
|
||||||
|
buildOperationPayload({
|
||||||
|
...defaultOperationFormValues,
|
||||||
|
executionHeadersText: JSON.stringify({ "x-trace-id": 42 }, null, 2),
|
||||||
|
}),
|
||||||
|
).toThrow(/Execution headers.x-trace-id must be a string value/);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const operationFormSchema = z
|
|||||||
restBaseUrl: z.string(),
|
restBaseUrl: z.string(),
|
||||||
restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
||||||
restPathTemplate: z.string(),
|
restPathTemplate: z.string(),
|
||||||
|
restStaticHeadersText: jsonTextSchema,
|
||||||
graphqlEndpoint: z.string(),
|
graphqlEndpoint: z.string(),
|
||||||
graphqlOperationType: z.enum(["query", "mutation"]),
|
graphqlOperationType: z.enum(["query", "mutation"]),
|
||||||
graphqlOperationName: z.string(),
|
graphqlOperationName: z.string(),
|
||||||
@@ -32,6 +33,7 @@ export const operationFormSchema = z
|
|||||||
outputSchemaText: jsonTextSchema,
|
outputSchemaText: jsonTextSchema,
|
||||||
inputMappingText: jsonTextSchema,
|
inputMappingText: jsonTextSchema,
|
||||||
outputMappingText: jsonTextSchema,
|
outputMappingText: jsonTextSchema,
|
||||||
|
executionHeadersText: jsonTextSchema,
|
||||||
executionConfigText: jsonTextSchema,
|
executionConfigText: jsonTextSchema,
|
||||||
})
|
})
|
||||||
.superRefine((values, context) => {
|
.superRefine((values, context) => {
|
||||||
@@ -111,6 +113,22 @@ export const operationFormSchema = z
|
|||||||
|
|
||||||
export type OperationFormValues = z.infer<typeof operationFormSchema>;
|
export type OperationFormValues = z.infer<typeof operationFormSchema>;
|
||||||
|
|
||||||
|
function parseStringMap(raw: string, fieldName: string) {
|
||||||
|
const value = safeParseJson<Record<string, unknown>>(raw, fieldName);
|
||||||
|
|
||||||
|
if (value === null || Array.isArray(value) || typeof value !== "object") {
|
||||||
|
throw new Error(`${fieldName} must be a JSON object`);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [key, entry] of Object.entries(value)) {
|
||||||
|
if (typeof entry !== "string") {
|
||||||
|
throw new Error(`${fieldName}.${key} must be a string value`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
function defaultInputSchemaText() {
|
function defaultInputSchemaText() {
|
||||||
return JSON.stringify(
|
return JSON.stringify(
|
||||||
{
|
{
|
||||||
@@ -265,6 +283,7 @@ export const defaultOperationFormValues: OperationFormValues = {
|
|||||||
restBaseUrl: "https://api.example.com",
|
restBaseUrl: "https://api.example.com",
|
||||||
restMethod: "POST",
|
restMethod: "POST",
|
||||||
restPathTemplate: "/v1/leads",
|
restPathTemplate: "/v1/leads",
|
||||||
|
restStaticHeadersText: JSON.stringify({}, null, 2),
|
||||||
graphqlEndpoint: "https://api.example.com/graphql",
|
graphqlEndpoint: "https://api.example.com/graphql",
|
||||||
graphqlOperationType: "mutation",
|
graphqlOperationType: "mutation",
|
||||||
graphqlOperationName: "CreateLead",
|
graphqlOperationName: "CreateLead",
|
||||||
@@ -285,6 +304,7 @@ export const defaultOperationFormValues: OperationFormValues = {
|
|||||||
outputSchemaText: defaultOutputSchemaText(),
|
outputSchemaText: defaultOutputSchemaText(),
|
||||||
inputMappingText: defaultRestInputMappingText(),
|
inputMappingText: defaultRestInputMappingText(),
|
||||||
outputMappingText: defaultRestOutputMappingText(),
|
outputMappingText: defaultRestOutputMappingText(),
|
||||||
|
executionHeadersText: JSON.stringify({}, null, 2),
|
||||||
executionConfigText: defaultExecutionConfigText(),
|
executionConfigText: defaultExecutionConfigText(),
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -300,6 +320,8 @@ export function getProtocolPreset(
|
|||||||
toolDescription: "Creates a CRM lead from MCP input fields.",
|
toolDescription: "Creates a CRM lead from MCP input fields.",
|
||||||
inputMappingText: defaultRestInputMappingText(),
|
inputMappingText: defaultRestInputMappingText(),
|
||||||
outputMappingText: defaultRestOutputMappingText(),
|
outputMappingText: defaultRestOutputMappingText(),
|
||||||
|
restStaticHeadersText: JSON.stringify({}, null, 2),
|
||||||
|
executionHeadersText: JSON.stringify({}, null, 2),
|
||||||
};
|
};
|
||||||
case "graphql":
|
case "graphql":
|
||||||
return {
|
return {
|
||||||
@@ -310,6 +332,7 @@ export function getProtocolPreset(
|
|||||||
"Executes a fixed GraphQL mutation and returns the selected lead payload.",
|
"Executes a fixed GraphQL mutation and returns the selected lead payload.",
|
||||||
inputMappingText: defaultGraphqlInputMappingText(),
|
inputMappingText: defaultGraphqlInputMappingText(),
|
||||||
outputMappingText: defaultGraphqlOutputMappingText(),
|
outputMappingText: defaultGraphqlOutputMappingText(),
|
||||||
|
executionHeadersText: JSON.stringify({}, null, 2),
|
||||||
};
|
};
|
||||||
case "grpc":
|
case "grpc":
|
||||||
return {
|
return {
|
||||||
@@ -320,6 +343,7 @@ export function getProtocolPreset(
|
|||||||
"Executes a unary gRPC method using a descriptor-driven request contract.",
|
"Executes a unary gRPC method using a descriptor-driven request contract.",
|
||||||
inputMappingText: defaultGrpcInputMappingText(),
|
inputMappingText: defaultGrpcInputMappingText(),
|
||||||
outputMappingText: defaultGrpcOutputMappingText(),
|
outputMappingText: defaultGrpcOutputMappingText(),
|
||||||
|
executionHeadersText: JSON.stringify({}, null, 2),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -330,10 +354,18 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
|
|||||||
const outputSchema = safeParseJson(values.outputSchemaText, "Output schema");
|
const outputSchema = safeParseJson(values.outputSchemaText, "Output schema");
|
||||||
const inputMapping = safeParseJson(values.inputMappingText, "Input mapping");
|
const inputMapping = safeParseJson(values.inputMappingText, "Input mapping");
|
||||||
const outputMapping = safeParseJson(values.outputMappingText, "Output mapping");
|
const outputMapping = safeParseJson(values.outputMappingText, "Output mapping");
|
||||||
const executionConfig = safeParseJson(
|
const executionHeaders = parseStringMap(
|
||||||
|
values.executionHeadersText,
|
||||||
|
"Execution headers",
|
||||||
|
);
|
||||||
|
const executionConfig = safeParseJson<Record<string, unknown>>(
|
||||||
values.executionConfigText,
|
values.executionConfigText,
|
||||||
"Execution config",
|
"Execution config",
|
||||||
);
|
);
|
||||||
|
const normalizedExecutionConfig = {
|
||||||
|
...executionConfig,
|
||||||
|
headers: executionHeaders,
|
||||||
|
};
|
||||||
|
|
||||||
const basePayload = {
|
const basePayload = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
@@ -342,7 +374,7 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
|
|||||||
output_schema: outputSchema,
|
output_schema: outputSchema,
|
||||||
input_mapping: inputMapping,
|
input_mapping: inputMapping,
|
||||||
output_mapping: outputMapping,
|
output_mapping: outputMapping,
|
||||||
execution_config: executionConfig,
|
execution_config: normalizedExecutionConfig,
|
||||||
tool_description: {
|
tool_description: {
|
||||||
title: values.toolTitle,
|
title: values.toolTitle,
|
||||||
description: values.toolDescription,
|
description: values.toolDescription,
|
||||||
@@ -361,7 +393,10 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
|
|||||||
base_url: values.restBaseUrl,
|
base_url: values.restBaseUrl,
|
||||||
method: values.restMethod,
|
method: values.restMethod,
|
||||||
path_template: values.restPathTemplate,
|
path_template: values.restPathTemplate,
|
||||||
static_headers: {},
|
static_headers: parseStringMap(
|
||||||
|
values.restStaticHeadersText,
|
||||||
|
"REST static headers",
|
||||||
|
),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
case "graphql":
|
case "graphql":
|
||||||
|
|||||||
@@ -22,89 +22,155 @@ type FieldConfig = {
|
|||||||
description?: string;
|
description?: string;
|
||||||
wide?: boolean;
|
wide?: boolean;
|
||||||
rows?: number;
|
rows?: number;
|
||||||
|
code?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const protocolCards = [
|
||||||
|
{
|
||||||
|
protocol: "rest",
|
||||||
|
title: "REST / HTTP",
|
||||||
|
description:
|
||||||
|
"One HTTP method and one path template, with body, query and header mapping.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "graphql",
|
||||||
|
title: "GraphQL",
|
||||||
|
description:
|
||||||
|
"One fixed query or mutation with stable variables and a typed response shape.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
protocol: "grpc",
|
||||||
|
title: "gRPC (unary)",
|
||||||
|
description:
|
||||||
|
"One unary method backed by a descriptor-set contract and typed request payload.",
|
||||||
|
},
|
||||||
|
] as const satisfies Array<{
|
||||||
|
protocol: OperationFormValues["protocol"];
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}>;
|
||||||
|
|
||||||
const commonFields: FieldConfig[] = [
|
const commonFields: FieldConfig[] = [
|
||||||
{ name: "name", label: "Tool name" },
|
{ name: "name", label: "Tool name" },
|
||||||
{ name: "displayName", label: "Display name" },
|
{ name: "displayName", label: "Display name" },
|
||||||
{ name: "toolTitle", label: "Tool title" },
|
{ name: "toolTitle", label: "Tool title" },
|
||||||
{
|
{
|
||||||
name: "toolDescription",
|
name: "toolDescription",
|
||||||
label: "Tool description",
|
label: "Description",
|
||||||
description: "LLM-facing description for the MCP tool.",
|
description: "LLM-facing description for the tool runtime contract.",
|
||||||
rows: 4,
|
rows: 4,
|
||||||
wide: true,
|
wide: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const restFields: FieldConfig[] = [
|
const restFields: FieldConfig[] = [
|
||||||
{ name: "restBaseUrl", label: "Base URL" },
|
{ name: "restBaseUrl", label: "Base URL", wide: true },
|
||||||
{ name: "restMethod", label: "HTTP method" },
|
|
||||||
{ name: "restPathTemplate", label: "Path template" },
|
{ name: "restPathTemplate", label: "Path template" },
|
||||||
|
{ name: "restMethod", label: "HTTP method" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const graphqlFields: FieldConfig[] = [
|
const graphqlFields: FieldConfig[] = [
|
||||||
{ name: "graphqlEndpoint", label: "GraphQL endpoint" },
|
{ name: "graphqlEndpoint", label: "GraphQL endpoint", wide: true },
|
||||||
{ name: "graphqlOperationType", label: "Operation type" },
|
{ name: "graphqlOperationType", label: "Operation type" },
|
||||||
{ name: "graphqlOperationName", label: "Operation name" },
|
{ name: "graphqlOperationName", label: "Operation name" },
|
||||||
{
|
{
|
||||||
name: "graphqlQueryTemplate",
|
name: "graphqlResponsePath",
|
||||||
label: "Query template",
|
label: "Response path",
|
||||||
description: "A fixed query or mutation document exposed as a single MCP tool.",
|
description: "Stable extraction root inside the response payload.",
|
||||||
rows: 10,
|
|
||||||
wide: true,
|
wide: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "graphqlResponsePath",
|
name: "graphqlQueryTemplate",
|
||||||
label: "Response path",
|
label: "Query template",
|
||||||
description: "JSONPath-like extraction root inside response preview.",
|
description: "Fixed GraphQL document exposed as one MCP tool.",
|
||||||
|
rows: 12,
|
||||||
wide: true,
|
wide: true,
|
||||||
|
code: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const grpcFields: FieldConfig[] = [
|
const grpcFields: FieldConfig[] = [
|
||||||
{ name: "grpcServerAddr", label: "Server address" },
|
{ name: "grpcServerAddr", label: "Server address", wide: true },
|
||||||
{ name: "grpcPackage", label: "Package" },
|
{ name: "grpcPackage", label: "Package" },
|
||||||
{ name: "grpcService", label: "Service" },
|
{ name: "grpcService", label: "Service" },
|
||||||
{ name: "grpcMethod", label: "Method" },
|
{ name: "grpcMethod", label: "Method" },
|
||||||
{
|
{
|
||||||
name: "grpcDescriptorRef",
|
name: "grpcDescriptorRef",
|
||||||
label: "Descriptor reference",
|
label: "Descriptor reference",
|
||||||
description: "Stable descriptor identifier stored together with the operation.",
|
description: "Stable descriptor identifier stored with the operation.",
|
||||||
|
wide: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "grpcDescriptorSetB64",
|
name: "grpcDescriptorSetB64",
|
||||||
label: "Descriptor set base64",
|
label: "Descriptor set base64",
|
||||||
description: "Use a descriptor-set file to prefill this field before creation.",
|
description: "Compiled descriptor-set contents used for runtime invocation.",
|
||||||
rows: 8,
|
rows: 10,
|
||||||
wide: true,
|
wide: true,
|
||||||
|
code: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const schemaFields: FieldConfig[] = [
|
const schemaFields: FieldConfig[] = [
|
||||||
{ name: "inputSchemaText", label: "Input schema", rows: 12, wide: true },
|
{
|
||||||
{ name: "outputSchemaText", label: "Output schema", rows: 12, wide: true },
|
name: "inputSchemaText",
|
||||||
|
label: "Input schema",
|
||||||
|
rows: 14,
|
||||||
|
wide: true,
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outputSchemaText",
|
||||||
|
label: "Output schema",
|
||||||
|
rows: 14,
|
||||||
|
wide: true,
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const mappingFields: FieldConfig[] = [
|
const mappingFields: FieldConfig[] = [
|
||||||
{ name: "inputMappingText", label: "Input mapping", rows: 12, wide: true },
|
{
|
||||||
{ name: "outputMappingText", label: "Output mapping", rows: 12, wide: true },
|
name: "inputMappingText",
|
||||||
|
label: "Input → Request mapping",
|
||||||
|
rows: 12,
|
||||||
|
wide: true,
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "outputMappingText",
|
||||||
|
label: "Response → Output mapping",
|
||||||
|
rows: 12,
|
||||||
|
wide: true,
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const headerFields: FieldConfig[] = [
|
||||||
|
{
|
||||||
|
name: "executionHeadersText",
|
||||||
|
label: "Execution headers",
|
||||||
|
description: "Shared transport headers for REST, GraphQL and gRPC requests.",
|
||||||
|
rows: 8,
|
||||||
|
wide: true,
|
||||||
|
code: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: "executionConfigText",
|
name: "executionConfigText",
|
||||||
label: "Execution config",
|
label: "Execution config",
|
||||||
|
description: "Timeouts, auth profile reference and protocol options.",
|
||||||
rows: 10,
|
rows: 10,
|
||||||
wide: true,
|
wide: true,
|
||||||
|
code: true,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function protocolSummary(protocol: OperationFormValues["protocol"]) {
|
function protocolSummary(protocol: OperationFormValues["protocol"]) {
|
||||||
switch (protocol) {
|
switch (protocol) {
|
||||||
case "rest":
|
case "rest":
|
||||||
return "One MCP tool maps to one HTTP method and one path template.";
|
return "One tool maps to one HTTP method and one path template.";
|
||||||
case "graphql":
|
case "graphql":
|
||||||
return "One MCP tool maps to one fixed GraphQL query or mutation with stable variables and response shape.";
|
return "One tool maps to one fixed GraphQL query or mutation.";
|
||||||
case "grpc":
|
case "grpc":
|
||||||
return "One MCP tool maps to one unary gRPC method backed by a descriptor set embedded at creation time.";
|
return "One tool maps to one unary gRPC method backed by a descriptor set.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -188,53 +254,13 @@ export function OperationForm() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderField(field: FieldConfig) {
|
function fieldError(name: OperationFieldName) {
|
||||||
const fieldError = form.formState.errors[field.name];
|
const error = form.formState.errors[name];
|
||||||
const message =
|
return typeof error?.message === "string" ? error.message : undefined;
|
||||||
typeof fieldError?.message === "string" ? fieldError.message : undefined;
|
}
|
||||||
|
|
||||||
if (
|
function renderSelect(field: FieldConfig) {
|
||||||
field.name === "restMethod" ||
|
const message = fieldError(field.name);
|
||||||
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 (
|
return (
|
||||||
<label
|
<label
|
||||||
@@ -243,11 +269,70 @@ export function OperationForm() {
|
|||||||
>
|
>
|
||||||
<span>{field.label}</span>
|
<span>{field.label}</span>
|
||||||
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||||
{isTextarea ? (
|
<select className="select" {...form.register(field.name)}>
|
||||||
<textarea rows={field.rows} {...form.register(field.name)} />
|
{field.name === "restMethod" ? (
|
||||||
) : (
|
<>
|
||||||
<input type="text" {...form.register(field.name)} />
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(field: FieldConfig) {
|
||||||
|
if (field.name === "restMethod" || field.name === "graphqlOperationType") {
|
||||||
|
return renderSelect(field);
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = fieldError(field.name);
|
||||||
|
const blockClassName = field.wide ? "field-block field-block-wide" : "field-block";
|
||||||
|
|
||||||
|
if (field.rows !== undefined) {
|
||||||
|
return (
|
||||||
|
<label className={blockClassName} key={field.name}>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||||
|
{field.code ? (
|
||||||
|
<div className="code-editor-shell">
|
||||||
|
<div className="code-editor-toolbar">
|
||||||
|
<div className="code-editor-dots">
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
<span />
|
||||||
|
</div>
|
||||||
|
<small>json / contract</small>
|
||||||
|
</div>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-code"
|
||||||
|
rows={field.rows}
|
||||||
|
{...form.register(field.name)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<textarea rows={field.rows} {...form.register(field.name)} />
|
||||||
|
)}
|
||||||
|
{message ? <small className="field-error">{message}</small> : null}
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<label className={blockClassName} key={field.name}>
|
||||||
|
<span>{field.label}</span>
|
||||||
|
{field.description ? <small className="field-hint">{field.description}</small> : null}
|
||||||
|
<input type="text" className="input" {...form.register(field.name)} />
|
||||||
{message ? <small className="field-error">{message}</small> : null}
|
{message ? <small className="field-error">{message}</small> : null}
|
||||||
</label>
|
</label>
|
||||||
);
|
);
|
||||||
@@ -255,56 +340,155 @@ export function OperationForm() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<form
|
<form
|
||||||
className="stack-layout"
|
className="builder-stack"
|
||||||
onSubmit={form.handleSubmit((values) => {
|
onSubmit={form.handleSubmit((values) => {
|
||||||
creationMutation.mutate(values);
|
creationMutation.mutate(values);
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="protocol-selector-card">
|
<section className="builder-card">
|
||||||
<div className="form-grid">
|
<header className="builder-card-header">
|
||||||
{renderField({ name: "protocol", label: "Protocol" })}
|
<div>
|
||||||
<div className="protocol-brief">
|
<h2>1 — Protocol</h2>
|
||||||
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
|
<p>One tool maps to exactly one upstream method.</p>
|
||||||
<p>{protocolSummary(activeProtocol)}</p>
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="builder-card-body">
|
||||||
|
<div className="proto-grid">
|
||||||
|
{protocolCards.map((card) => {
|
||||||
|
const isSelected = activeProtocol === card.protocol;
|
||||||
|
const selectedClassName = isSelected
|
||||||
|
? `proto-card selected-${card.protocol === "graphql" ? "gql" : card.protocol}`
|
||||||
|
: "proto-card";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={selectedClassName}
|
||||||
|
key={card.protocol}
|
||||||
|
onClick={() => {
|
||||||
|
form.setValue("protocol", card.protocol, {
|
||||||
|
shouldDirty: true,
|
||||||
|
shouldValidate: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<div className="proto-card-top">
|
||||||
|
<div className={`proto-icon pi-${card.protocol === "graphql" ? "gql" : card.protocol}`}>
|
||||||
|
{card.protocol === "graphql" ? "GQL" : card.protocol.toUpperCase()}
|
||||||
|
</div>
|
||||||
|
<div className="proto-radio" />
|
||||||
|
</div>
|
||||||
|
<div className="proto-card-copy">
|
||||||
|
<h3>{card.title}</h3>
|
||||||
|
<p>{card.description}</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</section>
|
||||||
|
|
||||||
<div className="form-grid">{commonFields.map(renderField)}</div>
|
<section className="builder-card">
|
||||||
|
<header className="builder-card-header">
|
||||||
|
<div>
|
||||||
|
<h2>2 — Tool identity</h2>
|
||||||
|
<p>Name and description visible to the LLM at runtime.</p>
|
||||||
|
</div>
|
||||||
|
<span className="status-pill status-draft">draft</span>
|
||||||
|
</header>
|
||||||
|
<div className="builder-card-body">
|
||||||
|
<div className="form-grid">{commonFields.map(renderField)}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div className="page-subsection">
|
<section className="builder-card">
|
||||||
<div className="page-subsection-header">
|
<header className="builder-card-header">
|
||||||
<h3>Target</h3>
|
<div>
|
||||||
<p>Protocol-specific transport configuration exposed through one MCP tool.</p>
|
<h2>3 — Upstream target</h2>
|
||||||
</div>
|
<p>Protocol-specific target configuration exposed through one MCP tool.</p>
|
||||||
<div className="form-grid">
|
</div>
|
||||||
{protocolFields.map(renderField)}
|
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
|
||||||
{activeProtocol === "grpc" ? (
|
</header>
|
||||||
<label className="field-block field-block-wide">
|
<div className="builder-card-body">
|
||||||
<span>Descriptor set file</span>
|
<div className="form-grid">
|
||||||
<small className="field-hint">
|
{protocolFields.map(renderField)}
|
||||||
Upload a compiled descriptor-set file to autofill the base64 payload.
|
{activeProtocol === "grpc" ? (
|
||||||
</small>
|
<label className="field-block field-block-wide">
|
||||||
<input
|
<span>Descriptor set file</span>
|
||||||
type="file"
|
<small className="field-hint">
|
||||||
accept=".bin,.pb,.desc"
|
Upload a compiled descriptor-set file to autofill the base64 payload.
|
||||||
onChange={(event) => {
|
</small>
|
||||||
void handleDescriptorFileChange(event.target.files?.[0]);
|
<input
|
||||||
}}
|
className="input"
|
||||||
/>
|
type="file"
|
||||||
</label>
|
accept=".bin,.pb,.desc"
|
||||||
) : null}
|
onChange={(event) => {
|
||||||
</div>
|
void handleDescriptorFileChange(event.target.files?.[0]);
|
||||||
</div>
|
}}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
) : null}
|
||||||
|
{activeProtocol === "rest" ? renderField({
|
||||||
|
name: "restStaticHeadersText",
|
||||||
|
label: "Static headers",
|
||||||
|
description:
|
||||||
|
"Always sent for this REST target before dynamic request headers are merged.",
|
||||||
|
rows: 8,
|
||||||
|
wide: true,
|
||||||
|
code: true,
|
||||||
|
}) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="page-subsection">
|
<div className="info-banner">
|
||||||
<div className="page-subsection-header">
|
<div className="info-dot" />
|
||||||
<h3>Contract</h3>
|
<p>
|
||||||
<p>Schemas and mapping stay JSON-based regardless of the upstream protocol.</p>
|
Headers and auth stay separate from path and payload mapping. Configure shared
|
||||||
|
transport headers in the execution section below.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-grid">{schemaFields.map(renderField)}</div>
|
</section>
|
||||||
<div className="form-grid">{mappingFields.map(renderField)}</div>
|
|
||||||
</div>
|
<section className="builder-card">
|
||||||
|
<header className="builder-card-header">
|
||||||
|
<div>
|
||||||
|
<h2>4 — Contract schemas</h2>
|
||||||
|
<p>Protocol-agnostic schemas for MCP input and output payloads.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="builder-card-body">
|
||||||
|
<div className="form-grid">{schemaFields.map(renderField)}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="builder-card">
|
||||||
|
<header className="builder-card-header">
|
||||||
|
<div>
|
||||||
|
<h2>5 — Mapping and execution</h2>
|
||||||
|
<p>Translate MCP input to request fields, then map upstream output back to tool output.</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="builder-card-body">
|
||||||
|
<div className="section-divider">
|
||||||
|
<span className="section-divider-label">Input → Request</span>
|
||||||
|
<div className="section-divider-line" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">{mappingFields.slice(0, 1).map(renderField)}</div>
|
||||||
|
|
||||||
|
<div className="section-divider">
|
||||||
|
<span className="section-divider-label">Response → Output</span>
|
||||||
|
<div className="section-divider-line" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">{mappingFields.slice(1).map(renderField)}</div>
|
||||||
|
|
||||||
|
<div className="section-divider">
|
||||||
|
<span className="section-divider-label">Headers And Runtime</span>
|
||||||
|
<div className="section-divider-line" />
|
||||||
|
</div>
|
||||||
|
<div className="form-grid">{headerFields.map(renderField)}</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{creationMutation.error ? (
|
{creationMutation.error ? (
|
||||||
<div className="feedback-card feedback-error">
|
<div className="feedback-card feedback-error">
|
||||||
@@ -314,8 +498,12 @@ export function OperationForm() {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div className="button-row">
|
<div className="sticky-action-bar">
|
||||||
<button className="button-primary" type="submit" disabled={creationMutation.isPending}>
|
<div className="sticky-action-copy">
|
||||||
|
<span className="status-pill status-testing">ready to publish later</span>
|
||||||
|
<p>{protocolSummary(activeProtocol)}</p>
|
||||||
|
</div>
|
||||||
|
<button className="button-primary button-primary-strong" type="submit" disabled={creationMutation.isPending}>
|
||||||
{creationMutation.isPending ? "Creating..." : "Create operation"}
|
{creationMutation.isPending ? "Creating..." : "Create operation"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,53 +1,9 @@
|
|||||||
import { OperationForm } from "../../features/operation-form/operation-form";
|
import { OperationForm } from "../../features/operation-form/operation-form";
|
||||||
import { PageSection } from "../../shared/ui/page-section";
|
|
||||||
|
|
||||||
const protocolCards = [
|
|
||||||
{
|
|
||||||
protocol: "rest",
|
|
||||||
title: "REST",
|
|
||||||
description:
|
|
||||||
"One tool maps to one method and one path, with body, query and header mapping.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "graphql",
|
|
||||||
title: "GraphQL",
|
|
||||||
description:
|
|
||||||
"One tool maps to one fixed query or mutation with stable variables and response shape.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
protocol: "grpc",
|
|
||||||
title: "gRPC",
|
|
||||||
description:
|
|
||||||
"One tool maps to one unary method backed by a descriptor-set contract.",
|
|
||||||
},
|
|
||||||
] as const;
|
|
||||||
|
|
||||||
export function OperationCreatePage() {
|
export function OperationCreatePage() {
|
||||||
return (
|
return (
|
||||||
<div className="page-stack">
|
<div className="page-stack">
|
||||||
<PageSection
|
<OperationForm />
|
||||||
title="Create Operation"
|
|
||||||
subtitle="Create one MCP tool from one fixed REST, GraphQL or unary gRPC contract."
|
|
||||||
>
|
|
||||||
<div className="hero-grid">
|
|
||||||
{protocolCards.map((card) => (
|
|
||||||
<article className="hero-card" key={card.protocol}>
|
|
||||||
<span className={`protocol-pill protocol-${card.protocol}`}>
|
|
||||||
{card.protocol}
|
|
||||||
</span>
|
|
||||||
<h3>{card.title}</h3>
|
|
||||||
<p className="body-copy">{card.description}</p>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</PageSection>
|
|
||||||
|
|
||||||
<PageSection
|
|
||||||
title="Contract Builder"
|
|
||||||
subtitle="Define the operator-facing tool contract first, then map it to the upstream target."
|
|
||||||
>
|
|
||||||
<OperationForm />
|
|
||||||
</PageSection>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,6 +40,25 @@ function describeTarget(target: OperationTarget) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function readObjectRecord(value: unknown) {
|
||||||
|
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return value as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readExecutionHeaders(value: unknown) {
|
||||||
|
const config = readObjectRecord(value);
|
||||||
|
const headers = config.headers;
|
||||||
|
|
||||||
|
if (headers === null || typeof headers !== "object" || Array.isArray(headers)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
return headers as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
function draftSubtitle(target: OperationTarget) {
|
function draftSubtitle(target: OperationTarget) {
|
||||||
switch (target.kind) {
|
switch (target.kind) {
|
||||||
case "rest":
|
case "rest":
|
||||||
@@ -147,6 +166,16 @@ export function OperationDetailPage() {
|
|||||||
<h3>Execution config</h3>
|
<h3>Execution config</h3>
|
||||||
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
|
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="result-panel">
|
||||||
|
<h3>Transport headers</h3>
|
||||||
|
<pre>{JSON.stringify(readExecutionHeaders(snapshot.execution_config), null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
{snapshot.target.kind === "rest" ? (
|
||||||
|
<div className="result-panel">
|
||||||
|
<h3>REST static headers</h3>
|
||||||
|
<pre>{JSON.stringify(snapshot.target.static_headers ?? {}, null, 2)}</pre>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</PageSection>
|
</PageSection>
|
||||||
) : null}
|
) : null}
|
||||||
|
|||||||
@@ -1,108 +1,133 @@
|
|||||||
import { NavLink, useLocation } from "react-router-dom";
|
|
||||||
import { ReactNode } from "react";
|
import { ReactNode } from "react";
|
||||||
|
import { NavLink, useLocation } from "react-router-dom";
|
||||||
|
|
||||||
type AppShellProps = {
|
type AppShellProps = {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
function pageTitle(pathname: string) {
|
function routeMeta(pathname: string) {
|
||||||
if (pathname.startsWith("/operations/new")) {
|
if (pathname === "/operations/new") {
|
||||||
return {
|
return {
|
||||||
title: "Create tool contract",
|
section: "Operations",
|
||||||
|
title: "New Operation",
|
||||||
|
heading: "Create tool contract",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Configure one fixed upstream contract and publish it as one MCP tool.",
|
"Define a single upstream endpoint and expose it as one MCP tool. Choose a protocol, fill the contract and map fields.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathname.startsWith("/operations/")) {
|
if (pathname.startsWith("/operations/")) {
|
||||||
return {
|
return {
|
||||||
title: "Operation workspace",
|
section: "Operations",
|
||||||
|
title: "Operation Workspace",
|
||||||
|
heading: "Refine operation contract",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Refine target metadata, validate mappings and publish the current draft.",
|
"Inspect target metadata, validate mappings, run tests and publish the active draft version.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: "Operation catalog",
|
section: "Operations",
|
||||||
|
title: "Catalog",
|
||||||
|
heading: "Operation catalog",
|
||||||
subtitle:
|
subtitle:
|
||||||
"Track draft and published tools across REST, GraphQL and unary gRPC.",
|
"Track draft and published tool contracts across REST, GraphQL and unary gRPC.",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
export function AppShell({ children }: AppShellProps) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const currentPage = pageTitle(location.pathname);
|
const meta = routeMeta(location.pathname);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="layout-shell">
|
<div className="layout-shell">
|
||||||
<aside className="layout-sidebar">
|
<aside className="layout-sidebar">
|
||||||
<div className="brand-block">
|
<div className="sidebar-logo">
|
||||||
<p className="eyebrow">RMCP Console</p>
|
<div className="logo-mark">
|
||||||
<h1>Tool Console</h1>
|
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
|
||||||
<p className="brand-copy">
|
<path d="M12 2 2 7l10 5 10-5-10-5Z" />
|
||||||
Low-code operator surface for REST, GraphQL and unary gRPC tool
|
<path d="m2 12 10 5 10-5" />
|
||||||
onboarding, draft generation and publish flow.
|
<path d="m2 17 10 5 10-5" />
|
||||||
</p>
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="logo-text">
|
||||||
|
<strong>MCPaaS</strong>
|
||||||
|
<span>Tool Console</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="sidebar-nav">
|
<div className="sidebar-section">
|
||||||
<p className="nav-section-title">Workspace</p>
|
<p className="sidebar-label">Workspace</p>
|
||||||
<NavLink
|
<nav className="sidebar-nav">
|
||||||
to="/operations"
|
<NavLink
|
||||||
className={({ isActive }) =>
|
to="/operations"
|
||||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
className={({ isActive }) =>
|
||||||
}
|
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||||
>
|
}
|
||||||
<strong>Operations</strong>
|
>
|
||||||
<span>Catalog, status, search and entry points.</span>
|
<div className="nav-copy">
|
||||||
</NavLink>
|
<strong>Operations</strong>
|
||||||
<NavLink
|
<span>Catalog, status and search across all tools.</span>
|
||||||
to="/operations/new"
|
</div>
|
||||||
className={({ isActive }) =>
|
<span className="nav-badge">live</span>
|
||||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
</NavLink>
|
||||||
}
|
|
||||||
>
|
|
||||||
<strong>New Operation</strong>
|
|
||||||
<span>Create a protocol-specific contract and publishable tool.</span>
|
|
||||||
</NavLink>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
<div className="sidebar-meta">
|
<NavLink
|
||||||
<div className="sidebar-card">
|
to="/operations/new"
|
||||||
<p className="eyebrow">Protocols</p>
|
className={({ isActive }) =>
|
||||||
<div className="sidebar-pill-row">
|
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||||
<span className="protocol-pill protocol-rest">rest</span>
|
}
|
||||||
<span className="protocol-pill protocol-graphql">graphql</span>
|
>
|
||||||
<span className="protocol-pill protocol-grpc">grpc</span>
|
<div className="nav-copy">
|
||||||
</div>
|
<strong>New Operation</strong>
|
||||||
|
<span>Create a new protocol-specific tool contract.</span>
|
||||||
|
</div>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="sidebar-divider" />
|
||||||
|
|
||||||
|
<div className="sidebar-section">
|
||||||
|
<p className="sidebar-label">Protocols</p>
|
||||||
|
<div className="sidebar-pill-row">
|
||||||
|
<span className="protocol-pill protocol-rest">REST</span>
|
||||||
|
<span className="protocol-pill protocol-graphql">GraphQL</span>
|
||||||
|
<span className="protocol-pill protocol-grpc">gRPC</span>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="sidebar-card">
|
<div className="sidebar-footer">
|
||||||
<p className="eyebrow">Lifecycle</p>
|
<div className="sidebar-user-avatar" />
|
||||||
<div className="sidebar-pill-row">
|
<div className="sidebar-user-copy">
|
||||||
<span className="status-pill status-draft">draft</span>
|
<strong>Operator</strong>
|
||||||
<span className="status-pill status-published">published</span>
|
<span>Admin</span>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="layout-main">
|
<div className="layout-main">
|
||||||
<header className="workspace-header">
|
<header className="topbar">
|
||||||
<div>
|
<div className="breadcrumb">
|
||||||
<p className="eyebrow">Operator Workspace</p>
|
<span>{meta.section}</span>
|
||||||
<h2>{currentPage.title}</h2>
|
<span className="breadcrumb-separator">/</span>
|
||||||
<p>{currentPage.subtitle}</p>
|
<strong>{meta.title}</strong>
|
||||||
</div>
|
</div>
|
||||||
<div className="workspace-badges">
|
<div className="topbar-actions">
|
||||||
<span className="workspace-badge">MCP request-response</span>
|
<span className="workspace-badge">MCP request-response</span>
|
||||||
<span className="workspace-badge">JSONPath mappings</span>
|
<span className="workspace-badge">JSONPath mappings</span>
|
||||||
<span className="workspace-badge">YAML import/export</span>
|
<span className="workspace-badge">YAML import/export</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{children}
|
<main className="main-content">
|
||||||
</main>
|
<div className="page-header">
|
||||||
|
<h1>{meta.heading}</h1>
|
||||||
|
<p>{meta.subtitle}</p>
|
||||||
|
</div>
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+810
-434
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user