feat: implement ui v1

This commit is contained in:
a.tolmachev
2026-03-25 19:27:31 +03:00
parent 39c88456b4
commit 85ffda67d3
32 changed files with 5084 additions and 62 deletions
@@ -0,0 +1,15 @@
import { PageSection } from "../../shared/ui/page-section";
import { OperationForm } from "../../features/operation-form/operation-form";
export function OperationCreatePage() {
return (
<div className="page-stack">
<PageSection
title="Create Operation"
subtitle="REST-first creation flow for the first production UI slice. GraphQL and gRPC will reuse the same operator surface later."
>
<OperationForm />
</PageSection>
</div>
);
}
+176
View File
@@ -0,0 +1,176 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
exportOperationYaml,
generateDraft,
getOperation,
getOperationVersion,
listAuthProfiles,
importOperationYaml,
} from "../../entities/operation/api";
import { PublishOperationPanel } from "../../features/publish-operation/panel";
import { SampleUploadPanel } from "../../features/sample-upload/panel";
import { SchemaViewerPanel } from "../../features/schema-viewer/panel";
import { TestRunPanel } from "../../features/test-run/panel";
import { PageSection } from "../../shared/ui/page-section";
export function OperationDetailPage() {
const { operationId = "" } = useParams();
const summaryQuery = useQuery({
queryKey: ["operation-summary", operationId],
queryFn: () => getOperation(operationId),
enabled: operationId.length > 0,
});
const versionQuery = useQuery({
queryKey: [
"operation-version",
operationId,
summaryQuery.data?.current_draft_version,
],
queryFn: () =>
getOperationVersion(operationId, summaryQuery.data!.current_draft_version),
enabled:
operationId.length > 0 &&
summaryQuery.data !== undefined &&
summaryQuery.data.current_draft_version > 0,
});
const authProfilesQuery = useQuery({
queryKey: ["auth-profiles"],
queryFn: listAuthProfiles,
});
const draftMutation = useMutation({
mutationFn: async () => generateDraft(operationId),
});
const exportMutation = useMutation({
mutationFn: async () =>
exportOperationYaml(operationId, summaryQuery.data?.current_draft_version),
});
const importMutation = useMutation({
mutationFn: async (yamlText: string) => importOperationYaml(yamlText, "upsert"),
});
const currentVersion = summaryQuery.data?.current_draft_version ?? 0;
const exportedYaml = exportMutation.data;
return (
<div className="page-stack">
<PageSection
title={summaryQuery.data?.display_name ?? "Operation"}
subtitle={
summaryQuery.data
? `${summaryQuery.data.name} · ${summaryQuery.data.protocol} · draft v${currentVersion}`
: "Loading operation metadata..."
}
>
{summaryQuery.data ? (
<div className="summary-grid">
<div className="summary-card">
<span>Status</span>
<strong>{summaryQuery.data.status}</strong>
</div>
<div className="summary-card">
<span>Published version</span>
<strong>{summaryQuery.data.latest_published_version ?? "none"}</strong>
</div>
<div className="summary-card">
<span>Updated</span>
<strong>{new Date(summaryQuery.data.updated_at).toLocaleString()}</strong>
</div>
</div>
) : null}
</PageSection>
<PageSection
title="Samples And Draft"
subtitle="Upload input/output samples first, then generate a draft schema and mapping set."
actions={
<button
className="button-primary"
onClick={() => draftMutation.mutate()}
type="button"
>
{draftMutation.isPending ? "Generating..." : "Generate draft"}
</button>
}
>
<SampleUploadPanel operationId={operationId} />
<SchemaViewerPanel
operationRecord={versionQuery.data}
draftResult={draftMutation.data}
/>
</PageSection>
<PageSection
title="Test Run"
subtitle="Run the current draft version through the backend runtime before publishing."
>
<TestRunPanel operationId={operationId} version={currentVersion} />
</PageSection>
<PageSection
title="Publish"
subtitle="Move the current draft version into the published tool set exposed by MCP."
>
<PublishOperationPanel operationId={operationId} version={currentVersion} />
</PageSection>
<PageSection
title="YAML Export And Reimport"
subtitle="Export the current version, inspect the YAML, then upsert it back through the same backend contract."
actions={
<button
className="button-secondary"
type="button"
onClick={() => exportMutation.mutate()}
>
{exportMutation.isPending ? "Exporting..." : "Export YAML"}
</button>
}
>
<div className="stack-layout">
{exportedYaml ? (
<>
<pre className="yaml-panel">{exportedYaml}</pre>
<div className="button-row">
<button
className="button-primary"
type="button"
onClick={() => importMutation.mutate(exportedYaml)}
>
{importMutation.isPending ? "Importing..." : "Upsert from YAML"}
</button>
</div>
</>
) : (
<p>Export YAML to inspect the current operation snapshot.</p>
)}
{importMutation.data ? (
<div className="feedback-card feedback-success">
Imported version {String(importMutation.data.version)} in{" "}
{String(importMutation.data.import_mode)} mode.
</div>
) : null}
</div>
</PageSection>
<PageSection
title="Auth Profiles"
subtitle="Currently available auth profiles exposed by the admin backend."
>
<div className="operation-grid">
{(authProfilesQuery.data?.items ?? []).map((profile) => (
<div className="operation-card" key={profile.id}>
<div className="operation-card-top">
<span className="protocol-pill protocol-rest">{profile.kind}</span>
</div>
<h3>{profile.name}</h3>
<p className="operation-name">{profile.id}</p>
<pre>{JSON.stringify(profile.config, null, 2)}</pre>
</div>
))}
</div>
</PageSection>
</div>
);
}
+89
View File
@@ -0,0 +1,89 @@
import { useDeferredValue, useMemo, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Link } from "react-router-dom";
import { listOperations } from "../../entities/operation/api";
import { PageSection } from "../../shared/ui/page-section";
export function OperationListPage() {
const [searchTerm, setSearchTerm] = useState("");
const deferredSearchTerm = useDeferredValue(searchTerm);
const operationsQuery = useQuery({
queryKey: ["operations"],
queryFn: listOperations,
});
const filteredItems = useMemo(() => {
const items = operationsQuery.data?.items ?? [];
const normalizedSearchTerm = deferredSearchTerm.trim().toLowerCase();
if (!normalizedSearchTerm) {
return items;
}
return items.filter((item) =>
`${item.name} ${item.display_name} ${item.protocol} ${item.status}`
.toLowerCase()
.includes(normalizedSearchTerm),
);
}, [deferredSearchTerm, operationsQuery.data?.items]);
return (
<div className="page-stack">
<PageSection
title="Operations"
subtitle="Browse draft and published MCP tools exposed through the admin backend."
actions={
<Link className="button-primary" to="/operations/new">
New operation
</Link>
}
>
<label className="field-block field-inline">
<span>Search</span>
<input
type="text"
placeholder="name, protocol or status"
value={searchTerm}
onChange={(event) => setSearchTerm(event.target.value)}
/>
</label>
{operationsQuery.isLoading ? <p>Loading operations...</p> : null}
{operationsQuery.data ? (
<div className="operation-grid">
{filteredItems.map((item) => (
<Link className="operation-card" key={item.id} to={`/operations/${item.id}`}>
<div className="operation-card-top">
<span className={`protocol-pill protocol-${item.protocol}`}>
{item.protocol}
</span>
<span className={`status-pill status-${item.status}`}>
{item.status}
</span>
</div>
<h3>{item.display_name}</h3>
<p className="operation-name">{item.name}</p>
<dl className="key-value-list">
<div>
<dt>Draft</dt>
<dd>{item.current_draft_version}</dd>
</div>
<div>
<dt>Published</dt>
<dd>{item.latest_published_version ?? "none"}</dd>
</div>
<div>
<dt>Updated</dt>
<dd>{new Date(item.updated_at).toLocaleString()}</dd>
</div>
</dl>
</Link>
))}
</div>
) : null}
</PageSection>
</div>
);
}