feat: improve operator UI and navigation
This commit is contained in:
@@ -2,17 +2,17 @@
|
||||
|
||||
## Current
|
||||
|
||||
### `feat/publish-host-config`
|
||||
### `feat/ui-ux-pass`
|
||||
|
||||
Status: completed
|
||||
|
||||
DoD:
|
||||
|
||||
- публикация портов в `docker-compose` не завязана жестко на `127.0.0.1`
|
||||
- отдельный reverse proxy на другом хосте может достучаться до `ui`, `admin-api` и `mcp-server`
|
||||
- локальный сценарий с loopback тоже остается доступным через env-конфиг
|
||||
- deployment docs и `.env.example` синхронизированы с новой моделью
|
||||
- после фикса внешний reverse proxy перестает получать `502` из-за недоступного upstream
|
||||
- shell выглядит как цельный продуктовый интерфейс, а не как набор отдельных секций
|
||||
- operation list показывает обзор состояния системы, а не только список карточек
|
||||
- create page объясняет оператору различия между `REST`, `GraphQL` и `gRPC`
|
||||
- ключевые сценарии имеют пустые состояния и более понятную визуальную иерархию
|
||||
- UI build и tests остаются зелеными после UX-pass
|
||||
|
||||
## Next
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,11 +1,38 @@
|
||||
import { NavLink } from "react-router-dom";
|
||||
import { NavLink, useLocation } from "react-router-dom";
|
||||
import { ReactNode } from "react";
|
||||
|
||||
type AppShellProps = {
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
function pageTitle(pathname: string) {
|
||||
if (pathname.startsWith("/operations/new")) {
|
||||
return {
|
||||
title: "Create tool contract",
|
||||
subtitle:
|
||||
"Configure one fixed upstream contract and publish it as one MCP tool.",
|
||||
};
|
||||
}
|
||||
|
||||
if (pathname.startsWith("/operations/")) {
|
||||
return {
|
||||
title: "Operation workspace",
|
||||
subtitle:
|
||||
"Refine target metadata, validate mappings and publish the current draft.",
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
title: "Operation catalog",
|
||||
subtitle:
|
||||
"Track draft and published tools across REST, GraphQL and unary gRPC.",
|
||||
};
|
||||
}
|
||||
|
||||
export function AppShell({ children }: AppShellProps) {
|
||||
const location = useLocation();
|
||||
const currentPage = pageTitle(location.pathname);
|
||||
|
||||
return (
|
||||
<div className="layout-shell">
|
||||
<aside className="layout-sidebar">
|
||||
@@ -17,14 +44,17 @@ export function AppShell({ children }: AppShellProps) {
|
||||
onboarding, draft generation and publish flow.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav className="sidebar-nav">
|
||||
<p className="nav-section-title">Workspace</p>
|
||||
<NavLink
|
||||
to="/operations"
|
||||
className={({ isActive }) =>
|
||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||
}
|
||||
>
|
||||
Operations
|
||||
<strong>Operations</strong>
|
||||
<span>Catalog, status, search and entry points.</span>
|
||||
</NavLink>
|
||||
<NavLink
|
||||
to="/operations/new"
|
||||
@@ -32,11 +62,47 @@ export function AppShell({ children }: AppShellProps) {
|
||||
isActive ? "nav-link nav-link-active" : "nav-link"
|
||||
}
|
||||
>
|
||||
New Operation
|
||||
<strong>New Operation</strong>
|
||||
<span>Create a protocol-specific contract and publishable tool.</span>
|
||||
</NavLink>
|
||||
</nav>
|
||||
|
||||
<div className="sidebar-meta">
|
||||
<div className="sidebar-card">
|
||||
<p className="eyebrow">Protocols</p>
|
||||
<div className="sidebar-pill-row">
|
||||
<span className="protocol-pill protocol-rest">rest</span>
|
||||
<span className="protocol-pill protocol-graphql">graphql</span>
|
||||
<span className="protocol-pill protocol-grpc">grpc</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sidebar-card">
|
||||
<p className="eyebrow">Lifecycle</p>
|
||||
<div className="sidebar-pill-row">
|
||||
<span className="status-pill status-draft">draft</span>
|
||||
<span className="status-pill status-published">published</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<main className="layout-main">{children}</main>
|
||||
|
||||
<main className="layout-main">
|
||||
<header className="workspace-header">
|
||||
<div>
|
||||
<p className="eyebrow">Operator Workspace</p>
|
||||
<h2>{currentPage.title}</h2>
|
||||
<p>{currentPage.subtitle}</p>
|
||||
</div>
|
||||
<div className="workspace-badges">
|
||||
<span className="workspace-badge">MCP request-response</span>
|
||||
<span className="workspace-badge">JSONPath mappings</span>
|
||||
<span className="workspace-badge">YAML import/export</span>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
+194
-2
@@ -53,7 +53,9 @@ button {
|
||||
.layout-sidebar {
|
||||
padding: 32px 28px;
|
||||
border-right: 1px solid rgba(30, 26, 23, 0.08);
|
||||
background: rgba(255, 250, 244, 0.78);
|
||||
background:
|
||||
linear-gradient(180deg, rgba(255, 248, 239, 0.94), rgba(246, 238, 228, 0.86)),
|
||||
rgba(255, 250, 244, 0.78);
|
||||
backdrop-filter: blur(18px);
|
||||
}
|
||||
|
||||
@@ -61,6 +63,46 @@ button {
|
||||
padding: 32px;
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
|
||||
.workspace-header h2 {
|
||||
margin: 4px 0 8px;
|
||||
font-size: clamp(2rem, 4vw, 3rem);
|
||||
line-height: 0.94;
|
||||
}
|
||||
|
||||
.workspace-header p {
|
||||
margin: 0;
|
||||
max-width: 760px;
|
||||
color: #62534b;
|
||||
}
|
||||
|
||||
.workspace-badges {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.workspace-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 34px;
|
||||
padding: 0 14px;
|
||||
border-radius: 999px;
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
background: rgba(255, 254, 250, 0.78);
|
||||
color: #5e514a;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.brand-block {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
@@ -91,13 +133,33 @@ button {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.nav-section-title {
|
||||
margin: 0 0 4px;
|
||||
color: #8d5e3d;
|
||||
font-size: 0.76rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.nav-link {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
padding: 14px 16px;
|
||||
border-radius: 18px;
|
||||
border-radius: 20px;
|
||||
background: rgba(255, 255, 255, 0.72);
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
}
|
||||
|
||||
.nav-link strong {
|
||||
font-size: 0.96rem;
|
||||
}
|
||||
|
||||
.nav-link span {
|
||||
font-size: 0.84rem;
|
||||
color: #6a5b53;
|
||||
}
|
||||
|
||||
.nav-link-active {
|
||||
background: linear-gradient(135deg, #0f7567, #19735d);
|
||||
color: white;
|
||||
@@ -105,6 +167,31 @@ button {
|
||||
box-shadow: 0 20px 38px rgba(12, 84, 73, 0.18);
|
||||
}
|
||||
|
||||
.nav-link-active span {
|
||||
color: rgba(255, 255, 255, 0.82);
|
||||
}
|
||||
|
||||
.sidebar-meta {
|
||||
margin-top: 28px;
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.sidebar-card {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 16px;
|
||||
border-radius: 22px;
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
background: rgba(255, 255, 255, 0.62);
|
||||
}
|
||||
|
||||
.sidebar-pill-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.page-stack {
|
||||
display: grid;
|
||||
gap: 24px;
|
||||
@@ -239,6 +326,89 @@ button {
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.hero-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.hero-card {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 22px;
|
||||
border-radius: 24px;
|
||||
border: 1px solid rgba(30, 26, 23, 0.08);
|
||||
background: rgba(255, 252, 246, 0.92);
|
||||
}
|
||||
|
||||
.hero-card-accent {
|
||||
background:
|
||||
radial-gradient(circle at top right, rgba(15, 117, 103, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, rgba(248, 242, 233, 0.98), rgba(255, 252, 246, 0.92));
|
||||
}
|
||||
|
||||
.hero-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.3rem;
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.hero-metrics div {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.hero-metrics span {
|
||||
color: #71625b;
|
||||
font-size: 0.82rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.hero-metrics strong {
|
||||
font-size: 1.7rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toolbar-row {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.toolbar-meta {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
justify-items: start;
|
||||
padding: 26px;
|
||||
border-radius: 24px;
|
||||
border: 1px dashed rgba(30, 26, 23, 0.18);
|
||||
background: rgba(253, 249, 243, 0.74);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0;
|
||||
color: #65574f;
|
||||
}
|
||||
|
||||
.operation-card,
|
||||
.summary-card,
|
||||
.feedback-card,
|
||||
@@ -273,6 +443,11 @@ button {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.operation-card-header {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.operation-name {
|
||||
margin: 0;
|
||||
color: #65574f;
|
||||
@@ -280,6 +455,15 @@ button {
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.card-link-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 4px;
|
||||
color: #0f7567;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.protocol-pill,
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
@@ -527,6 +711,10 @@ pre {
|
||||
border-right: none;
|
||||
border-bottom: 1px solid rgba(30, 26, 23, 0.08);
|
||||
}
|
||||
|
||||
.workspace-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
@@ -548,4 +736,8 @@ pre {
|
||||
.section-header {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.hero-metrics {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user