144 lines
9.2 KiB
TypeScript
144 lines
9.2 KiB
TypeScript
import { useEffect, useMemo, useState } from "react";
|
||
import type { ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts";
|
||
import { routeTitle } from "../contracts";
|
||
import { safeApiMessage } from "../api";
|
||
import { Breadcrumbs } from "../components/Breadcrumbs";
|
||
import type { Density } from "../components/DataTable";
|
||
import { columnsFor, DataTable } from "../components/DataTable";
|
||
import { ErrorPanel } from "../components/ErrorPanel";
|
||
import { Icon } from "../components/Icon";
|
||
import { LoadingPanel } from "../components/LoadingPanel";
|
||
import { basePath } from "../routing";
|
||
import { useCan } from "../rbac-context";
|
||
import { CatalogWorkflow } from "./CatalogWorkflow";
|
||
import { ClientOnboarding } from "./ClientOnboarding";
|
||
import { PolicySimulator } from "./PolicySimulator";
|
||
import { ToolReadinessModal } from "./ToolReadinessModal";
|
||
|
||
const descriptions: Record<ResourceKind, string> = {
|
||
projects: "Inspect health, environments and bounded capabilities across registered projects.",
|
||
servers: "Review project-scoped virtual MCP endpoints published through the trusted gateway.",
|
||
tools: "Find governed tools, verify their route and run safe readiness checks before invocation.",
|
||
connectors: "Monitor purpose-built source integrations without exposing their underlying credentials.",
|
||
clients: "Manage project-bound machine identities and generate secret-free client configuration.",
|
||
policies: "Inspect effective policy records and explain decisions against known entities.",
|
||
audit: "Trace governed requests by project, client, tool, decision and non-secret trace identifier.",
|
||
};
|
||
|
||
type Feedback = { tone: "info" | "danger"; message: string } | null;
|
||
|
||
export function ResourcePage({ path, kind, api, navigate }: { path: string; kind: ResourceKind; api: ManagementApiClient; navigate: (next: string) => void }) {
|
||
const canDisableClients = useCan("client.disable");
|
||
const canTestTools = useCan("tool.test");
|
||
const params = new URLSearchParams(path.split("?")[1] ?? "");
|
||
const urlFilter = params.get("q") ?? "";
|
||
const [filterInput, setFilterInput] = useState(urlFilter);
|
||
const [data, setData] = useState<ResourcePageRecord | null>(null);
|
||
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
|
||
const [error, setError] = useState("");
|
||
const [feedback, setFeedback] = useState<Feedback>(null);
|
||
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]);
|
||
const [density, setDensity] = useState<Density>("comfortable");
|
||
const [testingTool, setTestingTool] = useState<ResourceRecord | null>(null);
|
||
const [refreshKey, setRefreshKey] = useState(0);
|
||
const cursor = cursorStack[cursorStack.length - 1];
|
||
const hasCurrentData = data?.kind === kind;
|
||
const isRefreshing = state === "loading" && hasCurrentData;
|
||
const visibleColumns = useMemo(() => {
|
||
const hideActions = (kind === "clients" && !canDisableClients) || (kind === "tools" && !canTestTools);
|
||
return hideActions ? columnsFor(kind).filter((column) => column !== "actions") : columnsFor(kind);
|
||
}, [kind, canDisableClients, canTestTools]);
|
||
|
||
useEffect(() => { setFilterInput(urlFilter); }, [urlFilter]);
|
||
useEffect(() => {
|
||
const normalizedFilter = filterInput.trim();
|
||
if (normalizedFilter === urlFilter) return;
|
||
const timer = window.setTimeout(() => {
|
||
navigate(normalizedFilter ? `/${kind}?q=${encodeURIComponent(normalizedFilter)}` : `/${kind}`);
|
||
}, 260);
|
||
return () => window.clearTimeout(timer);
|
||
}, [filterInput, kind, navigate, urlFilter]);
|
||
useEffect(() => { setCursorStack([undefined]); }, [kind, urlFilter]);
|
||
useEffect(() => {
|
||
let active = true;
|
||
setState("loading");
|
||
setError("");
|
||
void api.resource(kind, { filter: urlFilter, cursor, limit: 25 })
|
||
.then((value) => {
|
||
if (!active) return;
|
||
setData(value);
|
||
setState("ready");
|
||
})
|
||
.catch((caught) => {
|
||
if (!active) return;
|
||
setError(safeApiMessage(caught));
|
||
setState("error");
|
||
});
|
||
return () => { active = false; };
|
||
}, [api, cursor, kind, refreshKey, urlFilter]);
|
||
|
||
const nextPage = () => {
|
||
if (data?.page.nextCursor) setCursorStack((stack) => [...stack, data.page.nextCursor]);
|
||
};
|
||
const previousPage = () => setCursorStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack));
|
||
const retry = () => setRefreshKey((value) => value + 1);
|
||
const action = async (id: string, verb: "disable" | "revoke") => {
|
||
setFeedback({ tone: "info", message: "Submitting server-authorized client action…" });
|
||
try {
|
||
const result = await api.clientAction(id, verb);
|
||
setFeedback({ tone: "info", message: result.message });
|
||
retry();
|
||
} catch (caught) {
|
||
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
|
||
}
|
||
};
|
||
const config = async (id: string) => {
|
||
try {
|
||
const value = await api.generatedConfig(id);
|
||
setFeedback({ tone: "info", message: `Generated configuration for ${value.clientId}: ${value.endpoint} · scopes ${value.scopes.join(", ")}. Secret material is write-only and is not displayed.` });
|
||
} catch (caught) {
|
||
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
|
||
}
|
||
};
|
||
|
||
if (!hasCurrentData && state === "error") return <ErrorPanel message={error} retry={retry} />;
|
||
if (!hasCurrentData || !data) return <LoadingPanel />;
|
||
|
||
return <div className="resource-stack">
|
||
<Breadcrumbs path={path} navigate={navigate} />
|
||
<section className="panel resource-page" aria-busy={isRefreshing}>
|
||
<div className="panel-heading resource-heading">
|
||
<div><p className="eyebrow">Management workspace</p><h2>{routeTitle(basePath(path))}</h2><p className="resource-description">{descriptions[kind]}</p></div>
|
||
<span className="record-count"><strong>{data.page.total}</strong><small>total · server paginated</small></span>
|
||
</div>
|
||
<div className="resource-toolbar">
|
||
<label className="filter-field">
|
||
<span>Filter records</span>
|
||
<span className="filter-input-wrap"><Icon name="search" size={16} /><input value={filterInput} onChange={(event) => setFilterInput(event.target.value)} placeholder={kind === "audit" ? "Project, client, tool or trace" : "Name, project or capability"} />{filterInput ? <button type="button" className="filter-clear" onClick={() => setFilterInput("")} aria-label="Clear filter">×</button> : null}</span>
|
||
</label>
|
||
<div className="table-controls" role="group" aria-label="Table density">
|
||
<span>Density</span>
|
||
<button className={density === "comfortable" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "comfortable"} onClick={() => setDensity("comfortable")}>Comfortable</button>
|
||
<button className={density === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => setDensity("compact")}>Compact</button>
|
||
</div>
|
||
</div>
|
||
<div className={isRefreshing ? "resource-results results-refreshing" : "resource-results"}>
|
||
{isRefreshing ? <span className="refresh-indicator" role="status"><span className="spinner spinner-small" aria-hidden="true" />Updating results…</span> : null}
|
||
{state === "error" ? <div className="notice notice-danger" role="alert"><span>{error}</span><button type="button" onClick={retry}>Retry</button></div> : null}
|
||
{data.rows.length === 0
|
||
? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No matching records</strong><small>{urlFilter ? "Clear or broaden the current filter." : "Nothing has been registered in this workspace yet."}</small></span></div>
|
||
: <DataTable data={data} kind={kind} density={density} visibleColumns={visibleColumns} onClientAction={action} onConfig={config} onTest={setTestingTool} />}
|
||
</div>
|
||
<div className="pagination">
|
||
<span>Showing {data.rows.length} of {data.page.total}</span>
|
||
<div><button className="button button-small button-secondary" aria-label="Previous page" onClick={previousPage} disabled={cursorStack.length <= 1} title={cursorStack.length <= 1 ? "No previous page" : "Load previous page"}>Previous</button><button className="button button-small button-secondary" aria-label="Next page" onClick={nextPage} disabled={!data.page.nextCursor} title={!data.page.nextCursor ? "No next page" : "Load next page"}>Next <Icon name="arrow" size={13} /></button></div>
|
||
</div>
|
||
{feedback ? <div className={`notice notice-${feedback.tone}`} role={feedback.tone === "danger" ? "alert" : "status"}>{feedback.message}</div> : null}
|
||
</section>
|
||
{kind === "tools" ? <CatalogWorkflow api={api} /> : null}
|
||
{kind === "policies" ? <PolicySimulator api={api} /> : null}
|
||
{kind === "clients" ? <><ClientOnboarding api={api} /><section className="panel workflow-panel client-safety"><div><p className="eyebrow">Client safety</p><h3>Write-only credentials</h3><p className="muted">Generated configuration includes endpoint, identity reference and scopes only. Client secrets are never returned by the API or rendered into the DOM.</p></div><span className="boundary-badge">Secret-free browser surface</span></section></> : null}
|
||
{testingTool ? <ToolReadinessModal tool={testingTool} api={api} onClose={() => setTestingTool(null)} /> : null}
|
||
</div>;
|
||
}
|