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
### `feat/ui-ux-pass`
### `feat/ui-concept-alignment`
Status: completed
DoD:
- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций
- operation list показывает обзор состояния системы, а не только список карточек
- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC`
- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию
- UI build и tests остаются зелеными после UX-pass
- shell и create flow визуально опираются на утвержденный concept
- sidebar, topbar и page header выглядят как единая продуктовая система
- create page больше не выглядит как длинная сырая форма, а как step-based contract builder
- кодовые JSON и mapping поля имеют отдельную визуальную подачу
- UI build и tests остаются зелеными после выравнивания под concept
## Next
@@ -4,11 +4,33 @@ import { buildOperationPayload, defaultOperationFormValues } from "./model";
describe("buildOperationPayload", () => {
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.target.kind).toBe("rest");
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({
rules: [
{
@@ -111,4 +133,13 @@ describe("buildOperationPayload", () => {
}),
).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(),
restMethod: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
restPathTemplate: z.string(),
restStaticHeadersText: jsonTextSchema,
graphqlEndpoint: z.string(),
graphqlOperationType: z.enum(["query", "mutation"]),
graphqlOperationName: z.string(),
@@ -32,6 +33,7 @@ export const operationFormSchema = z
outputSchemaText: jsonTextSchema,
inputMappingText: jsonTextSchema,
outputMappingText: jsonTextSchema,
executionHeadersText: jsonTextSchema,
executionConfigText: jsonTextSchema,
})
.superRefine((values, context) => {
@@ -111,6 +113,22 @@ export const operationFormSchema = z
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() {
return JSON.stringify(
{
@@ -265,6 +283,7 @@ export const defaultOperationFormValues: OperationFormValues = {
restBaseUrl: "https://api.example.com",
restMethod: "POST",
restPathTemplate: "/v1/leads",
restStaticHeadersText: JSON.stringify({}, null, 2),
graphqlEndpoint: "https://api.example.com/graphql",
graphqlOperationType: "mutation",
graphqlOperationName: "CreateLead",
@@ -285,6 +304,7 @@ export const defaultOperationFormValues: OperationFormValues = {
outputSchemaText: defaultOutputSchemaText(),
inputMappingText: defaultRestInputMappingText(),
outputMappingText: defaultRestOutputMappingText(),
executionHeadersText: JSON.stringify({}, null, 2),
executionConfigText: defaultExecutionConfigText(),
};
@@ -300,6 +320,8 @@ export function getProtocolPreset(
toolDescription: "Creates a CRM lead from MCP input fields.",
inputMappingText: defaultRestInputMappingText(),
outputMappingText: defaultRestOutputMappingText(),
restStaticHeadersText: JSON.stringify({}, null, 2),
executionHeadersText: JSON.stringify({}, null, 2),
};
case "graphql":
return {
@@ -310,6 +332,7 @@ export function getProtocolPreset(
"Executes a fixed GraphQL mutation and returns the selected lead payload.",
inputMappingText: defaultGraphqlInputMappingText(),
outputMappingText: defaultGraphqlOutputMappingText(),
executionHeadersText: JSON.stringify({}, null, 2),
};
case "grpc":
return {
@@ -320,6 +343,7 @@ export function getProtocolPreset(
"Executes a unary gRPC method using a descriptor-driven request contract.",
inputMappingText: defaultGrpcInputMappingText(),
outputMappingText: defaultGrpcOutputMappingText(),
executionHeadersText: JSON.stringify({}, null, 2),
};
}
}
@@ -330,10 +354,18 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
const outputSchema = safeParseJson(values.outputSchemaText, "Output schema");
const inputMapping = safeParseJson(values.inputMappingText, "Input 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,
"Execution config",
);
const normalizedExecutionConfig = {
...executionConfig,
headers: executionHeaders,
};
const basePayload = {
name: values.name,
@@ -342,7 +374,7 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
output_schema: outputSchema,
input_mapping: inputMapping,
output_mapping: outputMapping,
execution_config: executionConfig,
execution_config: normalizedExecutionConfig,
tool_description: {
title: values.toolTitle,
description: values.toolDescription,
@@ -361,7 +393,10 @@ export function buildOperationPayload(rawValues: OperationFormValues) {
base_url: values.restBaseUrl,
method: values.restMethod,
path_template: values.restPathTemplate,
static_headers: {},
static_headers: parseStringMap(
values.restStaticHeadersText,
"REST static headers",
),
},
};
case "graphql":
@@ -22,89 +22,155 @@ type FieldConfig = {
description?: string;
wide?: boolean;
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[] = [
{ name: "name", label: "Tool name" },
{ name: "displayName", label: "Display name" },
{ name: "toolTitle", label: "Tool title" },
{
name: "toolDescription",
label: "Tool description",
description: "LLM-facing description for the MCP tool.",
label: "Description",
description: "LLM-facing description for the tool runtime contract.",
rows: 4,
wide: true,
},
];
const restFields: FieldConfig[] = [
{ name: "restBaseUrl", label: "Base URL" },
{ name: "restMethod", label: "HTTP method" },
{ name: "restBaseUrl", label: "Base URL", wide: true },
{ name: "restPathTemplate", label: "Path template" },
{ name: "restMethod", label: "HTTP method" },
];
const graphqlFields: FieldConfig[] = [
{ name: "graphqlEndpoint", label: "GraphQL endpoint" },
{ name: "graphqlEndpoint", label: "GraphQL endpoint", wide: true },
{ name: "graphqlOperationType", label: "Operation type" },
{ name: "graphqlOperationName", label: "Operation name" },
{
name: "graphqlQueryTemplate",
label: "Query template",
description: "A fixed query or mutation document exposed as a single MCP tool.",
rows: 10,
name: "graphqlResponsePath",
label: "Response path",
description: "Stable extraction root inside the response payload.",
wide: true,
},
{
name: "graphqlResponsePath",
label: "Response path",
description: "JSONPath-like extraction root inside response preview.",
name: "graphqlQueryTemplate",
label: "Query template",
description: "Fixed GraphQL document exposed as one MCP tool.",
rows: 12,
wide: true,
code: true,
},
];
const grpcFields: FieldConfig[] = [
{ name: "grpcServerAddr", label: "Server address" },
{ name: "grpcServerAddr", label: "Server address", wide: true },
{ name: "grpcPackage", label: "Package" },
{ name: "grpcService", label: "Service" },
{ name: "grpcMethod", label: "Method" },
{
name: "grpcDescriptorRef",
label: "Descriptor reference",
description: "Stable descriptor identifier stored together with the operation.",
description: "Stable descriptor identifier stored with the operation.",
wide: true,
},
{
name: "grpcDescriptorSetB64",
label: "Descriptor set base64",
description: "Use a descriptor-set file to prefill this field before creation.",
rows: 8,
description: "Compiled descriptor-set contents used for runtime invocation.",
rows: 10,
wide: true,
code: true,
},
];
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[] = [
{ 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",
label: "Execution config",
description: "Timeouts, auth profile reference and protocol options.",
rows: 10,
wide: true,
code: true,
},
];
function protocolSummary(protocol: OperationFormValues["protocol"]) {
switch (protocol) {
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":
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":
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) {
const fieldError = form.formState.errors[field.name];
const message =
typeof fieldError?.message === "string" ? fieldError.message : undefined;
function fieldError(name: OperationFieldName) {
const error = form.formState.errors[name];
return typeof error?.message === "string" ? error.message : undefined;
}
if (
field.name === "restMethod" ||
field.name === "graphqlOperationType" ||
field.name === "protocol"
) {
return (
<label
className={field.wide ? "field-block field-block-wide" : "field-block"}
key={field.name}
>
<span>{field.label}</span>
{field.description ? <small className="field-hint">{field.description}</small> : null}
<select {...form.register(field.name)}>
{field.name === "protocol" ? (
<>
<option value="rest">REST</option>
<option value="graphql">GraphQL</option>
<option value="grpc">gRPC</option>
</>
) : null}
{field.name === "restMethod" ? (
<>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</>
) : null}
{field.name === "graphqlOperationType" ? (
<>
<option value="query">query</option>
<option value="mutation">mutation</option>
</>
) : null}
</select>
{message ? <small className="field-error">{message}</small> : null}
</label>
);
}
const isTextarea = field.rows !== undefined;
function renderSelect(field: FieldConfig) {
const message = fieldError(field.name);
return (
<label
@@ -243,11 +269,70 @@ export function OperationForm() {
>
<span>{field.label}</span>
{field.description ? <small className="field-hint">{field.description}</small> : null}
{isTextarea ? (
<textarea rows={field.rows} {...form.register(field.name)} />
) : (
<input type="text" {...form.register(field.name)} />
)}
<select className="select" {...form.register(field.name)}>
{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>
);
}
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}
</label>
);
@@ -255,56 +340,155 @@ export function OperationForm() {
return (
<form
className="stack-layout"
className="builder-stack"
onSubmit={form.handleSubmit((values) => {
creationMutation.mutate(values);
})}
>
<div className="protocol-selector-card">
<div className="form-grid">
{renderField({ name: "protocol", label: "Protocol" })}
<div className="protocol-brief">
<span className={`protocol-pill protocol-${activeProtocol}`}>{activeProtocol}</span>
<p>{protocolSummary(activeProtocol)}</p>
<section className="builder-card">
<header className="builder-card-header">
<div>
<h2>1 Protocol</h2>
<p>One tool maps to exactly one upstream method.</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>
</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">
<div className="page-subsection-header">
<h3>Target</h3>
<p>Protocol-specific transport configuration exposed through one MCP tool.</p>
</div>
<div className="form-grid">
{protocolFields.map(renderField)}
{activeProtocol === "grpc" ? (
<label className="field-block field-block-wide">
<span>Descriptor set file</span>
<small className="field-hint">
Upload a compiled descriptor-set file to autofill the base64 payload.
</small>
<input
type="file"
accept=".bin,.pb,.desc"
onChange={(event) => {
void handleDescriptorFileChange(event.target.files?.[0]);
}}
/>
</label>
) : null}
</div>
</div>
<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">
{protocolFields.map(renderField)}
{activeProtocol === "grpc" ? (
<label className="field-block field-block-wide">
<span>Descriptor set file</span>
<small className="field-hint">
Upload a compiled descriptor-set file to autofill the base64 payload.
</small>
<input
className="input"
type="file"
accept=".bin,.pb,.desc"
onChange={(event) => {
void handleDescriptorFileChange(event.target.files?.[0]);
}}
/>
</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="page-subsection-header">
<h3>Contract</h3>
<p>Schemas and mapping stay JSON-based regardless of the upstream protocol.</p>
<div className="info-banner">
<div className="info-dot" />
<p>
Headers and auth stay separate from path and payload mapping. Configure shared
transport headers in the execution section below.
</p>
</div>
</div>
<div className="form-grid">{schemaFields.map(renderField)}</div>
<div className="form-grid">{mappingFields.map(renderField)}</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>
</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 ? (
<div className="feedback-card feedback-error">
@@ -314,8 +498,12 @@ export function OperationForm() {
</div>
) : null}
<div className="button-row">
<button className="button-primary" type="submit" disabled={creationMutation.isPending}>
<div className="sticky-action-bar">
<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"}
</button>
</div>
+1 -45
View File
@@ -1,53 +1,9 @@
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() {
return (
<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 />
</PageSection>
<OperationForm />
</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) {
switch (target.kind) {
case "rest":
@@ -147,6 +166,16 @@ export function OperationDetailPage() {
<h3>Execution config</h3>
<pre>{JSON.stringify(snapshot.execution_config, null, 2)}</pre>
</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>
</PageSection>
) : null}
+86 -61
View File
@@ -1,108 +1,133 @@
import { NavLink, useLocation } from "react-router-dom";
import { ReactNode } from "react";
import { NavLink, useLocation } from "react-router-dom";
type AppShellProps = {
children: ReactNode;
};
function pageTitle(pathname: string) {
if (pathname.startsWith("/operations/new")) {
function routeMeta(pathname: string) {
if (pathname === "/operations/new") {
return {
title: "Create tool contract",
section: "Operations",
title: "New Operation",
heading: "Create tool contract",
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/")) {
return {
title: "Operation workspace",
section: "Operations",
title: "Operation Workspace",
heading: "Refine operation contract",
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 {
title: "Operation catalog",
section: "Operations",
title: "Catalog",
heading: "Operation catalog",
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) {
const location = useLocation();
const currentPage = pageTitle(location.pathname);
const meta = routeMeta(location.pathname);
return (
<div className="layout-shell">
<aside className="layout-sidebar">
<div className="brand-block">
<p className="eyebrow">RMCP Console</p>
<h1>Tool Console</h1>
<p className="brand-copy">
Low-code operator surface for REST, GraphQL and unary gRPC tool
onboarding, draft generation and publish flow.
</p>
<div className="sidebar-logo">
<div className="logo-mark">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.4">
<path d="M12 2 2 7l10 5 10-5-10-5Z" />
<path d="m2 12 10 5 10-5" />
<path d="m2 17 10 5 10-5" />
</svg>
</div>
<div className="logo-text">
<strong>MCPaaS</strong>
<span>Tool Console</span>
</div>
</div>
<nav className="sidebar-nav">
<p className="nav-section-title">Workspace</p>
<NavLink
to="/operations"
className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
<strong>Operations</strong>
<span>Catalog, status, search and entry points.</span>
</NavLink>
<NavLink
to="/operations/new"
className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
<strong>New Operation</strong>
<span>Create a protocol-specific contract and publishable tool.</span>
</NavLink>
</nav>
<div className="sidebar-section">
<p className="sidebar-label">Workspace</p>
<nav className="sidebar-nav">
<NavLink
to="/operations"
className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
<div className="nav-copy">
<strong>Operations</strong>
<span>Catalog, status and search across all tools.</span>
</div>
<span className="nav-badge">live</span>
</NavLink>
<div className="sidebar-meta">
<div className="sidebar-card">
<p className="eyebrow">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>
<NavLink
to="/operations/new"
className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
<div className="nav-copy">
<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 className="sidebar-card">
<p className="eyebrow">Lifecycle</p>
<div className="sidebar-pill-row">
<span className="status-pill status-draft">draft</span>
<span className="status-pill status-published">published</span>
</div>
<div className="sidebar-footer">
<div className="sidebar-user-avatar" />
<div className="sidebar-user-copy">
<strong>Operator</strong>
<span>Admin</span>
</div>
</div>
</aside>
<main className="layout-main">
<header className="workspace-header">
<div>
<p className="eyebrow">Operator Workspace</p>
<h2>{currentPage.title}</h2>
<p>{currentPage.subtitle}</p>
<div className="layout-main">
<header className="topbar">
<div className="breadcrumb">
<span>{meta.section}</span>
<span className="breadcrumb-separator">/</span>
<strong>{meta.title}</strong>
</div>
<div className="workspace-badges">
<div className="topbar-actions">
<span className="workspace-badge">MCP request-response</span>
<span className="workspace-badge">JSONPath mappings</span>
<span className="workspace-badge">YAML import/export</span>
</div>
</header>
{children}
</main>
<main className="main-content">
<div className="page-header">
<h1>{meta.heading}</h1>
<p>{meta.subtitle}</p>
</div>
{children}
</main>
</div>
</div>
);
}
+810 -434
View File
File diff suppressed because it is too large Load Diff