feat: align operator UI with design concept

This commit is contained in:
a.tolmachev
2026-03-26 01:18:53 +03:00
parent 8c5d585b7f
commit 9d1f5347c2
5 changed files with 1201 additions and 670 deletions
+6 -6
View File
@@ -2,17 +2,17 @@
## Current ## Current
### `feat/header-fields` ### `feat/ui-concept-alignment`
Status: completed Status: completed
DoD: DoD:
- форма создания явно поддерживает настройку headers, а не прячет их только в сыром JSON - shell и create flow визуально опираются на утвержденный concept
- `execution headers` доступны для всех протоколов - sidebar, topbar и page header выглядят как единая продуктовая система
- `static headers` доступны для REST target - create page больше не выглядит как длинная сырая форма, а как step-based contract builder
- detail view показывает сохраненные headers - кодовые JSON и mapping поля имеют отдельную визуальную подачу
- UI build и tests остаются зелеными - UI build и tests остаются зелеными после выравнивания под concept
## Next ## Next
@@ -22,103 +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" },
name: "restStaticHeadersText",
label: "Static headers",
description: "Always sent for this REST target before dynamic request headers are merged.",
rows: 8,
wide: true,
},
]; ];
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", name: "executionHeadersText",
label: "Execution headers", label: "Execution headers",
description: "Shared transport headers available for REST, GraphQL and gRPC requests.", description: "Shared transport headers for REST, GraphQL and gRPC requests.",
rows: 8, rows: 8,
wide: true, 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.";
} }
} }
@@ -202,16 +254,14 @@ 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; }
function renderSelect(field: FieldConfig) {
const message = fieldError(field.name);
if (
field.name === "restMethod" ||
field.name === "graphqlOperationType" ||
field.name === "protocol"
) {
return ( return (
<label <label
className={field.wide ? "field-block field-block-wide" : "field-block"} className={field.wide ? "field-block field-block-wide" : "field-block"}
@@ -219,14 +269,7 @@ 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}
<select {...form.register(field.name)}> <select className="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" ? ( {field.name === "restMethod" ? (
<> <>
<option value="GET">GET</option> <option value="GET">GET</option>
@@ -248,49 +291,126 @@ export function OperationForm() {
); );
} }
const isTextarea = field.rows !== undefined; 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 ( return (
<label <label className={blockClassName} key={field.name}>
className={field.wide ? "field-block field-block-wide" : "field-block"}
key={field.name}
>
<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 ? ( {field.code ? (
<textarea rows={field.rows} {...form.register(field.name)} /> <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>
) : ( ) : (
<input type="text" {...form.register(field.name)} /> <textarea rows={field.rows} {...form.register(field.name)} />
)} )}
{message ? <small className="field-error">{message}</small> : null} {message ? <small className="field-error">{message}</small> : null}
</label> </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}
</label>
);
}
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>
</div>
</div> </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>
</section>
<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 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>
</section>
<section className="builder-card">
<header className="builder-card-header">
<div>
<h2>3 Upstream target</h2>
<p>Protocol-specific target configuration exposed through one MCP tool.</p>
</div>
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
</header>
<div className="builder-card-body">
<div className="form-grid"> <div className="form-grid">
{protocolFields.map(renderField)} {protocolFields.map(renderField)}
{activeProtocol === "grpc" ? ( {activeProtocol === "grpc" ? (
@@ -300,6 +420,7 @@ export function OperationForm() {
Upload a compiled descriptor-set file to autofill the base64 payload. Upload a compiled descriptor-set file to autofill the base64 payload.
</small> </small>
<input <input
className="input"
type="file" type="file"
accept=".bin,.pb,.desc" accept=".bin,.pb,.desc"
onChange={(event) => { onChange={(event) => {
@@ -308,17 +429,66 @@ export function OperationForm() {
/> />
</label> </label>
) : null} ) : null}
</div> {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>
<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>
</section>
<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 className="form-grid">{schemaFields.map(renderField)}</div>
<div className="form-grid">{mappingFields.map(renderField)}</div>
</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">
@@ -328,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
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 /> <OperationForm />
</PageSection>
</div> </div>
); );
} }
+64 -39
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>
<div className="sidebar-section">
<p className="sidebar-label">Workspace</p>
<nav className="sidebar-nav"> <nav className="sidebar-nav">
<p className="nav-section-title">Workspace</p>
<NavLink <NavLink
to="/operations" to="/operations"
className={({ isActive }) => className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link" isActive ? "nav-link nav-link-active" : "nav-link"
} }
> >
<div className="nav-copy">
<strong>Operations</strong> <strong>Operations</strong>
<span>Catalog, status, search and entry points.</span> <span>Catalog, status and search across all tools.</span>
</div>
<span className="nav-badge">live</span>
</NavLink> </NavLink>
<NavLink <NavLink
to="/operations/new" to="/operations/new"
className={({ isActive }) => className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link" isActive ? "nav-link nav-link-active" : "nav-link"
} }
> >
<div className="nav-copy">
<strong>New Operation</strong> <strong>New Operation</strong>
<span>Create a protocol-specific contract and publishable tool.</span> <span>Create a new protocol-specific tool contract.</span>
</div>
</NavLink> </NavLink>
</nav> </nav>
</div>
<div className="sidebar-meta"> <div className="sidebar-divider" />
<div className="sidebar-card">
<p className="eyebrow">Protocols</p> <div className="sidebar-section">
<p className="sidebar-label">Protocols</p>
<div className="sidebar-pill-row"> <div className="sidebar-pill-row">
<span className="protocol-pill protocol-rest">rest</span> <span className="protocol-pill protocol-rest">REST</span>
<span className="protocol-pill protocol-graphql">graphql</span> <span className="protocol-pill protocol-graphql">GraphQL</span>
<span className="protocol-pill protocol-grpc">grpc</span> <span className="protocol-pill protocol-grpc">gRPC</span>
</div> </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>
<main className="main-content">
<div className="page-header">
<h1>{meta.heading}</h1>
<p>{meta.subtitle}</p>
</div>
{children} {children}
</main> </main>
</div> </div>
</div>
); );
} }
+810 -434
View File
File diff suppressed because it is too large Load Diff