merge: ui v1

This commit is contained in:
a.tolmachev
2026-03-25 19:28:24 +03:00
32 changed files with 5084 additions and 62 deletions
+27
View File
@@ -56,3 +56,30 @@ jobs:
- name: Run tests
run: cargo test --workspace --all-targets
ui:
name: UI Checks
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Install Node.js
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
cache-dependency-path: apps/ui/package-lock.json
- name: Install UI dependencies
working-directory: apps/ui
run: npm ci
- name: Build UI
working-directory: apps/ui
run: npm run build
- name: Run UI tests
working-directory: apps/ui
run: npm run test -- --run
+5
View File
@@ -8,4 +8,9 @@
/.env
/var
/notes
apps/ui/node_modules
apps/ui/dist
*.tsbuildinfo
apps/ui/vite.config.js
apps/ui/vite.config.d.ts
*.log
+7 -8
View File
@@ -2,22 +2,21 @@
## Current
### `feat/admin-api-v1`
### `feat/ui-v1`
Status: completed
DoD:
- CRUD operations are available through admin API
- version creation and publish flow work through HTTP endpoints
- JSON sample upload and draft generation work for REST scenario
- auth profiles and YAML import/export are available
- integration tests cover key admin API scenarios
- handlers do not contain hidden business logic
- UI covers operation list, create, test, sample upload, draft generation and publish
- UI works against `admin-api` without mock backend
- critical form helpers are covered by frontend tests
- production UI build works in Docker container
- CI checks UI build and tests together with Rust workspace
## Next
- `feat/ui-v1`
- `feat/mcp-server`
## Backlog
+10 -1
View File
@@ -1,6 +1,15 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY apps/ui/package.json apps/ui/package-lock.json ./
RUN npm ci
COPY apps/ui/ ./
RUN npm run build
FROM nginx:1.27-alpine
COPY apps/ui/index.html /usr/share/nginx/html/index.html
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 3000
+6 -53
View File
@@ -1,59 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>RMCP UI</title>
<style>
:root {
color-scheme: light;
font-family: "Segoe UI", sans-serif;
background: #f7f7f2;
color: #1e1d1a;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background:
radial-gradient(circle at top left, rgba(180, 196, 161, 0.45), transparent 32%),
radial-gradient(circle at bottom right, rgba(226, 196, 154, 0.45), transparent 38%),
#f7f7f2;
}
main {
width: min(720px, calc(100vw - 48px));
padding: 40px;
border-radius: 24px;
background: rgba(255, 255, 255, 0.84);
box-shadow: 0 24px 80px rgba(35, 34, 29, 0.14);
}
h1 {
margin: 0 0 12px;
font-size: clamp(2rem, 5vw, 3.4rem);
line-height: 1;
}
p {
margin: 0;
font-size: 1.05rem;
line-height: 1.6;
}
code {
padding: 0.2rem 0.4rem;
border-radius: 0.35rem;
background: rgba(31, 30, 27, 0.08);
}
</style>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>RMCP Console</title>
</head>
<body>
<main>
<h1>RMCP UI</h1>
<p>Deployment placeholder for the administrative console. The production UI container is reachable through <code>/</code>.</p>
</main>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
</html>
+3266
View File
File diff suppressed because it is too large Load Diff
+33
View File
@@ -0,0 +1,33 @@
{
"name": "rmcp-ui",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit -p tsconfig.app.json && tsc --noEmit -p tsconfig.node.json && vite build",
"preview": "vite preview",
"test": "vitest"
},
"dependencies": {
"@hookform/resolvers": "^5.2.2",
"@tanstack/react-query": "^5.90.5",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-hook-form": "^7.62.0",
"react-router-dom": "^7.9.3",
"zod": "^4.1.11"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.8.0",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^14.6.1",
"@types/react": "^19.1.16",
"@types/react-dom": "^19.1.9",
"@vitejs/plugin-react": "^5.0.4",
"jsdom": "^27.0.0",
"typescript": "^5.9.3",
"vite": "^7.1.7",
"vitest": "^3.2.4"
}
}
+24
View File
@@ -0,0 +1,24 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactNode, useState } from "react";
type AppProvidersProps = {
children: ReactNode;
};
export function AppProviders({ children }: AppProvidersProps) {
const [queryClient] = useState(
() =>
new QueryClient({
defaultOptions: {
queries: {
staleTime: 15_000,
retry: 1,
},
},
}),
);
return (
<QueryClientProvider client={queryClient}>{children}</QueryClientProvider>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { BrowserRouter, Navigate, Route, Routes } from "react-router-dom";
import { AppShell } from "../shared/ui/app-shell";
import { OperationCreatePage } from "../pages/operation-create/page";
import { OperationDetailPage } from "../pages/operation-detail/page";
import { OperationListPage } from "../pages/operation-list/page";
export function AppRouter() {
return (
<BrowserRouter>
<AppShell>
<Routes>
<Route path="/" element={<Navigate to="/operations" replace />} />
<Route path="/operations" element={<OperationListPage />} />
<Route path="/operations/new" element={<OperationCreatePage />} />
<Route
path="/operations/:operationId"
element={<OperationDetailPage />}
/>
</Routes>
</AppShell>
</BrowserRouter>
);
}
+84
View File
@@ -0,0 +1,84 @@
import { getJson, getText, postJson, postText } from "../../shared/api/client";
import type {
AuthProfile,
DraftGenerationResult,
OperationRecord,
OperationSummary,
TestRunResult,
} from "./types";
export function listOperations() {
return getJson<{ items: OperationSummary[] }>("/api/admin/operations");
}
export function getOperation(operationId: string) {
return getJson<OperationSummary>(`/api/admin/operations/${operationId}`);
}
export function getOperationVersion(operationId: string, version: number) {
return getJson<OperationRecord>(
`/api/admin/operations/${operationId}/versions/${version}`,
);
}
export function createOperation(payload: unknown) {
return postJson<{
operation_id: string;
version: number;
status: string;
}>("/api/admin/operations", payload);
}
export function publishOperation(operationId: string, version: number) {
return postJson<{ published_version: number; published_at: string }>(
`/api/admin/operations/${operationId}/publish`,
{ version },
);
}
export function runOperationTest(
operationId: string,
version: number,
input: unknown,
) {
return postJson<TestRunResult>(`/api/admin/operations/${operationId}/test-runs`, {
version,
input,
});
}
export function uploadInputJsonSample(operationId: string, payload: unknown) {
return postJson(`/api/admin/operations/${operationId}/samples/input-json`, payload);
}
export function uploadOutputJsonSample(operationId: string, payload: unknown) {
return postJson(
`/api/admin/operations/${operationId}/samples/output-json`,
payload,
);
}
export function generateDraft(operationId: string) {
return postJson<DraftGenerationResult>(
`/api/admin/operations/${operationId}/drafts/generate`,
{
sources: ["input_json_sample", "output_json_sample"],
},
);
}
export function exportOperationYaml(operationId: string, version?: number) {
const query = version === undefined ? "" : `?version=${version}`;
return getText(`/api/admin/operations/${operationId}/export${query}`);
}
export function importOperationYaml(yamlText: string, mode: "create" | "upsert") {
return postText<{ operation_id: string; version: number; import_mode: string }>(
`/api/admin/operations/import?mode=${mode}`,
yamlText,
);
}
export function listAuthProfiles() {
return getJson<{ items: AuthProfile[] }>("/api/admin/auth-profiles");
}
+85
View File
@@ -0,0 +1,85 @@
export type Protocol = "rest" | "graphql" | "grpc";
export type OperationStatus = "draft" | "testing" | "published" | "archived";
export type OperationSummary = {
id: string;
name: string;
display_name: string;
protocol: Protocol;
status: OperationStatus;
current_draft_version: number;
latest_published_version: number | null;
created_at: string;
updated_at: string;
published_at: string | null;
};
export type OperationRecord = {
operation_id: string;
version: number;
status: OperationStatus;
change_note: string | null;
created_at: string;
created_by: string | null;
snapshot: OperationSnapshot;
};
export type OperationSnapshot = {
id: string;
name: string;
display_name: string;
protocol: Protocol;
status: OperationStatus;
version: number;
target: {
kind: "rest" | "graphql" | "grpc";
base_url?: string;
method?: string;
path_template?: string;
static_headers?: Record<string, string>;
};
input_schema: unknown;
output_schema: unknown;
input_mapping: unknown;
output_mapping: unknown;
execution_config: unknown;
tool_description: {
title: string;
description: string;
tags: string[];
examples: Array<{ input: unknown }>;
};
};
export type DraftGenerationResult = {
generated_draft: {
status: string;
source_types: string[];
generated_at: string | null;
input_schema_generated: boolean;
output_schema_generated: boolean;
input_mapping_generated: boolean;
output_mapping_generated: boolean;
warnings: string[];
};
input_schema: unknown;
output_schema: unknown;
input_mapping: unknown;
output_mapping: unknown;
};
export type TestRunResult = {
ok: boolean;
request_preview: unknown;
response_preview: unknown;
errors: Array<{ code: string; message: string }>;
};
export type AuthProfile = {
id: string;
name: string;
kind: string;
config: unknown;
created_at: string;
updated_at: string;
};
@@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import {
buildOperationPayload,
defaultOperationFormValues,
} from "./model";
describe("buildOperationPayload", () => {
it("creates a REST operation payload from form values", () => {
const payload = buildOperationPayload(defaultOperationFormValues);
expect(payload.protocol).toBe("rest");
expect(payload.target.method).toBe("POST");
expect(payload.input_mapping).toEqual({
rules: [
{
source: "$.mcp.email",
target: "$.request.body.email",
required: true,
},
],
});
});
it("throws when JSON fields are invalid", () => {
expect(() =>
buildOperationPayload({
...defaultOperationFormValues,
inputSchemaText: "{invalid",
}),
).toThrow(/Input schema contains invalid JSON/);
});
});
@@ -0,0 +1,130 @@
import { z } from "zod";
import { safeParseJson } from "../../shared/lib/json";
const operationFormSchema = z.object({
name: z
.string()
.min(3, "Name is too short")
.regex(/^[a-z0-9_]+$/, "Use lowercase snake_case"),
displayName: z.string().min(3, "Display name is too short"),
baseUrl: z.string().url("Base URL must be valid"),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
pathTemplate: z.string().min(1, "Path is required"),
toolTitle: z.string().min(3, "Tool title is too short"),
toolDescription: z.string().min(8, "Tool description is too short"),
inputSchemaText: z.string().min(2, "Input schema is required"),
outputSchemaText: z.string().min(2, "Output schema is required"),
inputMappingText: z.string().min(2, "Input mapping is required"),
outputMappingText: z.string().min(2, "Output mapping is required"),
executionConfigText: z.string().min(2, "Execution config is required"),
});
export type OperationFormValues = z.infer<typeof operationFormSchema>;
export const defaultOperationFormValues: OperationFormValues = {
name: "crm_create_lead",
displayName: "Create Lead",
baseUrl: "https://api.example.com",
method: "POST",
pathTemplate: "/v1/leads",
toolTitle: "Create CRM lead",
toolDescription: "Creates a CRM lead from MCP input fields.",
inputSchemaText: JSON.stringify(
{
type: "object",
required: true,
nullable: false,
fields: {
email: {
type: "string",
required: true,
nullable: false,
},
},
},
null,
2,
),
outputSchemaText: JSON.stringify(
{
type: "object",
required: true,
nullable: false,
fields: {
id: {
type: "string",
required: true,
nullable: false,
},
},
},
null,
2,
),
inputMappingText: JSON.stringify(
{
rules: [
{
source: "$.mcp.email",
target: "$.request.body.email",
required: true,
},
],
},
null,
2,
),
outputMappingText: JSON.stringify(
{
rules: [
{
source: "$.response.body.id",
target: "$.output.id",
required: true,
},
],
},
null,
2,
),
executionConfigText: JSON.stringify(
{
timeout_ms: 10000,
headers: {},
},
null,
2,
),
};
export function buildOperationPayload(rawValues: OperationFormValues) {
const values = operationFormSchema.parse(rawValues);
return {
name: values.name,
display_name: values.displayName,
protocol: "rest",
target: {
kind: "rest",
base_url: values.baseUrl,
method: values.method,
path_template: values.pathTemplate,
static_headers: {},
},
input_schema: safeParseJson(values.inputSchemaText, "Input schema"),
output_schema: safeParseJson(values.outputSchemaText, "Output schema"),
input_mapping: safeParseJson(values.inputMappingText, "Input mapping"),
output_mapping: safeParseJson(values.outputMappingText, "Output mapping"),
execution_config: safeParseJson(
values.executionConfigText,
"Execution config",
),
tool_description: {
title: values.toolTitle,
description: values.toolDescription,
tags: ["rest"],
examples: [],
},
};
}
@@ -0,0 +1,135 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation } from "@tanstack/react-query";
import { useMemo } from "react";
import { useForm } from "react-hook-form";
import { useNavigate } from "react-router-dom";
import { z } from "zod";
import { createOperation } from "../../entities/operation/api";
import { ApiError } from "../../shared/api/client";
import {
buildOperationPayload,
defaultOperationFormValues,
type OperationFormValues,
} from "./model";
const formResolverSchema = z.object({
name: z.string().min(3),
displayName: z.string().min(3),
baseUrl: z.string().url(),
method: z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
pathTemplate: z.string().min(1),
toolTitle: z.string().min(3),
toolDescription: z.string().min(8),
inputSchemaText: z.string().min(2),
outputSchemaText: z.string().min(2),
inputMappingText: z.string().min(2),
outputMappingText: z.string().min(2),
executionConfigText: z.string().min(2),
});
export function OperationForm() {
const navigate = useNavigate();
const form = useForm<OperationFormValues>({
defaultValues: defaultOperationFormValues,
resolver: zodResolver(formResolverSchema),
});
const creationMutation = useMutation({
mutationFn: async (values: OperationFormValues) =>
createOperation(buildOperationPayload(values)),
onSuccess: (data) => {
navigate(`/operations/${data.operation_id}`);
},
});
type OperationFieldName = keyof OperationFormValues;
const fieldGroups = useMemo<OperationFieldName[][]>(
() => [
["name", "displayName", "baseUrl", "method", "pathTemplate"],
["toolTitle", "toolDescription"],
["inputSchemaText", "outputSchemaText"],
["inputMappingText", "outputMappingText", "executionConfigText"],
],
[],
);
return (
<form
className="stack-layout"
onSubmit={form.handleSubmit((values) => {
creationMutation.mutate(values);
})}
>
{fieldGroups.map((fieldGroup, index) => (
<div className="form-grid" key={index}>
{fieldGroup.map((fieldName) => {
const fieldError = form.formState.errors[fieldName];
const isTextarea = fieldName.endsWith("Text") || fieldName === "toolDescription";
const label = fieldName
.replace(/([A-Z])/g, " $1")
.replace(/^./, (character) => character.toUpperCase());
if (fieldName === "method") {
return (
<label className="field-block" key={fieldName}>
<span>{label}</span>
<select {...form.register("method")}>
<option value="GET">GET</option>
<option value="POST">POST</option>
<option value="PUT">PUT</option>
<option value="PATCH">PATCH</option>
<option value="DELETE">DELETE</option>
</select>
{fieldError ? (
<small className="field-error">{fieldError.message}</small>
) : null}
</label>
);
}
if (isTextarea) {
return (
<label className="field-block field-block-wide" key={fieldName}>
<span>{label}</span>
<textarea
rows={fieldName === "toolDescription" ? 4 : 12}
{...form.register(fieldName)}
/>
{fieldError ? (
<small className="field-error">{fieldError.message}</small>
) : null}
</label>
);
}
return (
<label className="field-block" key={fieldName}>
<span>{label}</span>
<input type="text" {...form.register(fieldName)} />
{fieldError ? (
<small className="field-error">{fieldError.message}</small>
) : null}
</label>
);
})}
</div>
))}
{creationMutation.error ? (
<div className="feedback-card feedback-error">
{creationMutation.error instanceof ApiError
? creationMutation.error.message
: "Failed to create operation"}
</div>
) : null}
<div className="button-row">
<button className="button-primary" type="submit" disabled={creationMutation.isPending}>
{creationMutation.isPending ? "Creating..." : "Create operation"}
</button>
</div>
</form>
);
}
@@ -0,0 +1,53 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { publishOperation } from "../../entities/operation/api";
import { ApiError } from "../../shared/api/client";
type PublishOperationPanelProps = {
operationId: string;
version: number;
};
export function PublishOperationPanel({
operationId,
version,
}: PublishOperationPanelProps) {
const queryClient = useQueryClient();
const publishMutation = useMutation({
mutationFn: async () => publishOperation(operationId, version),
onSuccess: async () => {
await queryClient.invalidateQueries({ queryKey: ["operations"] });
await queryClient.invalidateQueries({
queryKey: ["operation-summary", operationId],
});
await queryClient.invalidateQueries({
queryKey: ["operation-version", operationId, version],
});
},
});
return (
<div className="stack-layout">
<button
className="button-primary"
type="button"
onClick={() => publishMutation.mutate()}
>
{publishMutation.isPending ? "Publishing..." : "Publish operation"}
</button>
{publishMutation.data ? (
<div className="feedback-card feedback-success">
Published version {String(publishMutation.data.published_version)} at{" "}
{String(publishMutation.data.published_at)}
</div>
) : null}
{publishMutation.error ? (
<div className="feedback-card feedback-error">
{publishMutation.error instanceof ApiError
? publishMutation.error.message
: "Publish failed"}
</div>
) : null}
</div>
);
}
@@ -0,0 +1,104 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useState } from "react";
import {
uploadInputJsonSample,
uploadOutputJsonSample,
} from "../../entities/operation/api";
import { ApiError } from "../../shared/api/client";
import { safeParseJson } from "../../shared/lib/json";
type SampleUploadPanelProps = {
operationId: string;
};
export function SampleUploadPanel({ operationId }: SampleUploadPanelProps) {
const queryClient = useQueryClient();
const [inputSampleText, setInputSampleText] = useState(
JSON.stringify({ email: "user@example.com", name: "Ada" }, null, 2),
);
const [outputSampleText, setOutputSampleText] = useState(
JSON.stringify({ id: "lead_123", status: "created" }, null, 2),
);
const createUploadMutation = (
mutationKey: "input" | "output",
submitFn: (payload: unknown) => Promise<unknown>,
) =>
useMutation({
mutationKey: ["sample-upload", operationId, mutationKey],
mutationFn: submitFn,
onSuccess: async () => {
await queryClient.invalidateQueries({
queryKey: ["operation-version", operationId],
});
},
});
const inputMutation = createUploadMutation("input", async () =>
uploadInputJsonSample(
operationId,
safeParseJson(inputSampleText, "Input sample"),
),
);
const outputMutation = createUploadMutation("output", async () =>
uploadOutputJsonSample(
operationId,
safeParseJson(outputSampleText, "Output sample"),
),
);
return (
<div className="split-layout">
<div className="stack-layout">
<label className="field-block">
<span>Input sample</span>
<textarea
rows={10}
value={inputSampleText}
onChange={(event) => setInputSampleText(event.target.value)}
/>
</label>
<button
className="button-secondary"
onClick={() => inputMutation.mutate(undefined)}
type="button"
>
{inputMutation.isPending ? "Uploading..." : "Upload input sample"}
</button>
{inputMutation.error ? (
<small className="field-error">
{inputMutation.error instanceof ApiError
? inputMutation.error.message
: "Failed to upload input sample"}
</small>
) : null}
</div>
<div className="stack-layout">
<label className="field-block">
<span>Output sample</span>
<textarea
rows={10}
value={outputSampleText}
onChange={(event) => setOutputSampleText(event.target.value)}
/>
</label>
<button
className="button-secondary"
onClick={() => outputMutation.mutate(undefined)}
type="button"
>
{outputMutation.isPending ? "Uploading..." : "Upload output sample"}
</button>
{outputMutation.error ? (
<small className="field-error">
{outputMutation.error instanceof ApiError
? outputMutation.error.message
: "Failed to upload output sample"}
</small>
) : null}
</div>
</div>
);
}
@@ -0,0 +1,40 @@
import type { DraftGenerationResult, OperationRecord } from "../../entities/operation/types";
type SchemaViewerPanelProps = {
operationRecord?: OperationRecord;
draftResult?: DraftGenerationResult;
};
export function SchemaViewerPanel({
operationRecord,
draftResult,
}: SchemaViewerPanelProps) {
const inputSchema = draftResult?.input_schema ?? operationRecord?.snapshot.input_schema;
const outputSchema =
draftResult?.output_schema ?? operationRecord?.snapshot.output_schema;
const inputMapping =
draftResult?.input_mapping ?? operationRecord?.snapshot.input_mapping;
const outputMapping =
draftResult?.output_mapping ?? operationRecord?.snapshot.output_mapping;
return (
<div className="result-grid">
<div className="result-panel">
<h3>Input schema</h3>
<pre>{JSON.stringify(inputSchema, null, 2)}</pre>
</div>
<div className="result-panel">
<h3>Output schema</h3>
<pre>{JSON.stringify(outputSchema, null, 2)}</pre>
</div>
<div className="result-panel">
<h3>Input mapping</h3>
<pre>{JSON.stringify(inputMapping, null, 2)}</pre>
</div>
<div className="result-panel">
<h3>Output mapping</h3>
<pre>{JSON.stringify(outputMapping, null, 2)}</pre>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { useMutation } from "@tanstack/react-query";
import { useState } from "react";
import { runOperationTest } from "../../entities/operation/api";
import { ApiError } from "../../shared/api/client";
import { safeParseJson } from "../../shared/lib/json";
type TestRunPanelProps = {
operationId: string;
version: number;
};
export function TestRunPanel({ operationId, version }: TestRunPanelProps) {
const [inputText, setInputText] = useState(
JSON.stringify({ email: "user@example.com" }, null, 2),
);
const testMutation = useMutation({
mutationFn: async () =>
runOperationTest(
operationId,
version,
safeParseJson(inputText, "Test input"),
),
});
return (
<div className="stack-layout">
<label className="field-block">
<span>Test input</span>
<textarea
rows={10}
value={inputText}
onChange={(event) => setInputText(event.target.value)}
/>
</label>
<button
className="button-primary"
type="button"
onClick={() => testMutation.mutate()}
>
{testMutation.isPending ? "Running..." : "Run test"}
</button>
{testMutation.error ? (
<div className="feedback-card feedback-error">
{testMutation.error instanceof ApiError
? testMutation.error.message
: "Test run failed"}
</div>
) : null}
{testMutation.data ? (
<div className="result-grid">
<div className="result-panel">
<h3>Request preview</h3>
<pre>{JSON.stringify(testMutation.data.request_preview, null, 2)}</pre>
</div>
<div className="result-panel">
<h3>Response preview</h3>
<pre>{JSON.stringify(testMutation.data.response_preview, null, 2)}</pre>
</div>
</div>
) : null}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
import React from "react";
import ReactDOM from "react-dom/client";
import { AppProviders } from "./app/providers";
import { AppRouter } from "./app/router";
import "./styles.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<AppProviders>
<AppRouter />
</AppProviders>
</React.StrictMode>,
);
@@ -0,0 +1,15 @@
import { PageSection } from "../../shared/ui/page-section";
import { OperationForm } from "../../features/operation-form/operation-form";
export function OperationCreatePage() {
return (
<div className="page-stack">
<PageSection
title="Create Operation"
subtitle="REST-first creation flow for the first production UI slice. GraphQL and gRPC will reuse the same operator surface later."
>
<OperationForm />
</PageSection>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
exportOperationYaml,
generateDraft,
getOperation,
getOperationVersion,
listAuthProfiles,
importOperationYaml,
} from "../../entities/operation/api";
import { PublishOperationPanel } from "../../features/publish-operation/panel";
import { SampleUploadPanel } from "../../features/sample-upload/panel";
import { SchemaViewerPanel } from "../../features/schema-viewer/panel";
import { TestRunPanel } from "../../features/test-run/panel";
import { PageSection } from "../../shared/ui/page-section";
export function OperationDetailPage() {
const { operationId = "" } = useParams();
const summaryQuery = useQuery({
queryKey: ["operation-summary", operationId],
queryFn: () => getOperation(operationId),
enabled: operationId.length > 0,
});
const versionQuery = useQuery({
queryKey: [
"operation-version",
operationId,
summaryQuery.data?.current_draft_version,
],
queryFn: () =>
getOperationVersion(operationId, summaryQuery.data!.current_draft_version),
enabled:
operationId.length > 0 &&
summaryQuery.data !== undefined &&
summaryQuery.data.current_draft_version > 0,
});
const authProfilesQuery = useQuery({
queryKey: ["auth-profiles"],
queryFn: listAuthProfiles,
});
const draftMutation = useMutation({
mutationFn: async () => generateDraft(operationId),
});
const exportMutation = useMutation({
mutationFn: async () =>
exportOperationYaml(operationId, summaryQuery.data?.current_draft_version),
});
const importMutation = useMutation({
mutationFn: async (yamlText: string) => importOperationYaml(yamlText, "upsert"),
});
const currentVersion = summaryQuery.data?.current_draft_version ?? 0;
const exportedYaml = exportMutation.data;
return (
<div className="page-stack">
<PageSection
title={summaryQuery.data?.display_name ?? "Operation"}
subtitle={
summaryQuery.data
? `${summaryQuery.data.name} · ${summaryQuery.data.protocol} · draft v${currentVersion}`
: "Loading operation metadata..."
}
>
{summaryQuery.data ? (
<div className="summary-grid">
<div className="summary-card">
<span>Status</span>
<strong>{summaryQuery.data.status}</strong>
</div>
<div className="summary-card">
<span>Published version</span>
<strong>{summaryQuery.data.latest_published_version ?? "none"}</strong>
</div>
<div className="summary-card">
<span>Updated</span>
<strong>{new Date(summaryQuery.data.updated_at).toLocaleString()}</strong>
</div>
</div>
) : null}
</PageSection>
<PageSection
title="Samples And Draft"
subtitle="Upload input/output samples first, then generate a draft schema and mapping set."
actions={
<button
className="button-primary"
onClick={() => draftMutation.mutate()}
type="button"
>
{draftMutation.isPending ? "Generating..." : "Generate draft"}
</button>
}
>
<SampleUploadPanel operationId={operationId} />
<SchemaViewerPanel
operationRecord={versionQuery.data}
draftResult={draftMutation.data}
/>
</PageSection>
<PageSection
title="Test Run"
subtitle="Run the current draft version through the backend runtime before publishing."
>
<TestRunPanel operationId={operationId} version={currentVersion} />
</PageSection>
<PageSection
title="Publish"
subtitle="Move the current draft version into the published tool set exposed by MCP."
>
<PublishOperationPanel operationId={operationId} version={currentVersion} />
</PageSection>
<PageSection
title="YAML Export And Reimport"
subtitle="Export the current version, inspect the YAML, then upsert it back through the same backend contract."
actions={
<button
className="button-secondary"
type="button"
onClick={() => exportMutation.mutate()}
>
{exportMutation.isPending ? "Exporting..." : "Export YAML"}
</button>
}
>
<div className="stack-layout">
{exportedYaml ? (
<>
<pre className="yaml-panel">{exportedYaml}</pre>
<div className="button-row">
<button
className="button-primary"
type="button"
onClick={() => importMutation.mutate(exportedYaml)}
>
{importMutation.isPending ? "Importing..." : "Upsert from YAML"}
</button>
</div>
</>
) : (
<p>Export YAML to inspect the current operation snapshot.</p>
)}
{importMutation.data ? (
<div className="feedback-card feedback-success">
Imported version {String(importMutation.data.version)} in{" "}
{String(importMutation.data.import_mode)} mode.
</div>
) : null}
</div>
</PageSection>
<PageSection
title="Auth Profiles"
subtitle="Currently available auth profiles exposed by the admin backend."
>
<div className="operation-grid">
{(authProfilesQuery.data?.items ?? []).map((profile) => (
<div className="operation-card" key={profile.id}>
<div className="operation-card-top">
<span className="protocol-pill protocol-rest">{profile.kind}</span>
</div>
<h3>{profile.name}</h3>
<p className="operation-name">{profile.id}</p>
<pre>{JSON.stringify(profile.config, null, 2)}</pre>
</div>
))}
</div>
</PageSection>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useDeferredValue, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { listOperations } from "../../entities/operation/api";
import { PageSection } from "../../shared/ui/page-section";
export function OperationListPage() {
const [searchTerm, setSearchTerm] = useState("");
const deferredSearchTerm = useDeferredValue(searchTerm);
const operationsQuery = useQuery({
queryKey: ["operations"],
queryFn: listOperations,
});
const filteredItems = useMemo(() => {
const items = operationsQuery.data?.items ?? [];
const normalizedSearchTerm = deferredSearchTerm.trim().toLowerCase();
if (!normalizedSearchTerm) {
return items;
}
return items.filter((item) =>
`${item.name} ${item.display_name} ${item.protocol} ${item.status}`
.toLowerCase()
.includes(normalizedSearchTerm),
);
}, [deferredSearchTerm, operationsQuery.data?.items]);
return (
<div className="page-stack">
<PageSection
title="Operations"
subtitle="Browse draft and published MCP tools exposed through the admin backend."
actions={
<Link className="button-primary" to="/operations/new">
New operation
</Link>
}
>
<label className="field-block field-inline">
<span>Search</span>
<input
type="text"
placeholder="name, protocol or status"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</label>
{operationsQuery.isLoading ? <p>Loading operations...</p> : null}
{operationsQuery.data ? (
<div className="operation-grid">
{filteredItems.map((item) => (
<Link className="operation-card" key={item.id} to={`/operations/${item.id}`}>
<div className="operation-card-top">
<span className={`protocol-pill protocol-${item.protocol}`}>
{item.protocol}
</span>
<span className={`status-pill status-${item.status}`}>
{item.status}
</span>
</div>
<h3>{item.display_name}</h3>
<p className="operation-name">{item.name}</p>
<dl className="key-value-list">
<div>
<dt>Draft</dt>
<dd>{item.current_draft_version}</dd>
</div>
<div>
<dt>Published</dt>
<dd>{item.latest_published_version ?? "none"}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{new Date(item.updated_at).toLocaleString()}</dd>
</div>
</dl>
</Link>
))}
</div>
) : null}
</PageSection>
</div>
);
}
+79
View File
@@ -0,0 +1,79 @@
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = "ApiError";
this.status = status;
}
}
type RequestInitWithJson = RequestInit & {
json?: unknown;
};
async function request<TResponse>(
input: string,
init: RequestInitWithJson = {},
): Promise<TResponse> {
const headers = new Headers(init.headers);
const body =
init.json === undefined ? init.body : JSON.stringify(init.json, null, 2);
if (init.json !== undefined) {
headers.set("Content-Type", "application/json");
}
const response = await fetch(input, {
...init,
headers,
body,
});
if (!response.ok) {
let message = `${response.status} ${response.statusText}`;
try {
const errorPayload = (await response.json()) as {
error?: { message?: string };
};
message = errorPayload.error?.message ?? message;
} catch {
message = `${response.status} ${response.statusText}`;
}
throw new ApiError(message, response.status);
}
const contentType = response.headers.get("Content-Type") ?? "";
if (contentType.includes("application/yaml")) {
return (await response.text()) as TResponse;
}
return (await response.json()) as TResponse;
}
export function getJson<TResponse>(input: string) {
return request<TResponse>(input);
}
export function getText(input: string) {
return request<string>(input);
}
export function postJson<TResponse>(input: string, json?: unknown) {
return request<TResponse>(input, {
method: "POST",
json,
});
}
export function postText<TResponse>(input: string, text: string) {
return request<TResponse>(input, {
method: "POST",
headers: {
"Content-Type": "application/yaml",
},
body: text,
});
}
+13
View File
@@ -0,0 +1,13 @@
export function safeParseJson<TValue>(raw: string, fieldName: string): TValue {
try {
return JSON.parse(raw) as TValue;
} catch (error) {
throw new Error(
`${fieldName} contains invalid JSON: ${error instanceof Error ? error.message : "unknown error"}`,
);
}
}
export function prettyJson(value: unknown): string {
return JSON.stringify(value, null, 2);
}
+42
View File
@@ -0,0 +1,42 @@
import { NavLink } from "react-router-dom";
import { ReactNode } from "react";
type AppShellProps = {
children: ReactNode;
};
export function AppShell({ children }: AppShellProps) {
return (
<div className="layout-shell">
<aside className="layout-sidebar">
<div className="brand-block">
<p className="eyebrow">RMCP Console</p>
<h1>Operator Surface</h1>
<p className="brand-copy">
REST-first operator console for MCP tool onboarding, sample-based
draft generation and publish flow.
</p>
</div>
<nav className="sidebar-nav">
<NavLink
to="/operations"
className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
Operations
</NavLink>
<NavLink
to="/operations/new"
className={({ isActive }) =>
isActive ? "nav-link nav-link-active" : "nav-link"
}
>
New Operation
</NavLink>
</nav>
</aside>
<main className="layout-main">{children}</main>
</div>
);
}
+28
View File
@@ -0,0 +1,28 @@
import { ReactNode } from "react";
type PageSectionProps = {
title: string;
subtitle?: string;
actions?: ReactNode;
children: ReactNode;
};
export function PageSection({
title,
subtitle,
actions,
children,
}: PageSectionProps) {
return (
<section className="page-section">
<header className="section-header">
<div>
<h2>{title}</h2>
{subtitle ? <p>{subtitle}</p> : null}
</div>
{actions ? <div className="section-actions">{actions}</div> : null}
</header>
{children}
</section>
);
}
+428
View File
@@ -0,0 +1,428 @@
:root {
font-family:
"IBM Plex Sans", "Segoe UI", sans-serif;
line-height: 1.5;
font-weight: 400;
color: #1e1a17;
background:
radial-gradient(circle at top left, rgba(234, 165, 91, 0.18), transparent 28%),
radial-gradient(circle at 82% 10%, rgba(15, 117, 103, 0.18), transparent 24%),
linear-gradient(180deg, #f7f1e8 0%, #f2ece2 100%);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
textarea,
select {
font: inherit;
}
button {
cursor: pointer;
}
#root {
min-height: 100vh;
}
.layout-shell {
display: grid;
grid-template-columns: 320px 1fr;
min-height: 100vh;
}
.layout-sidebar {
padding: 32px 28px;
border-right: 1px solid rgba(30, 26, 23, 0.08);
background: rgba(255, 250, 244, 0.78);
backdrop-filter: blur(18px);
}
.layout-main {
padding: 32px;
}
.brand-block {
display: grid;
gap: 12px;
margin-bottom: 32px;
}
.brand-block h1 {
margin: 0;
font-size: clamp(2rem, 4vw, 3.2rem);
line-height: 0.96;
}
.eyebrow {
margin: 0;
font-size: 0.78rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #9a6540;
}
.brand-copy {
margin: 0;
color: #554941;
}
.sidebar-nav {
display: grid;
gap: 12px;
}
.nav-link {
padding: 14px 16px;
border-radius: 18px;
background: rgba(255, 255, 255, 0.72);
border: 1px solid rgba(30, 26, 23, 0.08);
}
.nav-link-active {
background: linear-gradient(135deg, #0f7567, #19735d);
color: white;
border-color: transparent;
box-shadow: 0 20px 38px rgba(12, 84, 73, 0.18);
}
.page-stack {
display: grid;
gap: 24px;
}
.page-section {
border-radius: 28px;
padding: 28px;
background: rgba(255, 255, 255, 0.78);
border: 1px solid rgba(30, 26, 23, 0.08);
box-shadow: 0 24px 60px rgba(45, 38, 32, 0.08);
}
.section-header {
display: flex;
align-items: start;
justify-content: space-between;
gap: 16px;
margin-bottom: 20px;
}
.section-header h2 {
margin: 0 0 6px;
font-size: 1.35rem;
}
.section-header p {
margin: 0;
color: #66574e;
max-width: 720px;
}
.section-actions {
display: flex;
align-items: center;
gap: 12px;
}
.stack-layout {
display: grid;
gap: 16px;
}
.split-layout {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 20px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.field-block {
display: grid;
gap: 8px;
}
.field-block span {
font-size: 0.92rem;
font-weight: 600;
color: #52453e;
}
.field-block input,
.field-block textarea,
.field-block select {
width: 100%;
border: 1px solid rgba(30, 26, 23, 0.14);
border-radius: 16px;
padding: 14px 16px;
background: rgba(255, 254, 252, 0.92);
color: #1e1a17;
}
.field-inline {
max-width: 420px;
margin-bottom: 18px;
}
.field-block-wide {
grid-column: 1 / -1;
}
.field-error {
color: #a1422c;
}
.button-row {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.button-primary,
.button-secondary {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 46px;
padding: 0 18px;
border-radius: 999px;
border: none;
}
.button-primary {
background: linear-gradient(135deg, #0f7567, #19735d);
color: white;
box-shadow: 0 18px 32px rgba(12, 84, 73, 0.18);
}
.button-secondary {
background: #ede1d1;
color: #2e2722;
}
.operation-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
gap: 18px;
}
.operation-card,
.summary-card,
.feedback-card,
.result-panel {
border-radius: 22px;
background: rgba(255, 252, 247, 0.9);
border: 1px solid rgba(30, 26, 23, 0.08);
}
.operation-card {
padding: 20px;
display: grid;
gap: 12px;
transition:
transform 0.16s ease,
box-shadow 0.16s ease;
}
.operation-card:hover {
transform: translateY(-2px);
box-shadow: 0 18px 40px rgba(37, 31, 28, 0.08);
}
.operation-card-top {
display: flex;
justify-content: space-between;
gap: 12px;
}
.operation-card h3 {
margin: 0;
font-size: 1.15rem;
}
.operation-name {
margin: 0;
color: #65574f;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.9rem;
}
.protocol-pill,
.status-pill {
display: inline-flex;
align-items: center;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
font-size: 0.8rem;
font-weight: 700;
text-transform: uppercase;
}
.protocol-rest {
background: rgba(15, 117, 103, 0.12);
color: #0f7567;
}
.protocol-graphql {
background: rgba(181, 92, 41, 0.12);
color: #b55c29;
}
.protocol-grpc {
background: rgba(77, 74, 140, 0.12);
color: #4d4a8c;
}
.status-draft {
background: rgba(224, 184, 67, 0.18);
color: #845d0e;
}
.status-published {
background: rgba(57, 153, 92, 0.16);
color: #1c6a3e;
}
.status-testing,
.status-archived {
background: rgba(102, 87, 78, 0.14);
color: #5a4d46;
}
.key-value-list {
display: grid;
gap: 10px;
margin: 0;
}
.key-value-list div {
display: flex;
justify-content: space-between;
gap: 12px;
}
.key-value-list dt,
.key-value-list dd {
margin: 0;
}
.summary-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 16px;
}
.summary-card {
padding: 18px;
display: grid;
gap: 6px;
}
.summary-card span {
color: #6b5d55;
font-size: 0.88rem;
}
.summary-card strong {
font-size: 1rem;
}
.feedback-card {
padding: 14px 16px;
}
.feedback-success {
border-color: rgba(28, 106, 62, 0.14);
background: rgba(243, 252, 246, 0.92);
}
.feedback-error {
border-color: rgba(161, 66, 44, 0.18);
background: rgba(255, 246, 243, 0.92);
}
.result-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.result-panel {
padding: 18px;
overflow: hidden;
}
.result-panel h3 {
margin-top: 0;
}
pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: "IBM Plex Mono", "SFMono-Regular", monospace;
font-size: 0.84rem;
line-height: 1.55;
}
.yaml-panel {
padding: 18px;
border-radius: 20px;
background: rgba(34, 30, 28, 0.96);
color: #f6efe6;
}
@media (max-width: 1080px) {
.layout-shell {
grid-template-columns: 1fr;
}
.layout-sidebar {
border-right: none;
border-bottom: 1px solid rgba(30, 26, 23, 0.08);
}
}
@media (max-width: 820px) {
.layout-main {
padding: 20px;
}
.page-section {
padding: 20px;
}
.form-grid,
.split-layout,
.result-grid {
grid-template-columns: 1fr;
}
.section-header {
flex-direction: column;
}
}
+20
View File
@@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+7
View File
@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}
+10
View File
@@ -0,0 +1,10 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"allowSyntheticDefaultImports": true
},
"include": ["vite.config.ts"]
}
+18
View File
@@ -0,0 +1,18 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
"/api/admin": {
target: "http://127.0.0.1:3001",
changeOrigin: true,
},
},
},
test: {
environment: "jsdom",
},
});
+12
View File
@@ -18,3 +18,15 @@ verify:
just clippy
just test
ui-install:
cd apps/ui && npm install
ui-build:
cd apps/ui && npm run build
ui-test:
cd apps/ui && npm run test -- --run
verify-ui:
just ui-build
just ui-test