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
+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>