Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1
This commit is contained in:
1 parent
77946d62c4
commit
6d40ab975c
86 files changed
+6784
-1478
No files matched your search
@@ -1,66 +1,253 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { availableNavigation } from "../navigation";
|
||||
import { useEffect, useMemo, useRef, useState, type KeyboardEvent as ReactKeyboardEvent } from "react";
|
||||
import type { ManagementApiClient, ResourceKind, ResourceRecord } from "../contracts";
|
||||
import { ApiError, safeApiMessage } from "../api";
|
||||
import { availableNavigation, navigation, type NavigationItem } from "../navigation";
|
||||
import { Icon } from "./Icon";
|
||||
import { Modal } from "./Modal";
|
||||
|
||||
type DestinationOption = {
|
||||
id: string;
|
||||
href: string;
|
||||
label: string;
|
||||
description: string;
|
||||
icon: NavigationItem["icon"];
|
||||
source: "navigation" | "resource";
|
||||
};
|
||||
type DestinationGroup = { label: string; items: DestinationOption[] };
|
||||
type SearchResults = Partial<Record<ResourceKind, ResourceRecord[]>>;
|
||||
type SearchState = { query: string; status: "idle" | "loading" | "ready"; results: SearchResults; error: string | null };
|
||||
|
||||
const ENTITY_LABELS: Record<ResourceKind, string> = {
|
||||
projects: "Projects",
|
||||
servers: "MCP Servers",
|
||||
tools: "Catalog",
|
||||
connectors: "Connectors",
|
||||
clients: "Clients",
|
||||
policies: "Policies",
|
||||
audit: "Audit",
|
||||
};
|
||||
const SEARCH_KINDS: readonly ResourceKind[] = ["projects", "servers", "tools", "connectors", "clients", "policies", "audit"];
|
||||
const EMPTY_SEARCH: SearchState = { query: "", status: "idle", results: {}, error: null };
|
||||
|
||||
function recordDestination(kind: ResourceKind, record: ResourceRecord): string {
|
||||
if (kind === "projects" || kind === "connectors") return `/${kind}/${encodeURIComponent(record.id)}`;
|
||||
if (kind === "tools") {
|
||||
const params = new URLSearchParams({ reopen: record.canonicalName ?? record.id });
|
||||
if (record.project) params.set("project", record.project);
|
||||
if (record.environment) params.set("environment", record.environment);
|
||||
return `/tools?${params.toString()}`;
|
||||
}
|
||||
if (kind === "clients") return record.onboardingStage ? `/clients?onboard=${encodeURIComponent(record.id)}` : `/clients?q=${encodeURIComponent(record.id)}`;
|
||||
const exactKey = kind === "servers" ? record.id : kind === "audit" ? record.traceId ?? record.name : record.name;
|
||||
return `/${kind}?q=${encodeURIComponent(exactKey)}`;
|
||||
}
|
||||
|
||||
function searchFailure(errors: unknown[]): string | null {
|
||||
if (!errors.length) return null;
|
||||
const unauthorized = errors.find((error) => error instanceof ApiError && error.status === 401);
|
||||
if (unauthorized) return safeApiMessage(unauthorized);
|
||||
if (errors.some((error) => error instanceof ApiError && error.status === 403)) {
|
||||
return "Some results are unavailable to your role, so this search is incomplete.";
|
||||
}
|
||||
return errors.length === 1
|
||||
? "One resource type could not be searched. Results may be incomplete."
|
||||
: "Some resource types could not be searched. Results may be incomplete.";
|
||||
}
|
||||
|
||||
export function CommandMenu({
|
||||
open,
|
||||
api,
|
||||
canReadAudit,
|
||||
navigate,
|
||||
onClose,
|
||||
}: {
|
||||
open: boolean;
|
||||
api: ManagementApiClient;
|
||||
canReadAudit: boolean;
|
||||
navigate: (next: string) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [activeIndex, setActiveIndex] = useState(-1);
|
||||
const [recentHrefs, setRecentHrefs] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState<SearchState>(EMPTY_SEARCH);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const items = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
|
||||
const results = useMemo(() => {
|
||||
const needle = query.trim().toLowerCase();
|
||||
if (!needle) return items;
|
||||
return items.filter((item) => `${item.label} ${item.description}`.toLowerCase().includes(needle));
|
||||
}, [items, query]);
|
||||
const normalizedQuery = query.trim().toLowerCase();
|
||||
const allowedNavigation = useMemo(() => availableNavigation(canReadAudit), [canReadAudit]);
|
||||
const iconForKind = (kind: ResourceKind) => allowedNavigation.find((item) => item.href === `/${kind}`)?.icon ?? "overview";
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setQuery("");
|
||||
setActiveIndex(-1);
|
||||
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => window.clearTimeout(focusTimer);
|
||||
}, [open]);
|
||||
|
||||
const select = (href: string) => {
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const term = query.trim().toLowerCase();
|
||||
if (term.length < 2) {
|
||||
setSearch({ query: term, status: "idle", results: {}, error: null });
|
||||
return;
|
||||
}
|
||||
|
||||
let current = true;
|
||||
setSearch({ query: term, status: "loading", results: {}, error: null });
|
||||
const kinds = SEARCH_KINDS.filter((kind) => kind !== "audit" || canReadAudit);
|
||||
const timer = window.setTimeout(() => {
|
||||
void Promise.all(kinds.map(async (kind) => {
|
||||
try {
|
||||
const page = await api.resource(kind, { filter: term, limit: 6 });
|
||||
return { kind, rows: page.rows, error: null as unknown };
|
||||
} catch (error) {
|
||||
return { kind, rows: [] as ResourceRecord[], error };
|
||||
}
|
||||
})).then((outcomes) => {
|
||||
if (!current) return;
|
||||
const results: SearchResults = {};
|
||||
const errors: unknown[] = [];
|
||||
for (const outcome of outcomes) {
|
||||
if (outcome.error) errors.push(outcome.error);
|
||||
else results[outcome.kind] = outcome.rows.slice(0, 6);
|
||||
}
|
||||
setSearch({ query: term, status: "ready", results, error: searchFailure(errors) });
|
||||
setActiveIndex(-1);
|
||||
});
|
||||
}, 250);
|
||||
|
||||
return () => {
|
||||
current = false;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [api, canReadAudit, open, query]);
|
||||
|
||||
const navigationGroups = useMemo(() => navigation.flatMap((group) => {
|
||||
const items = group.items
|
||||
.filter((item) => allowedNavigation.some((allowed) => allowed.href === item.href))
|
||||
.filter((item) => !normalizedQuery || `${item.label} ${item.description}`.toLowerCase().includes(normalizedQuery))
|
||||
.map((item) => ({ id: `nav:${item.href}`, href: item.href, label: item.label, description: item.description, icon: item.icon, source: "navigation" as const }));
|
||||
return items.length ? [{ label: group.label, items }] : [];
|
||||
}), [allowedNavigation, normalizedQuery]);
|
||||
|
||||
const groups = useMemo<DestinationGroup[]>(() => {
|
||||
const result: DestinationGroup[] = [];
|
||||
if (!normalizedQuery) {
|
||||
const recent = recentHrefs.flatMap((href) => {
|
||||
const item = allowedNavigation.find((candidate) => candidate.href === href);
|
||||
return item ? [{ id: `recent:${href}`, href, label: item.label, description: item.description, icon: item.icon, source: "navigation" as const }] : [];
|
||||
});
|
||||
if (recent.length) result.push({ label: "Recent destinations", items: recent });
|
||||
}
|
||||
result.push(...navigationGroups);
|
||||
|
||||
const hasCurrentSearch = search.query === normalizedQuery && search.status === "ready";
|
||||
if (normalizedQuery.length >= 2 && hasCurrentSearch) {
|
||||
for (const kind of SEARCH_KINDS) {
|
||||
if (kind === "audit" && !canReadAudit) continue;
|
||||
const rows = search.results[kind] ?? [];
|
||||
if (!rows.length) continue;
|
||||
result.push({
|
||||
label: ENTITY_LABELS[kind],
|
||||
items: rows.map((record) => ({
|
||||
id: `resource:${kind}:${record.id}`,
|
||||
href: recordDestination(kind, record),
|
||||
label: record.name,
|
||||
description: [record.project, record.summary].filter(Boolean).join(" · ") || ENTITY_LABELS[kind],
|
||||
icon: iconForKind(kind),
|
||||
source: "resource" as const,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}, [allowedNavigation, canReadAudit, iconForKind, navigationGroups, normalizedQuery, recentHrefs, search]);
|
||||
|
||||
const options = useMemo(() => groups.flatMap((group) => group.items), [groups]);
|
||||
const activeOptionId = activeIndex >= 0 && activeIndex < options.length ? `quick-jump-option-${activeIndex}` : undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeOptionId) return;
|
||||
document.getElementById(activeOptionId)?.scrollIntoView?.({ block: "nearest" });
|
||||
}, [activeOptionId]);
|
||||
|
||||
const select = (option: DestinationOption) => {
|
||||
if (option.source === "navigation") {
|
||||
setRecentHrefs((current) => [option.href, ...current.filter((href) => href !== option.href)].slice(0, 4));
|
||||
}
|
||||
onClose();
|
||||
navigate(href);
|
||||
navigate(option.href);
|
||||
};
|
||||
|
||||
const onInputKeyDown = (event: ReactKeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === "ArrowDown") {
|
||||
if (!options.length) return;
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => (current + 1) % options.length);
|
||||
} else if (event.key === "ArrowUp") {
|
||||
if (!options.length) return;
|
||||
event.preventDefault();
|
||||
setActiveIndex((current) => current <= 0 ? options.length - 1 : current - 1);
|
||||
} else if (event.key === "Enter" && activeIndex >= 0 && options[activeIndex]) {
|
||||
event.preventDefault();
|
||||
select(options[activeIndex]);
|
||||
}
|
||||
};
|
||||
|
||||
const currentSearch = search.query === normalizedQuery ? search : null;
|
||||
const searching = normalizedQuery.length >= 2 && (!currentSearch || currentSearch.status === "loading");
|
||||
const error = currentSearch?.status === "ready" ? currentSearch.error : null;
|
||||
const empty = !searching && !error && options.length === 0;
|
||||
|
||||
return (
|
||||
<Modal open={open} title="Quick jump" onClose={onClose} className="command-modal">
|
||||
<label className="command-search">
|
||||
<span className="sr-only">Find a workspace</span>
|
||||
<span className="sr-only">Find a workspace or resource</span>
|
||||
<Icon name="search" size={17} />
|
||||
<input
|
||||
ref={inputRef}
|
||||
role="combobox"
|
||||
aria-autocomplete="list"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded="true"
|
||||
aria-controls="command-results"
|
||||
aria-activedescendant={activeOptionId}
|
||||
value={query}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder="Find projects, catalog, policies…"
|
||||
onKeyDown={onInputKeyDown}
|
||||
onChange={(event) => { setQuery(event.target.value); setActiveIndex(-1); }}
|
||||
placeholder="Find projects, connectors, clients…"
|
||||
autoComplete="off"
|
||||
/>
|
||||
<kbd>Esc</kbd>
|
||||
</label>
|
||||
<div className="command-results" aria-live="polite">
|
||||
{results.length ? results.map((item) => (
|
||||
<button type="button" className="command-result" key={item.href} onClick={() => select(item.href)}>
|
||||
<span className="command-result-icon"><Icon name={item.icon} /></span>
|
||||
<span><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<Icon name="arrow" size={16} />
|
||||
</button>
|
||||
)) : (
|
||||
<div className="command-empty"><strong>No workspace found</strong><span>Try a broader name or capability.</span></div>
|
||||
)}
|
||||
<div className="command-results" id="command-results" role="listbox" aria-label="Quick jump destinations" aria-live="polite">
|
||||
{groups.map((group) => <section className="command-group" role="group" aria-label={group.label} key={group.label}>
|
||||
<h3>{group.label}</h3>
|
||||
{group.items.map((item) => {
|
||||
const index = options.findIndex((option) => option.id === item.id);
|
||||
return <button
|
||||
type="button"
|
||||
role="option"
|
||||
tabIndex={-1}
|
||||
id={`quick-jump-option-${index}`}
|
||||
aria-selected={activeIndex === index}
|
||||
className={activeIndex === index ? "command-result command-result-active" : "command-result"}
|
||||
key={item.id}
|
||||
onMouseMove={() => setActiveIndex(index)}
|
||||
onClick={() => select(item)}
|
||||
>
|
||||
<span className="command-result-icon"><Icon name={item.icon} /></span>
|
||||
<span><strong>{item.label}</strong><small>{item.description}</small></span>
|
||||
<Icon name="arrow" size={16} />
|
||||
</button>;
|
||||
})}
|
||||
</section>)}
|
||||
{searching ? <div className="command-empty" role="status"><strong>Searching platform records…</strong><span>Checking destinations available to your role.</span></div> : null}
|
||||
{error ? <div className="command-search-error" role="alert">{error}</div> : null}
|
||||
{normalizedQuery.length === 1 ? <div className="command-empty"><strong>Keep typing to search resources</strong><span>Enter at least two characters to search platform records.</span></div> : null}
|
||||
{empty ? <div className="command-empty"><strong>No destinations found</strong><span>Try a broader name or capability.</span></div> : null}
|
||||
</div>
|
||||
<p className="command-hint">Navigate without leaving the keyboard. Access-controlled destinations remain hidden.</p>
|
||||
<p className="command-hint">Use ↑ and ↓ to move, Enter to open, and Esc to close. Destinations follow your current role.</p>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
|
||||
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
|
||||
|
||||
export function DataTable({
|
||||
data, kind, density, visibleColumns, onClientAction, onConfig, onTest,
|
||||
data, kind, density, visibleColumns, onClientAction, onConfig, onTest, navigate, busyClientId,
|
||||
}: {
|
||||
data: ResourcePage;
|
||||
kind: ResourceKind;
|
||||
@@ -28,14 +28,25 @@ export function DataTable({
|
||||
onClientAction: (id: string, action: "disable" | "revoke") => void;
|
||||
onConfig: (id: string) => void;
|
||||
onTest: (row: ResourceRecord) => void;
|
||||
navigate?: (next: string) => void;
|
||||
busyClientId?: string | null;
|
||||
}) {
|
||||
const show = (column: ColumnKey) => visibleColumns.includes(column);
|
||||
const [confirmingAction, setConfirmingAction] = useState<{ id: string; action: "disable" | "revoke"; name: string } | null>(null);
|
||||
const actions = (row: ResourceRecord) => {
|
||||
if (kind === "clients" && show("actions")) return <div className="inline-actions"><button className="button button-small" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={row.status !== "healthy"}>Disable</button><button className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={row.status === "unavailable"}>Revoke</button><button className="button button-small button-secondary" onClick={() => onConfig(row.id)}>Config</button></div>;
|
||||
if (kind === "clients" && show("actions")) {
|
||||
const disabled = busyClientId === row.id;
|
||||
const resume = `/clients?onboard=${encodeURIComponent(row.id)}`;
|
||||
const secondary = <><button type="button" className="button button-small button-secondary" onClick={() => setConfirmingAction({ id: row.id, action: "disable", name: row.name })} disabled={disabled || row.status !== "healthy"}>Disable</button><button type="button" className="button button-small button-danger" onClick={() => setConfirmingAction({ id: row.id, action: "revoke", name: row.name })} disabled={disabled || row.status === "unavailable"}>Revoke</button>{row.onboardingStage ? <a className="button button-small button-secondary" href={resume} onClick={navigate ? (event) => { event.preventDefault(); navigate(resume); } : undefined}>Onboarding</a> : null}</>;
|
||||
return <div className="inline-actions client-actions"><button type="button" className="button button-small button-secondary" disabled={disabled} onClick={() => onConfig(row.id)}>{disabled ? "Working…" : "Config"}</button><div className="desktop-client-actions">{secondary}</div><details className="mobile-client-actions"><summary>More actions</summary><div>{secondary}</div></details></div>;
|
||||
}
|
||||
if (kind === "tools" && show("actions")) return <div className="inline-actions"><button className="button button-small button-secondary" onClick={() => onTest(row)}>Readiness</button></div>;
|
||||
return null;
|
||||
};
|
||||
const name = (row: ResourceRecord) => {
|
||||
const href = kind === "projects" ? `/projects/${encodeURIComponent(row.id)}` : kind === "connectors" ? `/connectors/${encodeURIComponent(row.id)}` : kind === "clients" ? row.onboardingStage ? `/clients?onboard=${encodeURIComponent(row.id)}` : `/clients?q=${encodeURIComponent(row.id)}` : null;
|
||||
return href ? <a className="record-link" href={href} onClick={navigate ? (event) => { event.preventDefault(); navigate(href); } : undefined}><strong className="long-name">{row.name}</strong></a> : <strong className="long-name">{row.name}</strong>;
|
||||
};
|
||||
return (
|
||||
<Fragment>
|
||||
<div className={`table-wrap desktop-data-table table-kind-${kind} density-${density}`} role="region" tabIndex={0} aria-label={`${kind} records table`}>
|
||||
@@ -55,10 +66,10 @@ export function DataTable({
|
||||
<tbody>
|
||||
{data.rows.map((row) => (
|
||||
<tr key={row.id}>
|
||||
{show("name") && <td><strong className="long-name">{row.name}</strong><small>{row.id}</small></td>}
|
||||
{show("name") && <td>{name(row)}<small>{row.id}</small></td>}
|
||||
{show("status") && <td><StatusPill value={row.status} /></td>}
|
||||
{show("project") && <td>{row.project ?? "—"}</td>}
|
||||
{show("summary") && <td>{row.summary}</td>}
|
||||
{show("summary") && <td>{row.summary}{row.onboardingStage ? <small>{row.onboardingStage}</small> : null}</td>}
|
||||
{kind === "tools" && show("risk") && <td>{row.risk ?? "R0"}</td>}
|
||||
{kind === "audit" && show("decision") && <td>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</td>}
|
||||
{(kind === "clients" || kind === "tools") && show("actions") && <td>{actions(row)}</td>}
|
||||
@@ -68,7 +79,7 @@ export function DataTable({
|
||||
</table>
|
||||
</div>
|
||||
<div className="mobile-record-list" aria-label={`${kind} records`}>
|
||||
{data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div><strong>{row.name}</strong><small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)}
|
||||
{data.rows.map((row) => <article className="mobile-record" key={row.id}><div className="mobile-record-heading"><div>{name(row)}<small>{row.id}</small></div>{show("status") && <StatusPill value={row.status} />}</div><dl>{show("project") && <><dt>Project</dt><dd>{row.project ?? "—"}</dd></>}{show("summary") && <><dt>Summary</dt><dd>{row.summary}{row.onboardingStage ? <small>{row.onboardingStage}</small> : null}</dd></>}{kind === "tools" && show("risk") && <><dt>Risk</dt><dd><span className="risk-label" title="Risk classification">{row.risk ?? "R0"}</span></dd></>}{kind === "audit" && show("decision") && <><dt>Decision / trace</dt><dd>{row.decision ?? "unknown"}{row.traceId && <small>{row.traceId}</small>}</dd></>}</dl>{actions(row)}</article>)}
|
||||
</div>
|
||||
<Modal
|
||||
open={confirmingAction !== null}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useEffect, useId, useRef, type ReactNode } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { focusableElements, isolateOutside, lockBodyScroll, trapTab } from "./modal-focus";
|
||||
|
||||
export function Modal({ open, title, onClose, children, className = "" }: { open: boolean; title: string; onClose: () => void; children: ReactNode; className?: string }) {
|
||||
const dialogRef = useRef<HTMLDivElement>(null);
|
||||
@@ -7,56 +9,49 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Kept in its own effect, keyed only on `open` (not `onClose`): if this
|
||||
// ran on every `onClose` identity change (callers commonly pass a fresh
|
||||
// inline arrow each render), it would re-capture document.activeElement
|
||||
// after focus had already moved into the dialog, overwriting the real
|
||||
// trigger element with the dialog itself.
|
||||
const previouslyFocused = document.activeElement as HTMLElement | null;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
dialogRef.current?.focus();
|
||||
return () => {
|
||||
document.body.style.overflow = previousOverflow;
|
||||
previouslyFocused?.focus();
|
||||
};
|
||||
}, [open]);
|
||||
const dialog = dialogRef.current;
|
||||
const overlay = dialog?.parentElement;
|
||||
if (!open || !dialog || !overlay) return;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
// Capture the trigger before moving focus into the body-level portal. This
|
||||
// effect only follows `open`, so fresh inline onClose callbacks cannot
|
||||
// replace the original trigger during a dialog render.
|
||||
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const restoreIsolation = isolateOutside(overlay);
|
||||
const restoreScroll = lockBodyScroll();
|
||||
let lastFocused: HTMLElement = dialog;
|
||||
const focusables = () => focusableElements(dialog);
|
||||
const onFocusIn = (event: FocusEvent) => {
|
||||
if (event.target instanceof Node && dialog.contains(event.target)) {
|
||||
lastFocused = event.target instanceof HTMLElement ? event.target : dialog;
|
||||
return;
|
||||
}
|
||||
(lastFocused.isConnected && dialog.contains(lastFocused) ? lastFocused : focusables()[0] ?? dialog).focus();
|
||||
};
|
||||
const onKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== "Tab" || !dialogRef.current) return;
|
||||
const focusable = Array.from(dialogRef.current.querySelectorAll<HTMLElement>(
|
||||
'a[href], button:not([disabled]), input:not([disabled]), select:not([disabled]), textarea:not([disabled]), [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute("hidden"));
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
dialogRef.current.focus();
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault();
|
||||
last?.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first?.focus();
|
||||
} else {
|
||||
trapTab(event, dialog);
|
||||
}
|
||||
};
|
||||
|
||||
dialog.focus();
|
||||
document.addEventListener("focusin", onFocusIn, true);
|
||||
document.addEventListener("keydown", onKeyDown);
|
||||
return () => document.removeEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
document.removeEventListener("focusin", onFocusIn, true);
|
||||
document.removeEventListener("keydown", onKeyDown);
|
||||
restoreIsolation();
|
||||
restoreScroll();
|
||||
if (previouslyFocused?.isConnected) previouslyFocused.focus();
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) return null;
|
||||
return (
|
||||
<div className="modal-overlay" onClick={onClose}>
|
||||
if (!open || typeof document === "undefined") return null;
|
||||
return createPortal(
|
||||
<div className="modal-overlay" data-modal-overlay onClick={(event) => { if (event.target === event.currentTarget) onCloseRef.current(); }}>
|
||||
<div
|
||||
className={`modal ${className}`.trim()}
|
||||
role="dialog"
|
||||
@@ -68,10 +63,11 @@ export function Modal({ open, title, onClose, children, className = "" }: { open
|
||||
>
|
||||
<div className="modal-heading">
|
||||
<h2 id={titleId}>{title}</h2>
|
||||
<button className="modal-close" onClick={onClose} aria-label="Close dialog">×</button>
|
||||
<button className="modal-close" type="button" onClick={() => onCloseRef.current()} aria-label="Close dialog">×</button>
|
||||
</div>
|
||||
<div className="modal-body">{children}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import type { ManagementApiClient } from "../contracts";
|
||||
import { formatBrusselsTime } from "../format";
|
||||
import { readToolHistory, TOOL_HISTORY_EVENT, type ToolRunMetadata } from "../tool-history";
|
||||
|
||||
function reopenUrl(run: ToolRunMetadata): string {
|
||||
const params = new URLSearchParams({ reopen: run.tool, project: run.project, environment: run.environment });
|
||||
return `/tools?${params.toString()}`;
|
||||
}
|
||||
|
||||
export function RecentToolRuns({ navigate }: {
|
||||
/** Accepted so callers can mount this beside API-backed tool content; recent
|
||||
* runs deliberately read only the bounded browser metadata store. */
|
||||
api?: ManagementApiClient;
|
||||
navigate: (next: string) => void;
|
||||
}) {
|
||||
const [runs, setRuns] = useState<ToolRunMetadata[]>(() => readToolHistory());
|
||||
|
||||
useEffect(() => {
|
||||
const reload = () => setRuns(readToolHistory());
|
||||
window.addEventListener(TOOL_HISTORY_EVENT, reload);
|
||||
window.addEventListener("storage", reload);
|
||||
window.addEventListener("focus", reload);
|
||||
return () => {
|
||||
window.removeEventListener(TOOL_HISTORY_EVENT, reload);
|
||||
window.removeEventListener("storage", reload);
|
||||
window.removeEventListener("focus", reload);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return <section className="panel recent-tool-runs" aria-labelledby="recent-tool-runs-title">
|
||||
<div className="panel-heading"><div><p className="eyebrow">Operator history</p><h3 id="recent-tool-runs-title">Recent Tool Runs</h3></div><span className="tag">Session metadata only</span></div>
|
||||
<p className="muted">Recent success and controlled-error records include scope and trace only. Arguments, results, errors and credentials are never retained here.</p>
|
||||
{runs.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No recent tool runs</strong><small>Completed invocations in this browser session will appear here.</small></span></div> : <ol className="recent-run-list">
|
||||
{runs.map((run) => <li className="recent-run" key={`${run.traceId}:${run.at}`}>
|
||||
<div className="recent-run-main">
|
||||
<div className="recent-run-heading"><strong className="recent-run-tool">{run.tool}</strong><span className={run.ok ? "recent-run-outcome recent-run-success" : "recent-run-outcome recent-run-error"}>{run.ok ? "Success" : "Controlled error"}</span></div>
|
||||
<dl><div><dt>Project</dt><dd>{run.project}</dd></div><div><dt>Environment</dt><dd>{run.environment}</dd></div><div><dt>Time</dt><dd>{formatBrusselsTime(run.at)}</dd></div><div><dt>Trace</dt><dd><code>{run.traceId}</code></dd></div></dl>
|
||||
</div>
|
||||
<button className="button button-small button-secondary recent-run-open" onClick={() => navigate(reopenUrl(run))}>Open tool context</button>
|
||||
</li>)}
|
||||
</ol>}
|
||||
</section>;
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import { useId, type ChangeEvent } from "react";
|
||||
import type { ToolInputProperty, ToolInputSchema } from "../schema-input";
|
||||
import { NULL_INPUT_VALUE } from "../schema-input";
|
||||
|
||||
function inputType(property: ToolInputProperty): string {
|
||||
const types = Array.isArray(property.type) ? property.type : [property.type ?? "string"];
|
||||
return types.find((type) => type !== "null") ?? "string";
|
||||
}
|
||||
|
||||
function optionKey(value: unknown): string {
|
||||
try {
|
||||
return JSON.stringify(value) ?? String(value);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
}
|
||||
|
||||
function optionLabel(value: unknown): string {
|
||||
return typeof value === "string" ? value : optionKey(value);
|
||||
}
|
||||
|
||||
export function SchemaInputFields({
|
||||
schema,
|
||||
rawValues,
|
||||
errors,
|
||||
onChange,
|
||||
disabled = false,
|
||||
}: {
|
||||
schema: ToolInputSchema;
|
||||
rawValues: Record<string, string>;
|
||||
errors: Record<string, string>;
|
||||
onChange: (name: string, value: string) => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
const idPrefix = useId();
|
||||
const properties = Object.entries(schema.properties ?? {});
|
||||
const required = new Set(schema.required ?? []);
|
||||
|
||||
if (properties.length === 0) return <p className="muted schema-input-empty">This tool takes no arguments.</p>;
|
||||
|
||||
return <div className="form-grid readiness-form schema-inputs">
|
||||
{properties.map(([name, property]) => {
|
||||
const id = `${idPrefix}-${name}`;
|
||||
const hintId = `${id}-hint`;
|
||||
const errorId = `${id}-error`;
|
||||
const isRequired = required.has(name);
|
||||
const raw = rawValues[name] ?? "";
|
||||
const error = errors[name];
|
||||
const type = inputType(property);
|
||||
const nullableByType = Array.isArray(property.type) && property.type.includes("null");
|
||||
const nullAllowed = nullableByType && (!property.enum || property.enum.some((value) => value === null));
|
||||
const setValue = (value: string) => onChange(name, value);
|
||||
const fieldProps = {
|
||||
id,
|
||||
"aria-required": isRequired,
|
||||
"aria-invalid": Boolean(error),
|
||||
"aria-describedby": error ? `${hintId} ${errorId}` : hintId,
|
||||
disabled,
|
||||
value: raw,
|
||||
onChange: (event: ChangeEvent<HTMLInputElement | HTMLSelectElement | HTMLTextAreaElement>) => setValue(event.currentTarget.value),
|
||||
};
|
||||
const isNull = raw === NULL_INPUT_VALUE;
|
||||
|
||||
return <div className="schema-input-field" key={name}>
|
||||
<label className="schema-input-label" htmlFor={id}>{property.title ?? name}{isRequired ? <span className="required-mark" aria-hidden="true"> *</span> : null}</label>
|
||||
{property.enum ? <select {...fieldProps}>
|
||||
<option value="">Select a value…</option>
|
||||
{property.enum.map((option) => <option key={optionKey(option)} value={optionKey(option)}>{optionLabel(option)}</option>)}
|
||||
</select> : type === "boolean" ? <select {...fieldProps}>
|
||||
<option value="">Choose…</option>
|
||||
<option value="true">Yes</option>
|
||||
<option value="false">No</option>
|
||||
{nullAllowed ? <option value={NULL_INPUT_VALUE}>Null</option> : null}
|
||||
</select> : <>
|
||||
{nullAllowed ? <select
|
||||
className="schema-null-choice"
|
||||
aria-label={`${property.title ?? name} value`}
|
||||
value={isNull ? NULL_INPUT_VALUE : "value"}
|
||||
disabled={disabled}
|
||||
onChange={(event) => setValue(event.currentTarget.value === NULL_INPUT_VALUE ? NULL_INPUT_VALUE : raw === NULL_INPUT_VALUE ? "" : raw)}
|
||||
><option value="value">Set a value</option><option value={NULL_INPUT_VALUE}>Null</option></select> : null}
|
||||
{isNull ? <span className="schema-null-value">Null will be sent for this field.</span> : type === "object" || type === "array" ? <textarea
|
||||
{...fieldProps}
|
||||
rows={5}
|
||||
spellCheck={false}
|
||||
placeholder={type === "array" ? "Enter a JSON array" : "Enter a JSON object"}
|
||||
/> : <input
|
||||
{...fieldProps}
|
||||
type={type === "integer" || type === "number" ? "number" : "text"}
|
||||
step={type === "integer" ? 1 : type === "number" ? "any" : undefined}
|
||||
min={type === "integer" || type === "number" ? property.minimum : undefined}
|
||||
max={type === "integer" || type === "number" ? property.maximum : undefined}
|
||||
minLength={type === "string" ? property.minLength : undefined}
|
||||
maxLength={type === "string" ? property.maxLength : undefined}
|
||||
disabled={isNull}
|
||||
placeholder={property.description ? "Enter a value" : undefined}
|
||||
/>}
|
||||
</>}
|
||||
<small id={hintId}>{property.description ? `${property.description} ` : ""}{isRequired ? "Required." : "Optional."}</small>
|
||||
{error ? <small className="schema-field-error" id={errorId} role="alert">{error}</small> : null}
|
||||
</div>;
|
||||
})}
|
||||
</div>;
|
||||
}
|
||||
@@ -17,5 +17,19 @@ export function SessionMenu({ subject, roles }: { subject: string | null; roles:
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
return <div className="session-menu"><span className="tag">Authenticated</span><div className="session-identity"><span className="avatar" aria-hidden="true">{initial}</span><span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span></div><button className="button button-small button-secondary" type="button" disabled={busy} onClick={() => void signOut()}>{busy ? "Signing out…" : "Sign out"}</button>{error ? <span className="session-error" role="alert">{error}</span> : null}</div>;
|
||||
return <div className="session-menu">
|
||||
<span className="tag">Authenticated</span>
|
||||
<div className="session-identity">
|
||||
<span className="avatar" aria-hidden="true">{initial}</span>
|
||||
<span><strong>{subject ?? "Authenticated user"}</strong><small>{roles.join(" · ") || "management"}</small></span>
|
||||
</div>
|
||||
<button
|
||||
className="button button-small button-secondary session-signout"
|
||||
type="button"
|
||||
disabled={busy}
|
||||
aria-busy={busy}
|
||||
onClick={() => void signOut()}
|
||||
>{busy ? "Signing out…" : "Sign out"}</button>
|
||||
{error ? <span className="session-error" role="alert">{error}</span> : null}
|
||||
</div>;
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
const FOCUSABLE_SELECTOR = [
|
||||
"a[href]",
|
||||
"area[href]",
|
||||
"button:not([disabled])",
|
||||
"input:not([disabled])",
|
||||
"select:not([disabled])",
|
||||
"textarea:not([disabled])",
|
||||
"iframe",
|
||||
"object",
|
||||
"embed",
|
||||
"[contenteditable='true']",
|
||||
"[tabindex]:not([tabindex='-1'])",
|
||||
].join(",");
|
||||
|
||||
export function focusableElements(container: HTMLElement): HTMLElement[] {
|
||||
return Array.from(container.querySelectorAll<HTMLElement>(FOCUSABLE_SELECTOR)).filter((element) => {
|
||||
if (element.hidden || element.closest("[hidden], [inert], [aria-hidden='true']")) return false;
|
||||
const closedDetails = element.closest("details:not([open])");
|
||||
if (closedDetails && !closedDetails.querySelector(":scope > summary")?.contains(element)) return false;
|
||||
let ancestor: HTMLElement | null = element;
|
||||
while (ancestor) {
|
||||
const style = window.getComputedStyle(ancestor);
|
||||
if (style.display === "none" || style.visibility === "hidden" || style.contentVisibility === "hidden") return false;
|
||||
if (ancestor === container) break;
|
||||
ancestor = ancestor.parentElement;
|
||||
}
|
||||
return element.getClientRects().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
export function trapTab(event: KeyboardEvent, container: HTMLElement): void {
|
||||
if (event.key !== "Tab") return;
|
||||
const focusable = focusableElements(container);
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
|
||||
if (!first || !last) {
|
||||
event.preventDefault();
|
||||
container.focus();
|
||||
return;
|
||||
}
|
||||
if (!container.contains(active)) {
|
||||
event.preventDefault();
|
||||
(event.shiftKey ? last : first).focus();
|
||||
} else if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
/** Make everything outside a focus scope unavailable until its cleanup runs. */
|
||||
export function isolateOutside(container: HTMLElement, interactiveExceptions: HTMLElement[] = []): () => void {
|
||||
const body = document.body;
|
||||
const changed = new Map<HTMLElement, { inert: boolean; ariaHidden: string | null }>();
|
||||
const protect = (element: HTMLElement) => {
|
||||
if (changed.has(element)) return;
|
||||
changed.set(element, { inert: element.inert, ariaHidden: element.getAttribute("aria-hidden") });
|
||||
element.inert = true;
|
||||
element.setAttribute("aria-hidden", "true");
|
||||
};
|
||||
const isException = (element: HTMLElement) => interactiveExceptions.some((exception) => element === exception || element.contains(exception));
|
||||
|
||||
let branch: HTMLElement = container;
|
||||
let parent = branch.parentElement;
|
||||
while (parent) {
|
||||
for (const sibling of Array.from(parent.children)) {
|
||||
if (sibling !== branch && sibling instanceof HTMLElement && !isException(sibling)) protect(sibling);
|
||||
}
|
||||
if (parent === body) break;
|
||||
branch = parent;
|
||||
parent = parent.parentElement;
|
||||
}
|
||||
|
||||
return () => {
|
||||
for (const [element, original] of changed) {
|
||||
element.inert = original.inert;
|
||||
if (original.ariaHidden === null) element.removeAttribute("aria-hidden");
|
||||
else element.setAttribute("aria-hidden", original.ariaHidden);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** Lock document scrolling while preserving inline styles and the scroll position. */
|
||||
export function lockBodyScroll(): () => void {
|
||||
const body = document.body;
|
||||
const root = document.documentElement;
|
||||
const scrollX = window.scrollX;
|
||||
const scrollY = window.scrollY;
|
||||
const styleNames = ["overflow", "position", "top", "left", "right", "width"] as const;
|
||||
const bodyStyles = new Map(styleNames.map((name) => [name, { value: body.style.getPropertyValue(name), priority: body.style.getPropertyPriority(name) }]));
|
||||
const rootOverflow = { value: root.style.getPropertyValue("overflow"), priority: root.style.getPropertyPriority("overflow") };
|
||||
|
||||
body.style.overflow = "hidden";
|
||||
body.style.position = "fixed";
|
||||
body.style.top = `${-scrollY}px`;
|
||||
body.style.left = `${-scrollX}px`;
|
||||
body.style.right = "0";
|
||||
body.style.width = "100%";
|
||||
root.style.overflow = "hidden";
|
||||
|
||||
return () => {
|
||||
for (const [name, original] of bodyStyles) {
|
||||
if (original.value) body.style.setProperty(name, original.value, original.priority);
|
||||
else body.style.removeProperty(name);
|
||||
}
|
||||
if (rootOverflow.value) root.style.setProperty("overflow", rootOverflow.value, rootOverflow.priority);
|
||||
else root.style.removeProperty("overflow");
|
||||
window.scrollTo(scrollX, scrollY);
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user