Files
ITWorx_MCP_Hub-Public/apps/web/src/pages/ResourcePage.tsx
T

144 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>;
}