feat: improve operator UI and navigation
This commit is contained in:
@@ -1,5 +1,26 @@
|
||||
import { PageSection } from "../../shared/ui/page-section";
|
||||
import { OperationForm } from "../../features/operation-form/operation-form";
|
||||
import { PageSection } from "../../shared/ui/page-section";
|
||||
|
||||
const protocolCards = [
|
||||
{
|
||||
protocol: "rest",
|
||||
title: "REST",
|
||||
description:
|
||||
"One tool maps to one method and one path, with body, query and header mapping.",
|
||||
},
|
||||
{
|
||||
protocol: "graphql",
|
||||
title: "GraphQL",
|
||||
description:
|
||||
"One tool maps to one fixed query or mutation with stable variables and response shape.",
|
||||
},
|
||||
{
|
||||
protocol: "grpc",
|
||||
title: "gRPC",
|
||||
description:
|
||||
"One tool maps to one unary method backed by a descriptor-set contract.",
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function OperationCreatePage() {
|
||||
return (
|
||||
@@ -7,6 +28,23 @@ export function OperationCreatePage() {
|
||||
<PageSection
|
||||
title="Create Operation"
|
||||
subtitle="Create one MCP tool from one fixed REST, GraphQL or unary gRPC contract."
|
||||
>
|
||||
<div className="hero-grid">
|
||||
{protocolCards.map((card) => (
|
||||
<article className="hero-card" key={card.protocol}>
|
||||
<span className={`protocol-pill protocol-${card.protocol}`}>
|
||||
{card.protocol}
|
||||
</span>
|
||||
<h3>{card.title}</h3>
|
||||
<p className="body-copy">{card.description}</p>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</PageSection>
|
||||
|
||||
<PageSection
|
||||
title="Contract Builder"
|
||||
subtitle="Define the operator-facing tool contract first, then map it to the upstream target."
|
||||
>
|
||||
<OperationForm />
|
||||
</PageSection>
|
||||
|
||||
@@ -28,6 +28,17 @@ export function OperationListPage() {
|
||||
);
|
||||
}, [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
|
||||
@@ -39,19 +50,73 @@ export function OperationListPage() {
|
||||
</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>
|
||||
<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>
|
||||
|
||||
{operationsQuery.isLoading ? <p>Loading operations...</p> : null}
|
||||
<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>
|
||||
|
||||
{operationsQuery.data ? (
|
||||
<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}`}>
|
||||
@@ -63,8 +128,10 @@ export function OperationListPage() {
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
<h3>{item.display_name}</h3>
|
||||
<p className="operation-name">{item.name}</p>
|
||||
<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>
|
||||
@@ -79,6 +146,10 @@ export function OperationListPage() {
|
||||
<dd>{new Date(item.updated_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div className="card-link-row">
|
||||
<span>Open workspace</span>
|
||||
<strong>→</strong>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user