diff --git a/TASKS.md b/TASKS.md index 2ca7024..bdf4da4 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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 diff --git a/apps/ui/src/pages/operation-create/page.tsx b/apps/ui/src/pages/operation-create/page.tsx index 143e1a9..2a53e38 100644 --- a/apps/ui/src/pages/operation-create/page.tsx +++ b/apps/ui/src/pages/operation-create/page.tsx @@ -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() { +
+ {protocolCards.map((card) => ( +
+ + {card.protocol} + +

{card.title}

+

{card.description}

+
+ ))} +
+
+ + diff --git a/apps/ui/src/pages/operation-list/page.tsx b/apps/ui/src/pages/operation-list/page.tsx index be84039..461e7dc 100644 --- a/apps/ui/src/pages/operation-list/page.tsx +++ b/apps/ui/src/pages/operation-list/page.tsx @@ -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 (
} > - +
+
+

Catalog health

+

{metrics.total} tools registered

+

+ Published and draft operations share one operator surface and one + MCP publication path. +

+
+
+
+
+ Published + {metrics.published} +
+
+ Drafts + {metrics.drafts} +
+
+ Protocols + {metrics.protocols} +
+
+
+
- {operationsQuery.isLoading ?

Loading operations...

: null} +
+ - {operationsQuery.data ? ( +
+ {filteredItems.length} visible + + {operationsQuery.data?.items.length ?? 0} total + +
+
+ + {operationsQuery.isLoading ? ( +
+

Loading operations

+

Fetching the current registry snapshot from the admin backend.

+
+ ) : null} + + {operationsQuery.data && filteredItems.length === 0 ? ( +
+

No matching operations

+

+ Adjust the search term or create a new contract for REST, GraphQL + or unary gRPC. +

+ + Create operation + +
+ ) : null} + + {operationsQuery.data && filteredItems.length > 0 ? (
{filteredItems.map((item) => ( @@ -63,8 +128,10 @@ export function OperationListPage() { {item.status}
-

{item.display_name}

-

{item.name}

+
+

{item.display_name}

+

{item.name}

+
Draft
@@ -79,6 +146,10 @@ export function OperationListPage() {
{new Date(item.updated_at).toLocaleString()}
+
+ Open workspace + +
))}
diff --git a/apps/ui/src/shared/ui/app-shell.tsx b/apps/ui/src/shared/ui/app-shell.tsx index f65fe24..fbd810a 100644 --- a/apps/ui/src/shared/ui/app-shell.tsx +++ b/apps/ui/src/shared/ui/app-shell.tsx @@ -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 (
+ + +
+
+

Protocols

+
+ rest + graphql + grpc +
+
+ +
+

Lifecycle

+
+ draft + published +
+
+
-
{children}
+ +
+
+
+

Operator Workspace

+

{currentPage.title}

+

{currentPage.subtitle}

+
+
+ MCP request-response + JSONPath mappings + YAML import/export +
+
+ + {children} +
); } diff --git a/apps/ui/src/styles.css b/apps/ui/src/styles.css index e8d5fd6..8da77c1 100644 --- a/apps/ui/src/styles.css +++ b/apps/ui/src/styles.css @@ -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; + } }