feat: improve operator UI and navigation

This commit is contained in:
a.tolmachev
2026-03-25 23:45:54 +03:00
parent c24c8cd0c4
commit f5a957585a
5 changed files with 393 additions and 26 deletions
+6 -6
View File
@@ -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
+39 -1
View File
@@ -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>
+84 -13
View File
@@ -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>
+70 -4
View File
@@ -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
View File
@@ -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;
}
}