refactor: remove legacy react ui
This commit is contained in:
@@ -65,24 +65,14 @@ jobs:
|
|||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Install Node.js
|
- name: Validate static UI files
|
||||||
uses: actions/setup-node@v6
|
run: |
|
||||||
with:
|
test -f apps/ui/Dockerfile
|
||||||
node-version: 22
|
test -f apps/ui/index.html
|
||||||
cache: npm
|
test -f apps/ui/nginx.conf
|
||||||
cache-dependency-path: apps/ui/package-lock.json
|
|
||||||
|
|
||||||
- name: Install UI dependencies
|
- name: Build UI image
|
||||||
working-directory: apps/ui
|
run: docker build -f apps/ui/Dockerfile .
|
||||||
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
|
|
||||||
|
|
||||||
deployment:
|
deployment:
|
||||||
name: Deployment Artifacts
|
name: Deployment Artifacts
|
||||||
|
|||||||
@@ -2,22 +2,21 @@
|
|||||||
|
|
||||||
## Current
|
## Current
|
||||||
|
|
||||||
### `feat/crank-rebrand`
|
### `feat/remove-legacy-ui`
|
||||||
|
|
||||||
Status: completed
|
Status: completed
|
||||||
|
|
||||||
DoD:
|
DoD:
|
||||||
|
|
||||||
- проект переименован в `Crank` в документации, UI и package metadata
|
- текущая React/Vite UI-кодовая база удалена
|
||||||
- workspace и crate package names согласованы с новым брендом
|
- `apps/ui` сохранен как статический placeholder для будущей замены
|
||||||
- `README.md` использует `Crank.png` как главное изображение
|
- compose, deploy и CI не ломаются после удаления legacy UI
|
||||||
- `origin` указывает на `git@github.com:bsodfather/crank.git`
|
|
||||||
- Rust workspace и UI build остаются зелеными
|
|
||||||
|
|
||||||
## Next
|
## Next
|
||||||
|
|
||||||
- `feat/demo-assets`
|
- `feat/alpine-ui`
|
||||||
|
|
||||||
## Backlog
|
## Backlog
|
||||||
|
|
||||||
|
- `feat/alpine-ui`
|
||||||
- `feat/demo-assets`
|
- `feat/demo-assets`
|
||||||
|
|||||||
+2
-10
@@ -1,15 +1,7 @@
|
|||||||
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
|
FROM nginx:1.27-alpine
|
||||||
|
|
||||||
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
COPY apps/ui/nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
COPY --from=build /app/dist /usr/share/nginx/html
|
COPY apps/ui/index.html /usr/share/nginx/html/index.html
|
||||||
|
COPY Crank.png /usr/share/nginx/html/Crank.png
|
||||||
|
|
||||||
EXPOSE 3000
|
EXPOSE 3000
|
||||||
|
|||||||
+84
-2
@@ -4,9 +4,91 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Crank Console</title>
|
<title>Crank Console</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
font-family: "Segoe UI", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
background: #eef3f7;
|
||||||
|
color: #162433;
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at top, rgba(0, 148, 134, 0.12), transparent 28rem),
|
||||||
|
linear-gradient(180deg, #f5f8fb 0%, #ebf1f6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
width: min(42rem, calc(100vw - 3rem));
|
||||||
|
padding: 3rem;
|
||||||
|
border-radius: 1.5rem;
|
||||||
|
background: rgba(255, 255, 255, 0.9);
|
||||||
|
border: 1px solid rgba(22, 36, 51, 0.08);
|
||||||
|
box-shadow: 0 24px 60px rgba(22, 36, 51, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
img {
|
||||||
|
width: 5rem;
|
||||||
|
height: 5rem;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
margin: 0 0 0.75rem;
|
||||||
|
font-size: clamp(2rem, 5vw, 3rem);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #516070;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 0.45rem 0.8rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #0f766e;
|
||||||
|
background: rgba(15, 118, 110, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.muted {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: #6a7b8d;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<main>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<img src="/Crank.png" alt="Crank logo" />
|
||||||
|
<div class="badge">UI replacement in progress</div>
|
||||||
|
<h1>Crank Console</h1>
|
||||||
|
<p>
|
||||||
|
The legacy React/Vite UI has been removed. A new operator console is being integrated on top of the same
|
||||||
|
deployment path.
|
||||||
|
</p>
|
||||||
|
<p class="muted">
|
||||||
|
Backend services, MCP endpoints and deployment contracts remain intact. Replace this static entrypoint with
|
||||||
|
the new Alpine.js UI when it is ready.
|
||||||
|
</p>
|
||||||
|
</main>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Generated
-3266
File diff suppressed because it is too large
Load Diff
@@ -1,33 +0,0 @@
|
|||||||
{
|
|
||||||
"name": "crank-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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,24 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import {
|
|
||||||
getJson,
|
|
||||||
getText,
|
|
||||||
postBytes,
|
|
||||||
postJson,
|
|
||||||
postText,
|
|
||||||
} from "../../shared/api/client";
|
|
||||||
import type {
|
|
||||||
AuthProfile,
|
|
||||||
DraftGenerationResult,
|
|
||||||
GrpcServiceSummary,
|
|
||||||
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");
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uploadProtoDescriptor(
|
|
||||||
operationId: string,
|
|
||||||
fileName: string,
|
|
||||||
bytes: ArrayBuffer,
|
|
||||||
) {
|
|
||||||
return postBytes<{ descriptor_id: string; version: number }>(
|
|
||||||
`/api/admin/operations/${operationId}/descriptors/proto`,
|
|
||||||
bytes,
|
|
||||||
{
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"x-file-name": fileName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function uploadDescriptorSet(
|
|
||||||
operationId: string,
|
|
||||||
fileName: string,
|
|
||||||
bytes: ArrayBuffer,
|
|
||||||
) {
|
|
||||||
return postBytes<{ descriptor_id: string; version: number }>(
|
|
||||||
`/api/admin/operations/${operationId}/descriptors/descriptor-set`,
|
|
||||||
bytes,
|
|
||||||
{
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"x-file-name": fileName,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function listGrpcServices(operationId: string, version?: number) {
|
|
||||||
const query = version === undefined ? "" : `?version=${version}`;
|
|
||||||
return getJson<{ services: GrpcServiceSummary[] }>(
|
|
||||||
`/api/admin/operations/${operationId}/grpc/services${query}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
export type Protocol = "rest" | "graphql" | "grpc";
|
|
||||||
export type OperationStatus = "draft" | "testing" | "published" | "archived";
|
|
||||||
|
|
||||||
export type RestTarget = {
|
|
||||||
kind: "rest";
|
|
||||||
base_url: string;
|
|
||||||
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
|
|
||||||
path_template: string;
|
|
||||||
static_headers?: Record<string, string>;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GraphqlTarget = {
|
|
||||||
kind: "graphql";
|
|
||||||
endpoint: string;
|
|
||||||
operation_type: "query" | "mutation";
|
|
||||||
operation_name: string;
|
|
||||||
query_template: string;
|
|
||||||
response_path: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcTarget = {
|
|
||||||
kind: "grpc";
|
|
||||||
server_addr: string;
|
|
||||||
package: string;
|
|
||||||
service: string;
|
|
||||||
method: string;
|
|
||||||
descriptor_ref: string;
|
|
||||||
descriptor_set_b64: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type OperationTarget = RestTarget | GraphqlTarget | GrpcTarget;
|
|
||||||
|
|
||||||
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: OperationTarget;
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcMethodSummary = {
|
|
||||||
name: string;
|
|
||||||
kind: string;
|
|
||||||
input_schema: unknown;
|
|
||||||
output_schema: unknown;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type GrpcServiceSummary = {
|
|
||||||
package: string;
|
|
||||||
service: string;
|
|
||||||
methods: GrpcMethodSummary[];
|
|
||||||
};
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import {
|
|
||||||
listGrpcServices,
|
|
||||||
uploadDescriptorSet,
|
|
||||||
uploadProtoDescriptor,
|
|
||||||
} from "../../entities/operation/api";
|
|
||||||
import type { GrpcServiceSummary } from "../../entities/operation/types";
|
|
||||||
import { ApiError } from "../../shared/api/client";
|
|
||||||
|
|
||||||
type GrpcDescriptorPanelProps = {
|
|
||||||
operationId: string;
|
|
||||||
version: number;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UploadKind = "proto" | "descriptor-set";
|
|
||||||
|
|
||||||
function descriptorSubtitle(service: GrpcServiceSummary) {
|
|
||||||
return service.package.length > 0
|
|
||||||
? `${service.package}.${service.service}`
|
|
||||||
: service.service;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GrpcDescriptorPanel({
|
|
||||||
operationId,
|
|
||||||
version,
|
|
||||||
}: GrpcDescriptorPanelProps) {
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
const [uploadError, setUploadError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const servicesQuery = useQuery({
|
|
||||||
queryKey: ["grpc-services", operationId, version],
|
|
||||||
queryFn: () => listGrpcServices(operationId, version),
|
|
||||||
enabled: operationId.length > 0 && version > 0,
|
|
||||||
});
|
|
||||||
|
|
||||||
const uploadMutation = useMutation({
|
|
||||||
mutationFn: async ({
|
|
||||||
file,
|
|
||||||
kind,
|
|
||||||
}: {
|
|
||||||
file: File;
|
|
||||||
kind: UploadKind;
|
|
||||||
}) => {
|
|
||||||
const bytes = await file.arrayBuffer();
|
|
||||||
if (kind === "proto") {
|
|
||||||
return uploadProtoDescriptor(operationId, file.name, bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
return uploadDescriptorSet(operationId, file.name, bytes);
|
|
||||||
},
|
|
||||||
onSuccess: async () => {
|
|
||||||
setUploadError(null);
|
|
||||||
await queryClient.invalidateQueries({
|
|
||||||
queryKey: ["grpc-services", operationId, version],
|
|
||||||
});
|
|
||||||
},
|
|
||||||
onError: (error) => {
|
|
||||||
setUploadError(
|
|
||||||
error instanceof ApiError ? error.message : "Descriptor upload failed",
|
|
||||||
);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
async function handleFileUpload(
|
|
||||||
file: File | undefined,
|
|
||||||
kind: UploadKind,
|
|
||||||
) {
|
|
||||||
if (file === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUploadError(null);
|
|
||||||
uploadMutation.mutate({ file, kind });
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="stack-layout">
|
|
||||||
<div className="upload-grid">
|
|
||||||
<label className="field-block">
|
|
||||||
<span>Upload `.proto`</span>
|
|
||||||
<small className="field-hint">
|
|
||||||
Stores the source artifact alongside the current draft version.
|
|
||||||
</small>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".proto"
|
|
||||||
onChange={(event) => {
|
|
||||||
void handleFileUpload(event.target.files?.[0], "proto");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="field-block">
|
|
||||||
<span>Upload descriptor set</span>
|
|
||||||
<small className="field-hint">
|
|
||||||
Enables unary service discovery and schema extraction for the current version.
|
|
||||||
</small>
|
|
||||||
<input
|
|
||||||
type="file"
|
|
||||||
accept=".bin,.pb,.desc"
|
|
||||||
onChange={(event) => {
|
|
||||||
void handleFileUpload(event.target.files?.[0], "descriptor-set");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{uploadMutation.isPending ? (
|
|
||||||
<div className="feedback-card">Uploading descriptor artifact...</div>
|
|
||||||
) : null}
|
|
||||||
{uploadError ? (
|
|
||||||
<div className="feedback-card feedback-error">{uploadError}</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{servicesQuery.data?.services.length ? (
|
|
||||||
<div className="service-grid">
|
|
||||||
{servicesQuery.data.services.map((service) => (
|
|
||||||
<article className="service-card" key={descriptorSubtitle(service)}>
|
|
||||||
<div className="service-card-header">
|
|
||||||
<div>
|
|
||||||
<h3>{service.service}</h3>
|
|
||||||
<p>{descriptorSubtitle(service)}</p>
|
|
||||||
</div>
|
|
||||||
<span className="status-pill status-testing">
|
|
||||||
{service.methods.length} methods
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="service-method-list">
|
|
||||||
{service.methods.map((method) => (
|
|
||||||
<div className="service-method" key={method.name}>
|
|
||||||
<div className="service-method-top">
|
|
||||||
<strong>{method.name}</strong>
|
|
||||||
<span className="protocol-pill protocol-grpc">{method.kind}</span>
|
|
||||||
</div>
|
|
||||||
<div className="result-grid">
|
|
||||||
<div className="result-panel">
|
|
||||||
<h3>Input schema</h3>
|
|
||||||
<pre>{JSON.stringify(method.input_schema, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
<div className="result-panel">
|
|
||||||
<h3>Output schema</h3>
|
|
||||||
<pre>{JSON.stringify(method.output_schema, null, 2)}</pre>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="feedback-card">
|
|
||||||
Upload a descriptor set to inspect unary services and message schemas.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
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,
|
|
||||||
restStaticHeadersText: JSON.stringify(
|
|
||||||
{
|
|
||||||
"x-app-source": "crank",
|
|
||||||
},
|
|
||||||
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": "crank",
|
|
||||||
});
|
|
||||||
expect(payload.execution_config.headers).toEqual({
|
|
||||||
"x-trace-id": "trace-123",
|
|
||||||
});
|
|
||||||
expect(payload.input_mapping).toEqual({
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.mcp.email",
|
|
||||||
target: "$.request.body.email",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a GraphQL operation payload from form values", () => {
|
|
||||||
const payload = buildOperationPayload({
|
|
||||||
...defaultOperationFormValues,
|
|
||||||
protocol: "graphql",
|
|
||||||
name: "crm_create_lead_graphql",
|
|
||||||
displayName: "Create Lead (GraphQL)",
|
|
||||||
toolTitle: "Create CRM lead through GraphQL",
|
|
||||||
inputMappingText: JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.mcp.email",
|
|
||||||
target: "$.request.variables.email",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
outputMappingText: JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.response.body.data.createLead.id",
|
|
||||||
target: "$.output.id",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload.protocol).toBe("graphql");
|
|
||||||
expect(payload.target.kind).toBe("graphql");
|
|
||||||
expect(payload.target.operation_type).toBe("mutation");
|
|
||||||
expect(payload.target.response_path).toBe("$.response.body.data.createLead");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("creates a gRPC operation payload from form values", () => {
|
|
||||||
const payload = buildOperationPayload({
|
|
||||||
...defaultOperationFormValues,
|
|
||||||
protocol: "grpc",
|
|
||||||
name: "crm_create_lead_grpc",
|
|
||||||
displayName: "Create Lead (gRPC)",
|
|
||||||
toolTitle: "Create CRM lead through gRPC",
|
|
||||||
grpcDescriptorSetB64: "ZGVzY3JpcHRvcg==",
|
|
||||||
inputMappingText: JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.mcp.email",
|
|
||||||
target: "$.request.grpc.email",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
outputMappingText: JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.response.body.id",
|
|
||||||
target: "$.output.id",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(payload.protocol).toBe("grpc");
|
|
||||||
expect(payload.target.kind).toBe("grpc");
|
|
||||||
expect(payload.target.descriptor_ref).toBe("desc_lead_service");
|
|
||||||
expect(payload.target.descriptor_set_b64).toBe("ZGVzY3JpcHRvcg==");
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws when JSON fields are invalid", () => {
|
|
||||||
expect(() =>
|
|
||||||
buildOperationPayload({
|
|
||||||
...defaultOperationFormValues,
|
|
||||||
inputSchemaText: "{invalid",
|
|
||||||
}),
|
|
||||||
).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/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,430 +0,0 @@
|
|||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { safeParseJson } from "../../shared/lib/json";
|
|
||||||
|
|
||||||
const jsonTextSchema = z.string().min(2, "JSON payload is required");
|
|
||||||
|
|
||||||
export const operationFormSchema = z
|
|
||||||
.object({
|
|
||||||
protocol: z.enum(["rest", "graphql", "grpc"]),
|
|
||||||
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"),
|
|
||||||
toolTitle: z.string().min(3, "Tool title is too short"),
|
|
||||||
toolDescription: z.string().min(8, "Tool description is too short"),
|
|
||||||
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(),
|
|
||||||
graphqlQueryTemplate: z.string(),
|
|
||||||
graphqlResponsePath: z.string(),
|
|
||||||
grpcServerAddr: z.string(),
|
|
||||||
grpcPackage: z.string(),
|
|
||||||
grpcService: z.string(),
|
|
||||||
grpcMethod: z.string(),
|
|
||||||
grpcDescriptorRef: z.string(),
|
|
||||||
grpcDescriptorSetB64: z.string(),
|
|
||||||
inputSchemaText: jsonTextSchema,
|
|
||||||
outputSchemaText: jsonTextSchema,
|
|
||||||
inputMappingText: jsonTextSchema,
|
|
||||||
outputMappingText: jsonTextSchema,
|
|
||||||
executionHeadersText: jsonTextSchema,
|
|
||||||
executionConfigText: jsonTextSchema,
|
|
||||||
})
|
|
||||||
.superRefine((values, context) => {
|
|
||||||
if (values.protocol === "rest") {
|
|
||||||
if (!z.string().url().safeParse(values.restBaseUrl).success) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["restBaseUrl"],
|
|
||||||
message: "Base URL must be valid",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.restPathTemplate.trim().length === 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["restPathTemplate"],
|
|
||||||
message: "Path is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.protocol === "graphql") {
|
|
||||||
if (!z.string().url().safeParse(values.graphqlEndpoint).success) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["graphqlEndpoint"],
|
|
||||||
message: "Endpoint must be valid",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.graphqlOperationName.trim().length < 2) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["graphqlOperationName"],
|
|
||||||
message: "Operation name is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.graphqlQueryTemplate.trim().length < 8) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["graphqlQueryTemplate"],
|
|
||||||
message: "Query template is too short",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.graphqlResponsePath.trim().length < 4) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: ["graphqlResponsePath"],
|
|
||||||
message: "Response path is required",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (values.protocol === "grpc") {
|
|
||||||
const requiredFields: Array<[keyof typeof values, string]> = [
|
|
||||||
["grpcServerAddr", "Server address is required"],
|
|
||||||
["grpcPackage", "Package is required"],
|
|
||||||
["grpcService", "Service is required"],
|
|
||||||
["grpcMethod", "Method is required"],
|
|
||||||
["grpcDescriptorRef", "Descriptor reference is required"],
|
|
||||||
["grpcDescriptorSetB64", "Descriptor set base64 is required"],
|
|
||||||
];
|
|
||||||
|
|
||||||
for (const [fieldName, message] of requiredFields) {
|
|
||||||
if (values[fieldName].trim().length === 0) {
|
|
||||||
context.addIssue({
|
|
||||||
code: "custom",
|
|
||||||
path: [fieldName],
|
|
||||||
message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
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(
|
|
||||||
{
|
|
||||||
type: "object",
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
fields: {
|
|
||||||
email: {
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultOutputSchemaText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
type: "object",
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
fields: {
|
|
||||||
id: {
|
|
||||||
type: "string",
|
|
||||||
required: true,
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRestInputMappingText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.mcp.email",
|
|
||||||
target: "$.request.body.email",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultRestOutputMappingText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.response.body.id",
|
|
||||||
target: "$.output.id",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultGraphqlInputMappingText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.mcp.email",
|
|
||||||
target: "$.request.variables.email",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultGraphqlOutputMappingText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.response.body.data.createLead.id",
|
|
||||||
target: "$.output.id",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultGrpcInputMappingText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.mcp.email",
|
|
||||||
target: "$.request.grpc.email",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultGrpcOutputMappingText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
rules: [
|
|
||||||
{
|
|
||||||
source: "$.response.body.id",
|
|
||||||
target: "$.output.id",
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function defaultExecutionConfigText() {
|
|
||||||
return JSON.stringify(
|
|
||||||
{
|
|
||||||
timeout_ms: 10000,
|
|
||||||
headers: {},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export const defaultOperationFormValues: OperationFormValues = {
|
|
||||||
protocol: "rest",
|
|
||||||
name: "crm_create_lead",
|
|
||||||
displayName: "Create Lead",
|
|
||||||
toolTitle: "Create CRM lead",
|
|
||||||
toolDescription: "Creates a CRM lead from MCP input fields.",
|
|
||||||
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",
|
|
||||||
graphqlQueryTemplate: `mutation CreateLead($email: String!) {
|
|
||||||
createLead(email: $email) {
|
|
||||||
id
|
|
||||||
status
|
|
||||||
}
|
|
||||||
}`,
|
|
||||||
graphqlResponsePath: "$.response.body.data.createLead",
|
|
||||||
grpcServerAddr: "https://grpc.example.com",
|
|
||||||
grpcPackage: "crm.v1",
|
|
||||||
grpcService: "LeadService",
|
|
||||||
grpcMethod: "CreateLead",
|
|
||||||
grpcDescriptorRef: "desc_lead_service",
|
|
||||||
grpcDescriptorSetB64: "",
|
|
||||||
inputSchemaText: defaultInputSchemaText(),
|
|
||||||
outputSchemaText: defaultOutputSchemaText(),
|
|
||||||
inputMappingText: defaultRestInputMappingText(),
|
|
||||||
outputMappingText: defaultRestOutputMappingText(),
|
|
||||||
executionHeadersText: JSON.stringify({}, null, 2),
|
|
||||||
executionConfigText: defaultExecutionConfigText(),
|
|
||||||
};
|
|
||||||
|
|
||||||
export function getProtocolPreset(
|
|
||||||
protocol: OperationFormValues["protocol"],
|
|
||||||
): Partial<OperationFormValues> {
|
|
||||||
switch (protocol) {
|
|
||||||
case "rest":
|
|
||||||
return {
|
|
||||||
name: "crm_create_lead",
|
|
||||||
displayName: "Create Lead",
|
|
||||||
toolTitle: "Create CRM lead",
|
|
||||||
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 {
|
|
||||||
name: "crm_create_lead_graphql",
|
|
||||||
displayName: "Create Lead (GraphQL)",
|
|
||||||
toolTitle: "Create CRM lead through GraphQL",
|
|
||||||
toolDescription:
|
|
||||||
"Executes a fixed GraphQL mutation and returns the selected lead payload.",
|
|
||||||
inputMappingText: defaultGraphqlInputMappingText(),
|
|
||||||
outputMappingText: defaultGraphqlOutputMappingText(),
|
|
||||||
executionHeadersText: JSON.stringify({}, null, 2),
|
|
||||||
};
|
|
||||||
case "grpc":
|
|
||||||
return {
|
|
||||||
name: "crm_create_lead_grpc",
|
|
||||||
displayName: "Create Lead (gRPC)",
|
|
||||||
toolTitle: "Create CRM lead through gRPC",
|
|
||||||
toolDescription:
|
|
||||||
"Executes a unary gRPC method using a descriptor-driven request contract.",
|
|
||||||
inputMappingText: defaultGrpcInputMappingText(),
|
|
||||||
outputMappingText: defaultGrpcOutputMappingText(),
|
|
||||||
executionHeadersText: JSON.stringify({}, null, 2),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildOperationPayload(rawValues: OperationFormValues) {
|
|
||||||
const values = operationFormSchema.parse(rawValues);
|
|
||||||
const inputSchema = safeParseJson(values.inputSchemaText, "Input schema");
|
|
||||||
const outputSchema = safeParseJson(values.outputSchemaText, "Output schema");
|
|
||||||
const inputMapping = safeParseJson(values.inputMappingText, "Input mapping");
|
|
||||||
const outputMapping = safeParseJson(values.outputMappingText, "Output mapping");
|
|
||||||
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,
|
|
||||||
display_name: values.displayName,
|
|
||||||
input_schema: inputSchema,
|
|
||||||
output_schema: outputSchema,
|
|
||||||
input_mapping: inputMapping,
|
|
||||||
output_mapping: outputMapping,
|
|
||||||
execution_config: normalizedExecutionConfig,
|
|
||||||
tool_description: {
|
|
||||||
title: values.toolTitle,
|
|
||||||
description: values.toolDescription,
|
|
||||||
tags: [values.protocol],
|
|
||||||
examples: [],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (values.protocol) {
|
|
||||||
case "rest":
|
|
||||||
return {
|
|
||||||
...basePayload,
|
|
||||||
protocol: "rest",
|
|
||||||
target: {
|
|
||||||
kind: "rest",
|
|
||||||
base_url: values.restBaseUrl,
|
|
||||||
method: values.restMethod,
|
|
||||||
path_template: values.restPathTemplate,
|
|
||||||
static_headers: parseStringMap(
|
|
||||||
values.restStaticHeadersText,
|
|
||||||
"REST static headers",
|
|
||||||
),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "graphql":
|
|
||||||
return {
|
|
||||||
...basePayload,
|
|
||||||
protocol: "graphql",
|
|
||||||
target: {
|
|
||||||
kind: "graphql",
|
|
||||||
endpoint: values.graphqlEndpoint,
|
|
||||||
operation_type: values.graphqlOperationType,
|
|
||||||
operation_name: values.graphqlOperationName,
|
|
||||||
query_template: values.graphqlQueryTemplate,
|
|
||||||
response_path: values.graphqlResponsePath,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
case "grpc":
|
|
||||||
return {
|
|
||||||
...basePayload,
|
|
||||||
protocol: "grpc",
|
|
||||||
target: {
|
|
||||||
kind: "grpc",
|
|
||||||
server_addr: values.grpcServerAddr,
|
|
||||||
package: values.grpcPackage,
|
|
||||||
service: values.grpcService,
|
|
||||||
method: values.grpcMethod,
|
|
||||||
descriptor_ref: values.grpcDescriptorRef,
|
|
||||||
descriptor_set_b64: values.grpcDescriptorSetB64,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,540 +0,0 @@
|
|||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
|
||||||
import { useMutation } from "@tanstack/react-query";
|
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
|
||||||
import { useForm } from "react-hook-form";
|
|
||||||
import { createPortal } from "react-dom";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { createOperation } from "../../entities/operation/api";
|
|
||||||
import { ApiError } from "../../shared/api/client";
|
|
||||||
import {
|
|
||||||
buildOperationPayload,
|
|
||||||
defaultOperationFormValues,
|
|
||||||
getProtocolPreset,
|
|
||||||
operationFormSchema,
|
|
||||||
type OperationFormValues,
|
|
||||||
} from "./model";
|
|
||||||
|
|
||||||
type OperationFieldName = keyof OperationFormValues;
|
|
||||||
|
|
||||||
type FieldConfig = {
|
|
||||||
name: OperationFieldName;
|
|
||||||
label: string;
|
|
||||||
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: "Description",
|
|
||||||
description: "LLM-facing description for the tool runtime contract.",
|
|
||||||
rows: 4,
|
|
||||||
wide: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const restFields: FieldConfig[] = [
|
|
||||||
{ 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", wide: true },
|
|
||||||
{ name: "graphqlOperationType", label: "Operation type" },
|
|
||||||
{ name: "graphqlOperationName", label: "Operation name" },
|
|
||||||
{
|
|
||||||
name: "graphqlResponsePath",
|
|
||||||
label: "Response path",
|
|
||||||
description: "Stable extraction root inside the response payload.",
|
|
||||||
wide: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
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", wide: true },
|
|
||||||
{ name: "grpcPackage", label: "Package" },
|
|
||||||
{ name: "grpcService", label: "Service" },
|
|
||||||
{ name: "grpcMethod", label: "Method" },
|
|
||||||
{
|
|
||||||
name: "grpcDescriptorRef",
|
|
||||||
label: "Descriptor reference",
|
|
||||||
description: "Stable descriptor identifier stored with the operation.",
|
|
||||||
wide: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "grpcDescriptorSetB64",
|
|
||||||
label: "Descriptor set base64",
|
|
||||||
description: "Compiled descriptor-set contents used for runtime invocation.",
|
|
||||||
rows: 10,
|
|
||||||
wide: true,
|
|
||||||
code: true,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const schemaFields: FieldConfig[] = [
|
|
||||||
{
|
|
||||||
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 → 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 tool maps to one HTTP method and one path template.";
|
|
||||||
case "graphql":
|
|
||||||
return "One tool maps to one fixed GraphQL query or mutation.";
|
|
||||||
case "grpc":
|
|
||||||
return "One tool maps to one unary gRPC method backed by a descriptor set.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function descriptorRefFromFileName(fileName: string) {
|
|
||||||
const normalized = fileName
|
|
||||||
.replace(/\.[^/.]+$/, "")
|
|
||||||
.replace(/[^a-zA-Z0-9]+/g, "_")
|
|
||||||
.replace(/^_+|_+$/g, "")
|
|
||||||
.toLowerCase();
|
|
||||||
|
|
||||||
return normalized.length === 0 ? "desc_uploaded" : `desc_${normalized}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function bytesToBase64(buffer: ArrayBuffer) {
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
let binary = "";
|
|
||||||
|
|
||||||
for (let index = 0; index < bytes.length; index += 0x8000) {
|
|
||||||
binary += String.fromCharCode(...bytes.subarray(index, index + 0x8000));
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.btoa(binary);
|
|
||||||
}
|
|
||||||
|
|
||||||
type OperationFormProps = {
|
|
||||||
actionBarContainerId?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function OperationForm({ actionBarContainerId }: OperationFormProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const form = useForm<OperationFormValues>({
|
|
||||||
defaultValues: defaultOperationFormValues,
|
|
||||||
resolver: zodResolver(operationFormSchema),
|
|
||||||
});
|
|
||||||
const activeProtocol = form.watch("protocol");
|
|
||||||
const previousProtocolRef = useRef(activeProtocol);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (previousProtocolRef.current === activeProtocol) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const preset = getProtocolPreset(activeProtocol);
|
|
||||||
for (const [fieldName, value] of Object.entries(preset)) {
|
|
||||||
form.setValue(fieldName as OperationFieldName, value);
|
|
||||||
}
|
|
||||||
|
|
||||||
previousProtocolRef.current = activeProtocol;
|
|
||||||
}, [activeProtocol, form]);
|
|
||||||
|
|
||||||
const creationMutation = useMutation({
|
|
||||||
mutationFn: async (values: OperationFormValues) =>
|
|
||||||
createOperation(buildOperationPayload(values)),
|
|
||||||
onSuccess: (data) => {
|
|
||||||
navigate(`/operations/${data.operation_id}`);
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const protocolFields = useMemo(() => {
|
|
||||||
switch (activeProtocol) {
|
|
||||||
case "rest":
|
|
||||||
return restFields;
|
|
||||||
case "graphql":
|
|
||||||
return graphqlFields;
|
|
||||||
case "grpc":
|
|
||||||
return grpcFields;
|
|
||||||
}
|
|
||||||
}, [activeProtocol]);
|
|
||||||
|
|
||||||
const actionBarContainer =
|
|
||||||
actionBarContainerId === undefined ? null : document.getElementById(actionBarContainerId);
|
|
||||||
|
|
||||||
async function handleDescriptorFileChange(file: File | undefined) {
|
|
||||||
if (file === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const encoded = bytesToBase64(await file.arrayBuffer());
|
|
||||||
form.setValue("grpcDescriptorSetB64", encoded, {
|
|
||||||
shouldDirty: true,
|
|
||||||
shouldValidate: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (form.getValues("grpcDescriptorRef").trim().length === 0) {
|
|
||||||
form.setValue("grpcDescriptorRef", descriptorRefFromFileName(file.name), {
|
|
||||||
shouldDirty: true,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function fieldError(name: OperationFieldName) {
|
|
||||||
const error = form.formState.errors[name];
|
|
||||||
return typeof error?.message === "string" ? error.message : undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderSelect(field: FieldConfig) {
|
|
||||||
const message = fieldError(field.name);
|
|
||||||
|
|
||||||
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 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<form
|
|
||||||
className="builder-stack"
|
|
||||||
id="operation-create-form"
|
|
||||||
onSubmit={form.handleSubmit((values) => {
|
|
||||||
creationMutation.mutate(values);
|
|
||||||
})}
|
|
||||||
>
|
|
||||||
<div className="builder-content">
|
|
||||||
<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>
|
|
||||||
</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>
|
|
||||||
</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">
|
|
||||||
{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="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>
|
|
||||||
</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">
|
|
||||||
{creationMutation.error instanceof ApiError
|
|
||||||
? creationMutation.error.message
|
|
||||||
: "Failed to create operation"}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
{actionBarContainer
|
|
||||||
? createPortal(
|
|
||||||
<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"
|
|
||||||
form="operation-create-form"
|
|
||||||
disabled={creationMutation.isPending}
|
|
||||||
>
|
|
||||||
{creationMutation.isPending ? "Creating..." : "Create operation"}
|
|
||||||
</button>
|
|
||||||
</div>,
|
|
||||||
actionBarContainer,
|
|
||||||
)
|
|
||||||
: null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,104 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,40 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +0,0 @@
|
|||||||
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>,
|
|
||||||
);
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
import { OperationForm } from "../../features/operation-form/operation-form";
|
|
||||||
|
|
||||||
export function OperationCreatePage() {
|
|
||||||
return <OperationForm actionBarContainerId="app-layout-main" />;
|
|
||||||
}
|
|
||||||
@@ -1,290 +0,0 @@
|
|||||||
import { useMutation, useQuery } from "@tanstack/react-query";
|
|
||||||
import { useParams } from "react-router-dom";
|
|
||||||
|
|
||||||
import {
|
|
||||||
exportOperationYaml,
|
|
||||||
generateDraft,
|
|
||||||
getOperation,
|
|
||||||
getOperationVersion,
|
|
||||||
importOperationYaml,
|
|
||||||
listAuthProfiles,
|
|
||||||
} from "../../entities/operation/api";
|
|
||||||
import type { OperationTarget } from "../../entities/operation/types";
|
|
||||||
import { GrpcDescriptorPanel } from "../../features/grpc-descriptor/panel";
|
|
||||||
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";
|
|
||||||
|
|
||||||
function describeTarget(target: OperationTarget) {
|
|
||||||
switch (target.kind) {
|
|
||||||
case "rest":
|
|
||||||
return [
|
|
||||||
["Base URL", target.base_url],
|
|
||||||
["Method", target.method],
|
|
||||||
["Path", target.path_template],
|
|
||||||
] as const;
|
|
||||||
case "graphql":
|
|
||||||
return [
|
|
||||||
["Endpoint", target.endpoint],
|
|
||||||
["Operation", `${target.operation_type} ${target.operation_name}`],
|
|
||||||
["Response path", target.response_path],
|
|
||||||
] as const;
|
|
||||||
case "grpc":
|
|
||||||
return [
|
|
||||||
["Server", target.server_addr],
|
|
||||||
["Service", `${target.package}.${target.service}/${target.method}`],
|
|
||||||
["Descriptor ref", target.descriptor_ref],
|
|
||||||
] as const;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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":
|
|
||||||
return "Upload request and response JSON samples, then infer schema and mapping drafts.";
|
|
||||||
case "graphql":
|
|
||||||
return "Upload stable request and response samples for the fixed GraphQL operation contract.";
|
|
||||||
case "grpc":
|
|
||||||
return "Upload JSON samples or inspect descriptor-driven schemas before refining mappings.";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function OperationDetailPage() {
|
|
||||||
const { operationId = "" } = useParams();
|
|
||||||
const summaryQuery = useQuery({
|
|
||||||
queryKey: ["operation-summary", operationId],
|
|
||||||
queryFn: () => getOperation(operationId),
|
|
||||||
enabled: operationId.length > 0,
|
|
||||||
});
|
|
||||||
const currentVersion = summaryQuery.data?.current_draft_version ?? 0;
|
|
||||||
const versionQuery = useQuery({
|
|
||||||
queryKey: ["operation-version", operationId, currentVersion],
|
|
||||||
queryFn: () => getOperationVersion(operationId, currentVersion),
|
|
||||||
enabled: operationId.length > 0 && currentVersion > 0,
|
|
||||||
});
|
|
||||||
const authProfilesQuery = useQuery({
|
|
||||||
queryKey: ["auth-profiles"],
|
|
||||||
queryFn: listAuthProfiles,
|
|
||||||
});
|
|
||||||
const draftMutation = useMutation({
|
|
||||||
mutationFn: async () => generateDraft(operationId),
|
|
||||||
});
|
|
||||||
const exportMutation = useMutation({
|
|
||||||
mutationFn: async () => exportOperationYaml(operationId, currentVersion),
|
|
||||||
});
|
|
||||||
const importMutation = useMutation({
|
|
||||||
mutationFn: async (yamlText: string) => importOperationYaml(yamlText, "upsert"),
|
|
||||||
});
|
|
||||||
|
|
||||||
const snapshot = versionQuery.data?.snapshot;
|
|
||||||
const exportedYaml = exportMutation.data;
|
|
||||||
const targetRows = snapshot ? describeTarget(snapshot.target) : [];
|
|
||||||
|
|
||||||
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="detail-hero">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
{snapshot ? (
|
|
||||||
<div className="target-grid">
|
|
||||||
{targetRows.map(([label, value]) => (
|
|
||||||
<div className="target-card" key={label}>
|
|
||||||
<span>{label}</span>
|
|
||||||
<strong>{value}</strong>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PageSection>
|
|
||||||
|
|
||||||
{snapshot ? (
|
|
||||||
<PageSection
|
|
||||||
title="Target Contract"
|
|
||||||
subtitle="The runtime transport details below are the fixed contract exposed as one MCP tool."
|
|
||||||
>
|
|
||||||
<div className="metadata-grid">
|
|
||||||
<div className="result-panel">
|
|
||||||
<h3>Tool description</h3>
|
|
||||||
<dl className="key-value-list">
|
|
||||||
<div>
|
|
||||||
<dt>Title</dt>
|
|
||||||
<dd>{snapshot.tool_description.title}</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt>Tags</dt>
|
|
||||||
<dd>{snapshot.tool_description.tags.join(", ") || "none"}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<p className="body-copy">{snapshot.tool_description.description}</p>
|
|
||||||
</div>
|
|
||||||
<div className="result-panel">
|
|
||||||
<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}
|
|
||||||
|
|
||||||
{snapshot?.target.kind === "grpc" ? (
|
|
||||||
<PageSection
|
|
||||||
title="gRPC Discovery"
|
|
||||||
subtitle="Descriptor artifacts and discovered unary methods for the current draft version."
|
|
||||||
>
|
|
||||||
<GrpcDescriptorPanel operationId={operationId} version={currentVersion} />
|
|
||||||
</PageSection>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<PageSection
|
|
||||||
title="Samples And Draft"
|
|
||||||
subtitle={snapshot ? draftSubtitle(snapshot.target) : "Prepare schema and mapping drafts."}
|
|
||||||
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>
|
|
||||||
|
|
||||||
<div className="detail-columns">
|
|
||||||
<PageSection
|
|
||||||
title="Test Run"
|
|
||||||
subtitle="Run the current draft version through the runtime before publishing."
|
|
||||||
>
|
|
||||||
<TestRunPanel operationId={operationId} version={currentVersion} />
|
|
||||||
</PageSection>
|
|
||||||
|
|
||||||
<PageSection
|
|
||||||
title="Publish"
|
|
||||||
subtitle="Promote the current draft version into the active MCP tool catalog."
|
|
||||||
>
|
|
||||||
<PublishOperationPanel operationId={operationId} version={currentVersion} />
|
|
||||||
</PageSection>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="detail-columns">
|
|
||||||
<PageSection
|
|
||||||
title="YAML Export And Reimport"
|
|
||||||
subtitle="Export the current version, inspect the YAML, then upsert it 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 className="body-copy">
|
|
||||||
Export YAML to inspect the current operation snapshot and transport contract.
|
|
||||||
</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="Profiles currently available to the admin backend and execution config."
|
|
||||||
>
|
|
||||||
<div className="operation-grid">
|
|
||||||
{(authProfilesQuery.data?.items ?? []).map((profile) => (
|
|
||||||
<div className="operation-card" key={profile.id}>
|
|
||||||
<div className="operation-card-top">
|
|
||||||
<span className="status-pill status-testing">{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>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,160 +0,0 @@
|
|||||||
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]);
|
|
||||||
|
|
||||||
const metrics = useMemo(() => {
|
|
||||||
const items = operationsQuery.data?.items ?? [];
|
|
||||||
|
|
||||||
return {
|
|
||||||
total: items.length,
|
|
||||||
published: items.filter((item) => item.status === "published").length,
|
|
||||||
drafts: items.filter((item) => item.status === "draft").length,
|
|
||||||
protocols: Array.from(new Set(items.map((item) => item.protocol))).length,
|
|
||||||
};
|
|
||||||
}, [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>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<div className="hero-grid">
|
|
||||||
<div className="hero-card hero-card-accent">
|
|
||||||
<p className="eyebrow">Catalog health</p>
|
|
||||||
<h3>{metrics.total} tools registered</h3>
|
|
||||||
<p className="body-copy">
|
|
||||||
Published and draft operations share one operator surface and one
|
|
||||||
MCP publication path.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="hero-card">
|
|
||||||
<div className="hero-metrics">
|
|
||||||
<div>
|
|
||||||
<span>Published</span>
|
|
||||||
<strong>{metrics.published}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Drafts</span>
|
|
||||||
<strong>{metrics.drafts}</strong>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span>Protocols</span>
|
|
||||||
<strong>{metrics.protocols}</strong>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="toolbar-row">
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<div className="toolbar-meta">
|
|
||||||
<span className="workspace-badge">{filteredItems.length} visible</span>
|
|
||||||
<span className="workspace-badge">
|
|
||||||
{operationsQuery.data?.items.length ?? 0} total
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{operationsQuery.isLoading ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>Loading operations</h3>
|
|
||||||
<p>Fetching the current registry snapshot from the admin backend.</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{operationsQuery.data && filteredItems.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<h3>No matching operations</h3>
|
|
||||||
<p>
|
|
||||||
Adjust the search term or create a new contract for REST, GraphQL
|
|
||||||
or unary gRPC.
|
|
||||||
</p>
|
|
||||||
<Link className="button-secondary" to="/operations/new">
|
|
||||||
Create operation
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{operationsQuery.data && filteredItems.length > 0 ? (
|
|
||||||
<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>
|
|
||||||
<div className="operation-card-header">
|
|
||||||
<h3>{item.display_name}</h3>
|
|
||||||
<p className="operation-name">{item.name}</p>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
<div className="card-link-row">
|
|
||||||
<span>Open workspace</span>
|
|
||||||
<strong>→</strong>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</PageSection>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function postBytes<TResponse>(
|
|
||||||
input: string,
|
|
||||||
bytes: ArrayBuffer,
|
|
||||||
headers?: Record<string, string>,
|
|
||||||
) {
|
|
||||||
return request<TResponse>(input, {
|
|
||||||
method: "POST",
|
|
||||||
headers,
|
|
||||||
body: bytes,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,13 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
@@ -1,136 +0,0 @@
|
|||||||
import { ReactNode } from "react";
|
|
||||||
import { NavLink, useLocation } from "react-router-dom";
|
|
||||||
|
|
||||||
type AppShellProps = {
|
|
||||||
children: ReactNode;
|
|
||||||
};
|
|
||||||
|
|
||||||
function routeMeta(pathname: string) {
|
|
||||||
if (pathname === "/operations/new") {
|
|
||||||
return {
|
|
||||||
section: "Operations",
|
|
||||||
title: "New Operation",
|
|
||||||
heading: "Create tool contract",
|
|
||||||
subtitle:
|
|
||||||
"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 {
|
|
||||||
section: "Operations",
|
|
||||||
title: "Operation Workspace",
|
|
||||||
heading: "Refine operation contract",
|
|
||||||
subtitle:
|
|
||||||
"Inspect target metadata, validate mappings, run tests and publish the active draft version.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
section: "Operations",
|
|
||||||
title: "Catalog",
|
|
||||||
heading: "Operation catalog",
|
|
||||||
subtitle:
|
|
||||||
"Track draft and published tool contracts across REST, GraphQL and unary gRPC.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AppShell({ children }: AppShellProps) {
|
|
||||||
const location = useLocation();
|
|
||||||
const meta = routeMeta(location.pathname);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="layout-shell">
|
|
||||||
<aside className="layout-sidebar">
|
|
||||||
<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>Crank</strong>
|
|
||||||
<span>Tool Console</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="sidebar-section">
|
|
||||||
<p className="sidebar-label">Workspace</p>
|
|
||||||
<nav className="sidebar-nav">
|
|
||||||
<NavLink
|
|
||||||
end
|
|
||||||
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>
|
|
||||||
|
|
||||||
<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-footer">
|
|
||||||
<div className="sidebar-user-avatar" />
|
|
||||||
<div className="sidebar-user-copy">
|
|
||||||
<strong>Operator</strong>
|
|
||||||
<span>Admin</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
|
|
||||||
<div className="layout-main" id="app-layout-main">
|
|
||||||
<header className="topbar">
|
|
||||||
<div className="breadcrumb">
|
|
||||||
<span>{meta.section}</span>
|
|
||||||
<span className="breadcrumb-separator">/</span>
|
|
||||||
<strong>{meta.title}</strong>
|
|
||||||
</div>
|
|
||||||
<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>
|
|
||||||
|
|
||||||
<main className="main-content" id="app-main-content">
|
|
||||||
<div className="main-content-inner">
|
|
||||||
<div className="page-header">
|
|
||||||
<h1>{meta.heading}</h1>
|
|
||||||
<p>{meta.subtitle}</p>
|
|
||||||
</div>
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,20 +0,0 @@
|
|||||||
{
|
|
||||||
"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"]
|
|
||||||
}
|
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
{
|
|
||||||
"files": [],
|
|
||||||
"references": [
|
|
||||||
{ "path": "./tsconfig.app.json" },
|
|
||||||
{ "path": "./tsconfig.node.json" }
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
{
|
|
||||||
"compilerOptions": {
|
|
||||||
"composite": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"module": "ESNext",
|
|
||||||
"moduleResolution": "Bundler",
|
|
||||||
"allowSyntheticDefaultImports": true
|
|
||||||
},
|
|
||||||
"include": ["vite.config.ts"]
|
|
||||||
}
|
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
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",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
@@ -18,15 +18,8 @@ verify:
|
|||||||
just clippy
|
just clippy
|
||||||
just test
|
just test
|
||||||
|
|
||||||
ui-install:
|
|
||||||
cd apps/ui && npm install
|
|
||||||
|
|
||||||
ui-build:
|
ui-build:
|
||||||
cd apps/ui && npm run build
|
docker build -f apps/ui/Dockerfile .
|
||||||
|
|
||||||
ui-test:
|
|
||||||
cd apps/ui && npm run test -- --run
|
|
||||||
|
|
||||||
verify-ui:
|
verify-ui:
|
||||||
just ui-build
|
just ui-build
|
||||||
just ui-test
|
|
||||||
|
|||||||
Reference in New Issue
Block a user