Merge branch 'feat/ui-concept-alignment'

This commit is contained in:
a.tolmachev
2026-03-26 01:19:50 +03:00
8 changed files with 1306 additions and 666 deletions
+6 -6
View File
@@ -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/);
});
}); });
+38 -3
View File
@@ -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 -45
View File
@@ -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}
+86 -61
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff