161 lines
5.4 KiB
TypeScript
161 lines
5.4 KiB
TypeScript
import { useDeferredValue, useMemo, useState } from "react";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { Link } from "react-router-dom";
|
|
|
|
import { listOperations } from "../../entities/operation/api";
|
|
import { PageSection } from "../../shared/ui/page-section";
|
|
|
|
export function OperationListPage() {
|
|
const [searchTerm, setSearchTerm] = useState("");
|
|
const deferredSearchTerm = useDeferredValue(searchTerm);
|
|
const operationsQuery = useQuery({
|
|
queryKey: ["operations"],
|
|
queryFn: listOperations,
|
|
});
|
|
|
|
const filteredItems = useMemo(() => {
|
|
const items = operationsQuery.data?.items ?? [];
|
|
const normalizedSearchTerm = deferredSearchTerm.trim().toLowerCase();
|
|
|
|
if (!normalizedSearchTerm) {
|
|
return items;
|
|
}
|
|
|
|
return items.filter((item) =>
|
|
`${item.name} ${item.display_name} ${item.protocol} ${item.status}`
|
|
.toLowerCase()
|
|
.includes(normalizedSearchTerm),
|
|
);
|
|
}, [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
|
|
title="Operations"
|
|
subtitle="Browse draft and published MCP tools exposed through the admin backend."
|
|
actions={
|
|
<Link className="button-primary" to="/operations/new">
|
|
New operation
|
|
</Link>
|
|
}
|
|
>
|
|
<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>
|
|
|
|
<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>
|
|
|
|
<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}`}>
|
|
<div className="operation-card-top">
|
|
<span className={`protocol-pill protocol-${item.protocol}`}>
|
|
{item.protocol}
|
|
</span>
|
|
<span className={`status-pill status-${item.status}`}>
|
|
{item.status}
|
|
</span>
|
|
</div>
|
|
<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>
|
|
<dd>{item.current_draft_version}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Published</dt>
|
|
<dd>{item.latest_published_version ?? "none"}</dd>
|
|
</div>
|
|
<div>
|
|
<dt>Updated</dt>
|
|
<dd>{new Date(item.updated_at).toLocaleString()}</dd>
|
|
</div>
|
|
</dl>
|
|
<div className="card-link-row">
|
|
<span>Open workspace</span>
|
|
<strong>→</strong>
|
|
</div>
|
|
</Link>
|
|
))}
|
|
</div>
|
|
) : null}
|
|
</PageSection>
|
|
</div>
|
|
);
|
|
}
|