Publish curated ITWorx_MCP_Hub source from 6c7434368ce2a594005983c5a6601a31a639e7f1

This commit is contained in:
Public Source Publisher committed 2026-10-01 18:03:00 +00:00
1 parent 77946d62c4
commit 6d40ab975c
86 files changed
+6784 -1478

No files matched your search

+5 -5
View File
@@ -19,17 +19,17 @@
},
"devDependencies": {
"@axe-core/playwright": "4.11.0",
"@playwright/test": "^1.62.1",
"@types/node": "^26.1.2",
"@playwright/test": "1.62.1",
"@types/node": "26.1.2",
"@types/react": "19.1.10",
"@types/react-dom": "19.1.7",
"@typescript-eslint/parser": "8.39.0",
"@vitejs/plugin-react": "4.7.0",
"@vitest/coverage-v8": "3.2.4",
"@vitest/coverage-v8": "4.1.11",
"eslint": "9.35.0",
"eslint-plugin-react-hooks": "5.2.0",
"typescript": "5.9.2",
"vite": "7.1.3",
"vitest": "3.2.4"
"vite": "7.3.5",
"vitest": "4.1.11"
}
}
+9 -1
View File
@@ -9,6 +9,7 @@ const DIST = path.join(WEB_DIR, "dist");
const WEB_PORT = 8780;
const CONTROL_PORT = 8781;
const OIDC_PORT = 8782;
const WORKER_PORT = 8783;
export default defineConfig({
testDir: "./tests/e2e",
@@ -43,7 +44,14 @@ export default defineConfig({
cwd: REPO_ROOT,
},
{
command: `"${PYTHON}" -m tests.e2e.harness web --port ${WEB_PORT} --control-port ${CONTROL_PORT} --dist "${DIST}"`,
command: `"${PYTHON}" -m tests.e2e.harness worker --port ${WORKER_PORT}`,
url: `http://127.0.0.1:${WORKER_PORT}/health/ready`,
reuseExistingServer: false,
timeout: 90_000,
cwd: REPO_ROOT,
},
{
command: `"${PYTHON}" -m tests.e2e.harness web --port ${WEB_PORT} --control-port ${CONTROL_PORT} --worker-port ${WORKER_PORT} --dist "${DIST}"`,
url: `https://127.0.0.1:${WEB_PORT}/ready`,
ignoreHTTPSErrors: true,
reuseExistingServer: false,
+18 -3
View File
@@ -1,4 +1,4 @@
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, ClientOnboardingStatus, ConnectorDetailInfo, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
export class ApiError extends Error {
readonly status: number;
@@ -24,8 +24,9 @@ export async function logoutSession(): Promise<void> {
export function createApiClient(): ManagementApiClient { let csrfToken: string | null = null; return {
session: async () => { const raw = await getJson<{ authenticated: boolean; csrf_token?: string; session?: { subject: string; client_id: string | null; kind: string; roles: string[]; expires_at: string } }>("/auth/session"); csrfToken = raw.csrf_token ?? null; return { authenticated: raw.authenticated, csrf_token: raw.csrf_token, session: raw.session ? { subject: raw.session.subject, clientId: raw.session.client_id, kind: raw.session.kind as "management_user" | "machine_client", roles: raw.session.roles as ManagementRole[], expiresAt: raw.session.expires_at } : undefined }; },
dashboard: () => getJson<DashboardSnapshot>("/api/dashboard"),
resource: (kind: ResourceKind, query: PageQuery) => { const params = new URLSearchParams({ q: query.filter, limit: String(query.limit) }); if (query.cursor) params.set("cursor", query.cursor); return getJson<ResourcePage>(`/api/${kind}?${params.toString()}`); },
resource: (kind: ResourceKind, query: PageQuery) => { const params = new URLSearchParams({ q: query.filter, limit: String(query.limit) }); if (query.cursor) params.set("cursor", query.cursor); if (query.project) params.set("project", query.project); if (query.environment) params.set("environment", query.environment); return getJson<ResourcePage>(`/api/${kind}?${params.toString()}`); },
projectDetail: (slug) => getJson<ProjectDetailInfo>(`/api/projects/${encodeURIComponent(slug)}`),
connectorDetail: (slug, scope) => { const params = new URLSearchParams(); if (scope) { params.set("project", scope.project); params.set("environment", scope.environment); } const value = params.toString(); const query = value ? `?${value}` : ""; return getJson<ConnectorDetailInfo>(`/api/connectors/${encodeURIComponent(slug)}${query}`); },
simulatePolicy: (input) => postJson<PolicySimulation>("/api/policies/simulate", input, csrfToken),
testTool: (input) => postJson<ToolReadinessResult>("/api/tools/test", input, csrfToken),
toolDefinition: (canonicalName) => getJson<ToolDefinition>(`/api/tool-definitions/${encodeURIComponent(canonicalName)}`),
@@ -36,6 +37,20 @@ export function createApiClient(): ManagementApiClient { let csrfToken: string |
applyCatalog: (input) => postJson<ClientActionResult>("/api/catalog/apply", input, csrfToken),
clientAction: async (id, action) => { await postJson(`/api/client-management/${encodeURIComponent(id)}/${action}`, {}, csrfToken); return { accepted: true, message: `Client ${action} completed.` }; },
createClient: (input: { name: string; project: string; environment: string; kind: OnboardingClientKind }) => postJson<ClientOnboardingResult>("/api/client-management", input, csrfToken),
generatedConfig: (id) => getJson<{ clientId: string; endpoint: string; scopes: string[] }>(`/api/client-management/${encodeURIComponent(id)}/config`),
generatedConfig: async (id) => {
const raw = await getJson<ClientOnboardingResult["config"]>(`/api/client-management/${encodeURIComponent(id)}/config`);
return {
clientId: raw.clientId,
kind: raw.kind,
protocolVersion: raw.protocolVersion,
endpoint: raw.endpoint,
transport: raw.transport,
scopes: raw.scopes,
...(typeof raw.environmentVariable === "string" ? { environmentVariable: raw.environmentVariable } : {}),
...(typeof raw.bridgeCommand === "string" ? { bridgeCommand: raw.bridgeCommand } : {}),
};
},
clientOnboardingStatus: (id) => getJson<ClientOnboardingStatus>(`/api/client-management/${encodeURIComponent(id)}/onboarding-status`),
verifyClientOnboarding: (id) => postJson<ClientOnboardingStatus>(`/api/client-management/${encodeURIComponent(id)}/verify`, {}, csrfToken),
}; }
export function safeApiMessage(error: unknown): string { return error instanceof ApiError ? error.message : "The control API returned no usable data."; }
+81 -27
View File
@@ -1,10 +1,10 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { DashboardSnapshot, ManagementApiClient, ManagementRole, SessionStatus } from "./contracts";
import type { ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, SessionStatus } from "./contracts";
import { routeTitle } from "./contracts";
import { ApiError, createApiClient, safeApiMessage } from "./api";
import { RbacProvider } from "./rbac-context";
import { can } from "./rbac";
import { basePath, resourceKind, usePath } from "./routing";
import { basePath, connectorDetailId, resourceKind, usePath } from "./routing";
import { Brand } from "./components/Brand";
import { CommandMenu } from "./components/CommandMenu";
import { ErrorPanel } from "./components/ErrorPanel";
@@ -19,6 +19,8 @@ import { Operations } from "./pages/Operations";
import { ProjectDetail } from "./pages/ProjectDetail";
import { ResourcePage } from "./pages/ResourcePage";
import { SettingsPage } from "./pages/SettingsPage";
import { ConnectorDetail } from "./pages/ConnectorDetail";
import { focusableElements, isolateOutside, lockBodyScroll, trapTab } from "./components/modal-focus";
export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const [api] = useState<ManagementApiClient>(() => apiProp ?? createApiClient());
@@ -31,19 +33,24 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const [subject, setSubject] = useState<string | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const [createdClient, setCreatedClient] = useState<ClientOnboardingResult["client"] | null>(null);
const identitySubject = useRef<string | null>(null);
identitySubject.current = session === "authenticated" ? subject : null;
const navTriggerRef = useRef<HTMLButtonElement>(null);
const title = useMemo(() => routeTitle(basePath(path)), [path]);
const navCloseRef = useRef<HTMLButtonElement>(null);
const navSidebarRef = useRef<HTMLElement>(null);
const navBackdropRef = useRef<HTMLButtonElement>(null);
const [compactNavigation, setCompactNavigation] = useState(() => window.matchMedia("(max-width: 840px)").matches);
const title = useMemo(() => connectorDetailId(path) ? "Connector detail" : routeTitle(basePath(path)), [path]);
const clearIdentity = () => {
setRoles([]);
setSnapshot(null);
setSubject(null);
setCreatedClient(null);
};
const closeNavigation = (restoreFocus = true) => {
setNavOpen(false);
if (restoreFocus) window.setTimeout(() => navTriggerRef.current?.focus(), 0);
};
const closeNavigation = () => setNavOpen(false);
const load = async () => {
setSession("checking");
@@ -90,13 +97,45 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
useEffect(() => { void load(); }, []);
useEffect(() => { setNavOpen(false); }, [path]);
useEffect(() => {
if (!navOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeNavigation();
const media = window.matchMedia("(max-width: 840px)");
const update = () => {
setCompactNavigation(media.matches);
if (!media.matches) setNavOpen(false);
};
media.addEventListener("change", update);
return () => media.removeEventListener("change", update);
}, []);
useEffect(() => {
const sidebar = navSidebarRef.current;
if (!navOpen || !compactNavigation || !sidebar) return;
const previouslyFocused = document.activeElement instanceof HTMLElement ? document.activeElement : null;
const backdrop = navBackdropRef.current;
const restoreIsolation = isolateOutside(sidebar, backdrop ? [backdrop] : []);
const restoreScroll = lockBodyScroll();
const focusables = () => focusableElements(sidebar);
const onFocusIn = (event: FocusEvent) => {
if (event.target instanceof Node && sidebar.contains(event.target)) return;
(focusables()[0] ?? sidebar).focus();
};
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
setNavOpen(false);
} else {
trapTab(event, sidebar);
}
};
navCloseRef.current?.focus();
document.addEventListener("focusin", onFocusIn, true);
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [navOpen]);
return () => {
document.removeEventListener("focusin", onFocusIn, true);
document.removeEventListener("keydown", onKeyDown);
restoreIsolation();
restoreScroll();
if (previouslyFocused?.isConnected) previouslyFocused.focus();
};
}, [navOpen, compactNavigation]);
useEffect(() => {
if (session !== "authenticated") return;
const onKeyDown = (event: KeyboardEvent) => {
@@ -114,6 +153,9 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const kind = resourceKind(path);
const detail = basePath(path).startsWith("/projects/");
const connectorId = connectorDetailId(path);
const scopeParams = new URLSearchParams(path.split("?")[1] ?? "");
const scopeKey = `${scopeParams.get("project") ?? ""}:${scopeParams.get("environment") ?? ""}`;
const canReadAudit = can(roles, "audit.read");
const active = (href: string) => basePath(path) === href || (href !== "/" && basePath(path).startsWith(`${href}/`));
const authenticated = session === "authenticated";
@@ -125,27 +167,38 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
: loadState === "error"
? <ErrorPanel message={error} expired={session === "expired"} retry={() => void load()} />
: detail
? <ProjectDetail path={path} navigate={navigate} api={api} />
: kind
? <ResourcePage path={path} kind={kind} api={api} navigate={navigate} />
: snapshot && basePath(path) === "/"
? <Dashboard snapshot={snapshot} reload={() => void load()} navigate={navigate} />
: basePath(path) === "/operations"
? <Operations path={path} navigate={navigate} snapshot={snapshot} />
: basePath(path) === "/settings"
? <SettingsPage path={path} navigate={navigate} />
: <ForbiddenPanel />;
? <ProjectDetail key={basePath(path)} path={path} navigate={navigate} api={api} />
: connectorId
? <ConnectorDetail key={`${connectorId}:${scopeKey}`} path={path} navigate={navigate} api={api} />
: kind
? <ResourcePage key={`${kind}:${scopeKey}`} path={path} kind={kind} api={api} navigate={navigate} onClientCreated={(client) => { if (subject !== null && identitySubject.current === subject) setCreatedClient(client); }} />
: snapshot && basePath(path) === "/"
? <Dashboard snapshot={snapshot} api={api} reload={() => void load()} navigate={navigate} />
: basePath(path) === "/operations"
? <Operations path={path} navigate={navigate} snapshot={snapshot} api={api} />
: basePath(path) === "/settings"
? <SettingsPage path={path} navigate={navigate} />
: <ForbiddenPanel />;
return (
<RbacProvider roles={roles}>
<a className="skip-link" href="#main-content">Skip to main content</a>
<div className={authenticated ? "app-shell" : "preauth-shell"}>
{authenticated ? <>
{navOpen ? <button type="button" className="nav-backdrop" onClick={() => closeNavigation()} aria-label="Dismiss navigation" /> : null}
<aside className={navOpen ? "sidebar sidebar-open" : "sidebar"} aria-label="Application sidebar">
{navOpen ? <button ref={navBackdropRef} type="button" className="nav-backdrop" onClick={closeNavigation} aria-label="Dismiss navigation" aria-hidden="true" tabIndex={-1} /> : null}
<aside
ref={navSidebarRef}
className={navOpen ? "sidebar sidebar-open" : "sidebar"}
id="primary-navigation-panel"
aria-label="Application navigation"
role={compactNavigation && navOpen ? "dialog" : undefined}
aria-modal={compactNavigation && navOpen ? true : undefined}
aria-hidden={compactNavigation && !navOpen ? true : undefined}
inert={compactNavigation && !navOpen}
>
<div className="sidebar-head">
<a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}><Brand /></a>
<button type="button" className="nav-close" aria-label="Close navigation" onClick={() => closeNavigation()}>×</button>
<button ref={navCloseRef} type="button" className="nav-close" aria-label="Close navigation" onClick={closeNavigation}>×</button>
</div>
<nav aria-label="Primary navigation">
{navigation.map((group) => {
@@ -170,7 +223,7 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
</> : null}
<main className="main-content" id="main-content" tabIndex={-1}>
{authenticated ? <header className="topbar">
<button ref={navTriggerRef} type="button" className="nav-trigger" aria-label="Open navigation" aria-expanded={navOpen} onClick={() => setNavOpen(true)}><span aria-hidden="true" /><span aria-hidden="true" /><span aria-hidden="true" /></button>
<button ref={navTriggerRef} type="button" className="nav-trigger" aria-label="Open navigation" aria-expanded={navOpen} aria-controls="primary-navigation-panel" onClick={() => setNavOpen(true)}><span aria-hidden="true" /><span aria-hidden="true" /><span aria-hidden="true" /></button>
<div className="topbar-title"><p className="eyebrow">Governance control plane</p><h1>{title}</h1></div>
<div className="topbar-actions">
<button className="command-trigger" type="button" onClick={() => setCommandOpen(true)} aria-label="Open quick jump">
@@ -179,10 +232,11 @@ export function App({ api: apiProp }: { api?: ManagementApiClient }) {
<SessionMenu subject={subject} roles={roles} />
</div>
</header> : null}
{authenticated && createdClient ? <div className="notice notice-info client-created-notice" role="status"><span>Pending client {createdClient.name} created for {createdClient.project} / {createdClient.environment}.</span><a href={`/clients?onboard=${encodeURIComponent(createdClient.id)}`} onClick={(event) => { event.preventDefault(); navigate(`/clients?onboard=${encodeURIComponent(createdClient.id)}`); setCreatedClient(null); }}>Resume onboarding</a><button type="button" aria-label="Dismiss client creation notice" onClick={() => setCreatedClient(null)}>Dismiss</button></div> : null}
{content}
</main>
</div>
{authenticated ? <CommandMenu open={commandOpen} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null}
{authenticated ? <CommandMenu open={commandOpen} api={api} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null}
</RbacProvider>
);
}
+211 -24
View File
@@ -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>
);
}
+16 -5
View File
@@ -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}
+38 -42
View File
@@ -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>;
}
+15 -1
View File
@@ -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>;
}
+114
View File
@@ -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);
};
}
+60 -7
View File
@@ -6,7 +6,11 @@ export type PrincipalKind = "management_user" | "machine_client";
export interface SessionMetadata { subject: string; clientId: string | null; kind: PrincipalKind; roles: ManagementRole[]; expiresAt: string; }
export interface SessionInfo { authenticated: boolean; csrf_token?: string; session?: SessionMetadata; }
export interface ProjectStatus { slug: string; name: string; environment: string; health: Health; detail: string; }
export interface ConnectorStatus { name: string; project: string; state: Health; detail: string; }
export interface ConnectorStatus {
id: string; name: string; project: string; state: Health; detail: string;
registrationState: "registered" | "invalid";
readiness: { state: Health; detail: string; observedAt: string | null; source: "not-probed" };
}
export interface DashboardSnapshot {
generatedAt: string; staleAfterSeconds: number;
health: { hub: Health; gateway: Health; detail: string };
@@ -18,9 +22,9 @@ export interface DashboardSnapshot {
}
export interface ApiClient { session(): Promise<SessionInfo>; dashboard(): Promise<DashboardSnapshot>; }
export type ResourceKind = "projects" | "servers" | "tools" | "connectors" | "clients" | "policies" | "audit";
export interface PageQuery { filter: string; cursor?: string; limit: number; }
export interface PageQuery { filter: string; cursor?: string; limit: number; project?: string; environment?: string; }
export interface PageInfo { nextCursor?: string; total: number; }
export interface ResourceRecord { id: string; name: string; status: Health; summary: string; project?: string; environment?: string; risk?: string; decision?: string; traceId?: string; canonicalName?: string; }
export interface ResourceRecord { id: string; name: string; status: Health; summary: string; project?: string; environment?: string; risk?: string; decision?: string; traceId?: string; canonicalName?: string; onboardingStage?: string; }
export interface ResourcePage { kind: ResourceKind; generatedAt: string; rows: ResourceRecord[]; page: PageInfo; }
export type PolicyDecision = "allow" | "allow_with_preview" | "allow_with_approval" | "allow_if_conditions" | "enforce" | "deny" | "deny_and_alert";
export interface PolicyTraceStep { phase: string; rule: string | null; matched: boolean; reason: string; }
@@ -38,17 +42,63 @@ export interface ToolReadinessResult {
export interface ToolDefinition { canonicalName: string; description: string; readOnly: boolean; riskClass: string; inputSchema: { type?: string; required?: string[]; properties?: Record<string, { type?: string | string[]; title?: string; description?: string; default?: unknown; enum?: unknown[]; minimum?: number; maximum?: number; minLength?: number; maxLength?: number }> }; limits: { timeoutMs?: number; maxOutputBytes?: number; maxItems?: number }; }
export interface ToolInvocationResult { ok: boolean; traceId: string; tool: string; content?: Array<{ type?: string; text?: string }>; structuredContent?: unknown; warnings?: string[]; error?: { code: string; message: string }; }
export interface OperationsSummary { generatedAt: string; backup: DashboardSnapshot["backup"]; audit24h: { success: number; denied: number; error: number; rate_limited: number }; auditTruncated: boolean; trend: Array<{ hour: string; success: number; denied: number; error: number; rate_limited: number }>; alerts: Array<{ severity: "info" | "warning"; code: string; message: string }>; }
export interface OperationsCheck { generatedAt: string; healthy: boolean; connectors: Record<string, { reachable: boolean; status: string; detail: string }>; }
export interface OperationsCheck { generatedAt: string; healthy: boolean; connectors: Record<string, { reachable: boolean; status: string; detail: string; connectorRefs?: Array<{ id: string; name: string }>; connectorRefsTruncated?: boolean }>; }
export interface CatalogPreview { valid: boolean; changes: string[]; errors: string[]; }
export interface ClientActionResult { accepted: boolean; message: string; }
export type OnboardingClientKind = "native-2026" | "compatibility-2025" | "stdio-bridge" | "inspector";
export interface ClientOnboardingResult { client: { id: string; name: string; project: string; environment: string; status: string }; bootstrap: { reference: string; delivery: string; expiresAt: string }; config: { clientId: string; kind: OnboardingClientKind; protocolVersion: string; endpoint: string; transport: string; scopes: string[]; credentialReference: string; environmentVariable?: string; bridgeCommand?: string }; provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] }; }
export interface GeneratedClientConfig {
clientId: string; kind: OnboardingClientKind; protocolVersion: string; endpoint: string;
transport: string; scopes: string[]; environmentVariable?: string | null; bridgeCommand?: string | null;
}
export interface ClientOnboardingResult {
client: { id: string; name: string; project: string; environment: string; status: string };
bootstrap: { reference: string; delivery: string; expiresAt: string };
config: GeneratedClientConfig & { credentialReference: string };
provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] };
}
export interface ProjectEnvironment { name: string; classification: string | null; }
export interface ProjectConnectorBinding { ref: string; connectorType: string | null; environments: string[]; verification: string | null; }
export interface ProjectDetailInfo { slug: string; name: string; description: string; environments: ProjectEnvironment[]; capabilityPacks: string[]; connectorBindings: ProjectConnectorBinding[]; virtualServerCount: number; clientCount: number; activeClientCount: number; }
export interface ProjectMcpServer {
slug: string; environment: string; endpointAlias: string; protocolRevisions: string[];
defaultDecision: string; runtimeState: string; enabled: boolean | null; stateSource: "persisted" | "catalog-only";
stateDetail: string;
}
export interface ProjectKnowledgeSource {
id: string; uri: string; include: string[]; verification: string; maxDocumentBytes: number | null;
indexesSecrets: boolean; accessBoundary: string;
}
export interface ProjectDetailInfo {
slug: string; name: string; description: string; environments: ProjectEnvironment[];
capabilityPacks: string[]; connectorBindings: ProjectConnectorBinding[];
mcpServers: ProjectMcpServer[]; knowledgeSources: ProjectKnowledgeSource[];
virtualServerCount: number; clientCount: number; activeClientCount: number;
}
export interface ConnectorDetailInfo {
id: string; name: string; connectorType: string; description: string;
registration: { state: "registered" | "invalid"; detail: string };
readiness: { reachable: boolean | null; status: string; detail: string; observedAt: string | null; source: "direct-probe" | "persisted" | "unavailable" };
bindings: Array<{ project: string; environment: string; verification: string }>;
relatedTools: Array<{ canonicalName: string; name: string; summary: string; risk: string; readOnly: boolean }>;
}
export type OnboardingCheckState = "ready" | "pending" | "blocked" | "degraded" | "healthy" | "unavailable" | "unknown";
export interface ClientOnboardingStatus {
clientId: string; project: string; environment: string;
clientStatus: "pending" | "enabled" | "disabled" | "revoked";
provisioning: { status: string; subject: string | null; issuer: string; oauthUsername: string; tokenEndpoint: string; oauthClientId: string; steps: string[] };
checks: {
externalSubject: { state: OnboardingCheckState; detail: string };
externalScope: { state: "unavailable"; detail: string };
hubBinding: { state: OnboardingCheckState; detail: string };
catalog: { state: OnboardingCheckState; detail: string };
connector: { state: OnboardingCheckState; detail: string; observedAt: string | null };
mcpProof: { state: "unavailable"; detail: string };
};
verification: { checkedAt: string | null; checkedByManagement: boolean; verifiedByManagement: boolean; hubChecksPassed: boolean; detail: string };
}
export interface ManagementApiClient extends ApiClient {
resource(kind: ResourceKind, query: PageQuery): Promise<ResourcePage>;
projectDetail(slug: string): Promise<ProjectDetailInfo>;
connectorDetail(slug: string, scope?: { project: string; environment: string }): Promise<ConnectorDetailInfo>;
simulatePolicy(input: { project: string; environment: string; tool: string; clientId?: string; capability?: string; scopes?: string[]; bindingPresent?: boolean; clientEnabled?: boolean }): Promise<PolicySimulation>;
testTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolReadinessResult>;
toolDefinition(canonicalName: string): Promise<ToolDefinition>;
@@ -59,7 +109,9 @@ export interface ManagementApiClient extends ApiClient {
applyCatalog(input: { project: string }): Promise<ClientActionResult>;
clientAction(id: string, action: "disable" | "revoke"): Promise<ClientActionResult>;
createClient(input: { name: string; project: string; environment: string; kind: OnboardingClientKind }): Promise<ClientOnboardingResult>;
generatedConfig(id: string): Promise<{ clientId: string; endpoint: string; scopes: string[] }>;
generatedConfig(id: string): Promise<GeneratedClientConfig>;
clientOnboardingStatus(id: string): Promise<ClientOnboardingStatus>;
verifyClientOnboarding(id: string): Promise<ClientOnboardingStatus>;
}
export function isSnapshotStale(snapshot: DashboardSnapshot, now = Date.now()): boolean {
const generated = Date.parse(snapshot.generatedAt);
@@ -69,5 +121,6 @@ export function routeTitle(pathname: string): string {
const route = pathname.replace(/\/$/, "") || "/";
if (route === "/") return "Overview";
if (route.startsWith("/projects/")) return "Project detail";
if (route.startsWith("/connectors/")) return "Connector detail";
return ({ "/projects": "Projects", "/servers": "MCP Servers", "/tools": "Catalog", "/connectors": "Connectors", "/clients": "Clients", "/policies": "Policies", "/operations": "Operations", "/audit": "Audit", "/settings": "Platform" } as Record<string, string>)[route] ?? "Not found";
}
+1 -1
View File
@@ -14,6 +14,6 @@ export function useProjectOptions(api: ManagementApiClient) {
* selected or the lookup is in flight. */
export function useProjectEnvironments(api: ManagementApiClient, project: string) {
const [environments, setEnvironments] = useState<string[]>([]);
useEffect(() => { if (!project) { setEnvironments([]); return; } let active = true; void api.projectDetail(project).then((detail) => { if (active) setEnvironments(detail.environments.map((item) => item.name)); }).catch(() => { if (active) setEnvironments([]); }); return () => { active = false; }; }, [api, project]);
useEffect(() => { setEnvironments([]); if (!project) return; let active = true; void api.projectDetail(project).then((detail) => { if (active) setEnvironments(detail.environments.map((item) => item.name)); }).catch(() => { if (active) setEnvironments([]); }); return () => { active = false; }; }, [api, project]);
return environments;
}
+7 -1
View File
@@ -2,8 +2,14 @@ import { createRoot } from "react-dom/client";
import { createElement } from "react";
import { App } from "./app";
import "./styles.css";
import "./styles/operator.css";
import "./styles/details.css";
import "./styles/workflows.css";
import "./styles/shell.css";
const storedTheme = localStorage.getItem("hub-theme");
import { readPreference } from "./preferences";
const storedTheme = readPreference("hub-theme", ["light", "dark"], "");
if (storedTheme === "light" || storedTheme === "dark") {
document.documentElement.setAttribute("data-theme", storedTheme);
}
+41
View File
@@ -0,0 +1,41 @@
import type { DashboardSnapshot, Health, OperationsSummary } from "./contracts";
export interface ActionSignal { title: string; detail: string; impact: string; nextStep: string; status: Health; href: string; subsystem: string; project?: string; observedAt: string | null; }
export function actionInbox(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): ActionSignal[] {
const signals: ActionSignal[] = [];
const add = (signal: Omit<ActionSignal, "observedAt">) => signals.push({ ...signal, observedAt: snapshot.generatedAt });
if (stale) add({ title: "Operational data is stale", detail: "Health information has exceeded its freshness window.", impact: "Current availability is unknown until refreshed.", nextStep: "Refresh status before acting", status: "degraded", href: "/operations", subsystem: "Freshness" });
for (const [subsystem, status, detail] of [["Hub", snapshot.health.hub, snapshot.health.detail], ["Gateway", snapshot.health.gateway, "Trusted ContextForge route availability is not healthy."]] as const) {
if (status !== "healthy") add({ title: `${subsystem} needs investigation`, detail, impact: "Governed requests may be unavailable.", nextStep: "Inspect operational checks", status, href: "/operations?section=checks", subsystem });
}
for (const connector of snapshot.connectors.filter((item) => item.state !== "healthy")) {
const href = connector.id ? `/connectors/${encodeURIComponent(connector.id)}` : `/connectors?q=${encodeURIComponent(connector.name)}`;
add({ title: `${connector.name} · ${connector.state === "unknown" ? "health not observed" : connector.state}`, detail: connector.detail, impact: connector.state === "unknown" ? "Availability has not been verified by a live probe." : "Tools using this integration may be affected.", nextStep: "Inspect connector and scoped bindings", status: connector.state, href, subsystem: connector.name, project: connector.project === "registered" ? undefined : connector.project });
}
if (!snapshot.drift.known || snapshot.drift.count > 0) add({ title: "Catalog reconciliation needs attention", detail: snapshot.drift.known ? `${snapshot.drift.count} drifted gateway(s): ${snapshot.drift.projects.join(", ") || "Inspect reconciliation"}` : snapshot.drift.detail, impact: snapshot.drift.known ? "Published discovery may differ from the intended catalog." : "Published catalog parity is unknown.", nextStep: "Review catalog checks", status: snapshot.drift.known ? "degraded" : "unknown", href: "/operations?section=checks", subsystem: "Catalog" });
if (snapshot.backup.state !== "healthy") add({ title: "Backup freshness needs attention", detail: snapshot.backup.detail, impact: "Recovery confidence requires a verified backup and restore record.", nextStep: "Inspect recovery evidence", status: snapshot.backup.state, href: "/operations?section=recovery", subsystem: "Recovery" });
if (snapshot.denials.count > 0) add({ title: "Recent authorization denials", detail: `${snapshot.denials.count} in ${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`, impact: "Requests were blocked by governance; the cause requires authorized audit inspection.", nextStep: canReadAudit ? "Inspect matching audit activity" : "Inspect operational alerts; detailed audit requires auditor access", status: "unknown", href: canReadAudit ? `/audit${snapshot.denials.latestTool ? `?q=${encodeURIComponent(snapshot.denials.latestTool)}` : ""}` : "/operations?section=alerts", subsystem: "Authorization" });
return signals;
}
export function alertDestination(code: string, canReadAudit: boolean): string {
if (/BACKUP|RESTORE|RECOVERY/i.test(code)) return "/operations?section=recovery";
if (/DRIFT|CATALOG|RECONCIL/i.test(code)) return "/tools";
if (/DENI|AUTH/i.test(code) && canReadAudit) return "/audit";
return "/operations?section=checks";
}
export type TrendBucket = OperationsSummary["trend"][number] & { endHour: string };
export function aggregateTrend(trend: OperationsSummary["trend"], size: number): TrendBucket[] {
const buckets: TrendBucket[] = [];
const width = Math.max(1, Math.floor(size));
for (let index = 0; index < trend.length; index += width) {
const group = trend.slice(index, index + width);
const first = group[0];
const last = group[group.length - 1];
if (!first || !last) continue;
buckets.push({ hour: first.hour, endHour: last.hour, success: group.reduce((total, item) => total + item.success, 0), denied: group.reduce((total, item) => total + item.denied, 0), error: group.reduce((total, item) => total + item.error, 0), rate_limited: group.reduce((total, item) => total + item.rate_limited, 0) });
}
return buckets;
}
+15 -7
View File
@@ -1,20 +1,28 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import type { CatalogPreview, ManagementApiClient } from "../contracts";
import { safeApiMessage } from "../api";
import { useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
import { Modal } from "../components/Modal";
export function CatalogWorkflow({ api }: { api: ManagementApiClient }) {
export function CatalogWorkflow({ api, initialProject = "" }: { api: ManagementApiClient; initialProject?: string }) {
const canApply = useCan("catalog.apply");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const [project, setProject] = useState(initialProject);
const [preview, setPreview] = useState<CatalogPreview | null>(null);
const [message, setMessage] = useState("");
const [busy, setBusy] = useState(false);
const [confirming, setConfirming] = useState(false);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
const run = async () => { if (!project) return; setBusy(true); setMessage(""); try { setPreview(await api.previewCatalog({ project })); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } };
const apply = async () => { if (!project) return; setBusy(true); try { const result = await api.applyCatalog({ project }); setMessage(result.message); } catch (caught) { setMessage(safeApiMessage(caught)); } finally { setBusy(false); } };
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Safe configuration</p><h3>Catalog validation and preview</h3></div></div><p className="muted">Preview is read-only. Apply remains disabled until validation reports no errors and the server authorizes the change.</p><div className="form-grid"><label>Project<select value={project} onChange={(event) => { setProject(event.target.value); setPreview(null); setMessage(""); }} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button button-secondary" onClick={() => void run()} disabled={busy || !project}>{busy ? "Validating…" : "Validate and preview"}</button>{preview && <div className="diff-box"><strong>{preview.valid ? "Valid preview" : "Validation blocked"}</strong>{preview.changes.map((change) => <div key={change} className="diff-add">+ {change}</div>)}{preview.errors.map((error) => <div key={error} className="diff-error">! {error}</div>)}{canApply ? <button className="button button-small" onClick={() => setConfirming(true)} disabled={!preview.valid || busy} title={!preview.valid ? "Resolve validation errors before applying" : "Server authorization is still required"}>Apply approved catalog</button> : <p className="muted">Applying requires the admin role.</p>}</div>}{message && <p className="error-text" role="status">{message}</p>}<Modal open={confirming} title="Apply catalog changes?" onClose={() => setConfirming(false)}><p>This reconciles the catalog for <strong>{project}</strong> against the previewed changes above. This action is logged and cannot be undone.</p><div className="state-actions"><button className="button" onClick={() => { setConfirming(false); void apply(); }}>Confirm apply</button><button className="button button-secondary" onClick={() => setConfirming(false)}>Cancel</button></div></Modal></section>;
const [applying, setApplying] = useState(false);
const [previewProject, setPreviewProject] = useState("");
const currentProject = useRef(project);
currentProject.current = project;
const active = useRef(true);
useEffect(() => { active.current = true; return () => { active.current = false; }; }, []);
useEffect(() => { setConfirming(false); }, [project]);
useEffect(() => { if (projects.length && !projects.some((item) => item.id === project)) setProject(projects[0]?.id ?? ""); }, [projects, project]);
useEffect(() => { if (initialProject) { setProject(initialProject); setPreview(null); setMessage(""); } }, [initialProject]);
const run = async () => { if (!project) return; setBusy(true); setMessage(""); try { const next = await api.previewCatalog({ project }); if (active.current && currentProject.current === project) { setPreview(next); setPreviewProject(project); } } catch (caught) { if (active.current && currentProject.current === project) setMessage(safeApiMessage(caught)); } finally { if (active.current) setBusy(false); } };
const apply = async () => { if (busy || !project || !preview?.valid || previewProject !== project) return; setApplying(true); setBusy(true); try { const result = await api.applyCatalog({ project }); if (active.current && currentProject.current === project) setMessage(result.message); } catch (caught) { if (active.current && currentProject.current === project) setMessage(safeApiMessage(caught)); } finally { if (active.current) { setBusy(false); setApplying(false); } } };
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Safe configuration</p><h3>Catalog validation and preview</h3></div></div><p className="muted">Preview is read-only and checks the complete catalog, including the selected project. Apply reconciles all registered projects and remains disabled until validation reports no errors and the server authorizes the change.</p><div className="form-grid"><label>Project<select value={project} onChange={(event) => { setProject(event.target.value); setPreview(null); setMessage(""); }} disabled={projects.length === 0 || confirming || applying}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button button-secondary" onClick={() => void run()} disabled={busy || !project}>{applying ? "Applying…" : busy ? "Validating…" : "Validate and preview"}</button>{preview && <div className="diff-box"><strong>{preview.valid ? "Valid preview" : "Validation blocked"}</strong>{preview.changes.map((change) => <div key={change} className="diff-add">+ {change}</div>)}{preview.errors.map((error) => <div key={error} className="diff-error">! {error}</div>)}{canApply ? <button className="button button-small" onClick={() => setConfirming(true)} disabled={!preview.valid || previewProject !== project || busy} title={!preview.valid ? "Resolve validation errors before applying" : "Server authorization is still required"}>Apply approved catalog</button> : <p className="muted">Applying requires the admin role.</p>}</div>}{message && <p className="error-text" role="status">{message}</p>}<Modal open={confirming} title="Apply catalog changes?" onClose={() => setConfirming(false)}><p>This reconciles the complete registered catalog, including <strong>{project}</strong>. All registered project routes may be updated. This action is logged and cannot be undone.</p><div className="state-actions"><button className="button" onClick={() => { setConfirming(false); void apply(); }}>Confirm apply</button><button className="button button-secondary" onClick={() => setConfirming(false)}>Cancel</button></div></Modal></section>;
}
+288 -51
View File
@@ -1,93 +1,330 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, OnboardingClientKind } from "../contracts";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import type { ClientOnboardingResult, ClientOnboardingStatus, GeneratedClientConfig, ManagementApiClient, OnboardingClientKind, OnboardingCheckState } from "../contracts";
import { safeApiMessage } from "../api";
import { Icon } from "../components/Icon";
import { StatusPill } from "../components/StatusPill";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
import { formatBrusselsTime, healthLabel } from "../format";
export function ClientOnboarding({ api }: { api: ManagementApiClient }) {
type SafeGeneratedConfig = GeneratedClientConfig;
type WorkflowStage = "define" | "provision" | "verify";
function healthForCheck(state: OnboardingCheckState): "healthy" | "degraded" | "unavailable" | "unknown" {
if (state === "ready" || state === "healthy") return "healthy";
if (state === "pending" || state === "degraded") return "degraded";
if (state === "blocked" || state === "unavailable") return "unavailable";
return "unknown";
}
function currentStage(status: ClientOnboardingStatus | null, clientId: string | null, loading: boolean): WorkflowStage {
if (!clientId) return "define";
if (loading || !status) return "provision";
const externalReady = status.checks.externalSubject.state === "ready" && status.checks.hubBinding.state === "ready";
return externalReady ? "verify" : "provision";
}
function CheckRow({ label, state, detail, observedAt }: { label: string; state: OnboardingCheckState; detail: string; observedAt?: string | null }) {
return <div className="onboarding-check">
<dt>{label}</dt>
<dd>
<StatusPill value={healthForCheck(state)} />
<span className="onboarding-check-detail">{detail}</span>
{observedAt ? <small>Observed {formatBrusselsTime(observedAt)}</small> : null}
</dd>
</div>;
}
export function ClientOnboarding({
api,
clientId,
navigate,
onCreated,
}: {
api: ManagementApiClient;
clientId?: string;
navigate?: (next: string) => void;
onCreated?: (client: ClientOnboardingResult["client"]) => void;
}) {
const canCreate = useCan("client.create");
const canVerify = useCan("client.verify");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [name, setName] = useState("");
const [kind, setKind] = useState<OnboardingClientKind>("stdio-bridge");
const [result, setResult] = useState<Awaited<ReturnType<ManagementApiClient["createClient"]>> | null>(null);
const [error, setError] = useState("");
const activeClientId = clientId ?? null;
const workflowContext = useRef({ clientId: activeClientId, generation: 0 });
useLayoutEffect(() => {
if (workflowContext.current.clientId !== activeClientId) {
workflowContext.current = { clientId: activeClientId, generation: workflowContext.current.generation + 1 };
}
}, [activeClientId]);
const mounted = useRef(true);
const [createResult, setCreateResult] = useState<ClientOnboardingResult | null>(null);
const [status, setStatus] = useState<ClientOnboardingStatus | null>(null);
const [generatedConfig, setGeneratedConfig] = useState<SafeGeneratedConfig | null>(null);
const [statusState, setStatusState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [configState, setConfigState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [statusError, setStatusError] = useState("");
const [actionError, setActionError] = useState("");
const [actionMessage, setActionMessage] = useState("");
const [actionTone, setActionTone] = useState<"success" | "info">("info");
const [copyStatus, setCopyStatus] = useState("");
const [busy, setBusy] = useState(false);
const [busy, setBusy] = useState<"create" | "verify" | "">("");
const [refreshKey, setRefreshKey] = useState(0);
useLayoutEffect(() => {
mounted.current = true;
return () => { mounted.current = false; };
}, []);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
useEffect(() => {
if (!activeClientId) {
setStatus(null);
setGeneratedConfig(null);
setStatusState("idle");
setConfigState("idle");
setStatusError("");
setBusy("");
setActionError("");
setActionMessage("");
return;
}
let active = true;
const generation = workflowContext.current.generation;
setStatus(null);
setGeneratedConfig(null);
setStatusState("loading");
setConfigState("loading");
setStatusError("");
setBusy("");
setActionError("");
setActionMessage("");
void api.clientOnboardingStatus(activeClientId).then((value) => {
if (!active || !mounted.current || workflowContext.current.clientId !== activeClientId || workflowContext.current.generation !== generation) return;
if (value.clientId !== activeClientId) {
setStatusError("The Hub returned status for a different client identity.");
setStatusState("error");
return;
}
setStatus(value);
setStatusState("ready");
}).catch((caught) => {
if (!active || !mounted.current || workflowContext.current.clientId !== activeClientId || workflowContext.current.generation !== generation) return;
setStatusError(safeApiMessage(caught));
setStatusState("error");
});
void api.generatedConfig(activeClientId).then((value) => {
if (!active || !mounted.current || workflowContext.current.clientId !== activeClientId || workflowContext.current.generation !== generation) return;
if (value.clientId !== activeClientId) {
setConfigState("error");
return;
}
setGeneratedConfig(value);
setConfigState("ready");
}).catch(() => {
if (active && mounted.current && workflowContext.current.clientId === activeClientId && workflowContext.current.generation === generation) setConfigState("error");
});
return () => { active = false; };
}, [activeClientId, api, refreshKey]);
const normalizedName = name.trim();
const ready = normalizedName.length >= 1 && project !== "" && environment !== "";
if (!canCreate) return <section className="panel workflow-panel"><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3><div className="notice notice-info">Client creation requires the admin role.</div></section>;
const readyToCreate = normalizedName.length >= 1 && project !== "" && environment !== "";
const visibleStatus = status?.clientId === activeClientId ? status : null;
const stage = currentStage(visibleStatus, activeClientId, statusState === "loading");
const visibleConfig = createResult?.client.id === activeClientId
? createResult.config
: generatedConfig?.clientId === activeClientId ? generatedConfig : null;
const visibleProvisioning = createResult?.client.id === activeClientId
? createResult.provisioning
: visibleStatus?.provisioning ?? null;
const openClient = (id: string) => {
const next = `/clients?onboard=${encodeURIComponent(id)}`;
if (navigate) navigate(next);
else window.location.assign(next);
};
const create = async () => {
if (!ready) return;
setBusy(true);
setError("");
if (!readyToCreate || !canCreate) return;
const requestClientId = activeClientId;
const requestGeneration = workflowContext.current.generation;
const requestIsCurrent = () => mounted.current && workflowContext.current.clientId === requestClientId && workflowContext.current.generation === requestGeneration;
setBusy("create");
setActionError("");
setActionMessage("");
setCopyStatus("");
setResult(null);
setCreateResult(null);
try {
setResult(await api.createClient({ name: normalizedName, project, environment, kind }));
const result = await api.createClient({ name: normalizedName, project, environment, kind });
onCreated?.(result.client);
if (!requestIsCurrent()) return;
setCreateResult(result);
setName("");
openClient(result.client.id);
setActionTone("info");
setActionMessage("Pending Hub client created. External identity provisioning remains a separate administrator task.");
} catch (caught) {
setError(safeApiMessage(caught));
if (requestIsCurrent()) setActionError(safeApiMessage(caught));
} finally {
setBusy(false);
if (requestIsCurrent()) setBusy("");
}
};
const copyConfig = async () => {
if (!result) return;
const verify = async () => {
if (!activeClientId || !canVerify || statusState !== "ready" || status?.clientId !== activeClientId) return;
const requestClientId = activeClientId;
const requestGeneration = workflowContext.current.generation;
const requestIsCurrent = () => mounted.current && workflowContext.current.clientId === requestClientId && workflowContext.current.generation === requestGeneration;
setBusy("verify");
setActionError("");
setActionMessage("");
try {
await navigator.clipboard.writeText(JSON.stringify({
name: `itworx-${result.client.project}-${result.client.environment}`,
endpoint: result.config.endpoint,
hubClientId: result.client.id,
const result = await api.verifyClientOnboarding(requestClientId);
if (!requestIsCurrent()) return;
if (result.clientId !== requestClientId) throw new Error("mismatched client status");
setStatus(result);
setStatusState("ready");
setActionTone(result.verification.verifiedByManagement ? "success" : "info");
setActionMessage(result.verification.verifiedByManagement
? "Hub checks passed. External OAuth scopes and MCP discovery proof still require operator verification."
: "Hub checks were recorded, but one or more Hub prerequisites are still pending. External OAuth scopes and MCP discovery proof remain unverified.");
} catch (caught) {
if (requestIsCurrent()) setActionError(safeApiMessage(caught));
} finally {
if (requestIsCurrent()) setBusy("");
}
};
const copyConfig = async () => {
if (!activeClientId || !visibleConfig) return;
const safeConfig = {
hubClientId: activeClientId,
endpoint: visibleConfig.endpoint,
scopes: visibleConfig.scopes,
profile: visibleConfig.kind,
protocolVersion: visibleConfig.protocolVersion,
transport: visibleConfig.transport,
...(visibleConfig.environmentVariable ? { environmentVariable: visibleConfig.environmentVariable } : {}),
...(visibleConfig.bridgeCommand ? { bridgeCommand: visibleConfig.bridgeCommand } : {}),
...(visibleProvisioning ? {
authentik: {
issuer: result.provisioning.issuer,
tokenEndpoint: result.provisioning.tokenEndpoint,
oauthClientId: result.provisioning.oauthClientId,
username: result.provisioning.oauthUsername,
issuer: visibleProvisioning.issuer,
tokenEndpoint: visibleProvisioning.tokenEndpoint,
oauthClientId: visibleProvisioning.oauthClientId,
username: visibleProvisioning.oauthUsername,
},
installScript: "scripts/install_codex_mcp.ps1",
credential: "operator-owned user-only secret file",
}, null, 2));
setCopyStatus("Configuration copied");
} : {}),
credential: "Set this up through the external administrator and store it in a user-only secret file.",
};
try {
await navigator.clipboard.writeText(JSON.stringify(safeConfig, null, 2));
setCopyStatus("Secret-free configuration copied.");
} catch {
setCopyStatus("Clipboard access is unavailable. Copy the non-secret fields manually.");
}
};
return <section className="panel workflow-panel onboarding-panel">
const reset = () => {
setCreateResult(null);
setActionError("");
setActionMessage("");
const next = "/clients";
if (navigate) navigate(next);
else window.location.assign(next);
};
return <section className="panel workflow-panel onboarding-panel" aria-busy={busy !== "" || statusState === "loading"}>
<div className="panel-heading">
<div><p className="eyebrow">Machine access</p><h3>Onboard a project client</h3></div>
<span className="tag">Independent binding</span>
</div>
<p className="muted">Create a disabled-by-default Hub identity, then complete its explicit Authentik and Codex hand-off. Access starts only after the real external subject is bound.</p>
<p className="muted">Define a disabled Hub identity, complete its external identity hand-off, then verify the real project binding and connector. Credentials and external MCP proof stay outside this browser.</p>
<ol className="onboarding-steps" aria-label="Client onboarding stages">
<li className="active"><span>1</span><div><strong>Define</strong><small>Profile and scope</small></div></li>
<li><span>2</span><div><strong>Provision</strong><small>External identity</small></div></li>
<li><span>3</span><div><strong>Verify</strong><small>Discovery and audit</small></div></li>
{(["define", "provision", "verify"] as WorkflowStage[]).map((step, index) => <li className={stage === step ? "active" : visibleStatus && index < (["define", "provision", "verify"].indexOf(stage)) ? "complete" : ""} aria-current={stage === step ? "step" : undefined} key={step}>
<span>{index + 1}</span><div><strong>{healthLabel(step)}</strong><small>{step === "define" ? "Hub identity" : step === "provision" ? "External account" : "Scope and proof"}</small></div>
</li>)}
</ol>
<div className="form-grid onboarding-form">
<label>Name<input value={name} onChange={(event) => setName(event.target.value)} placeholder="Codex workstation" minLength={1} maxLength={128} required aria-describedby="client-name-help" /><small id="client-name-help">A recognizable operator-owned device or workflow name.</small></label>
<label>Client profile<select value={kind} onChange={(event) => setKind(event.target.value as OnboardingClientKind)}><option value="stdio-bridge">Codex · automatic token refresh</option><option value="native-2026">Native MCP 2026 · bearer token</option><option value="compatibility-2025">Compatibility 2025</option><option value="inspector">LAN Inspector</option></select></label>
<label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
</div>
{!normalizedName ? <p className="form-hint">Enter a client name to continue.</p> : null}
<button className="button" onClick={() => void create()} disabled={busy || !ready}>{busy ? "Creating pending client…" : <>Create pending client <Icon name="arrow" size={15} /></>}</button>
<p className="external-provisioning-note"><span aria-hidden="true">i</span><span><strong>External identity provisioning is a separate administrator task.</strong> The Hub produces an exact plan but never creates or displays the credential in this browser.</span></p>
{error ? <p className="error-text" role="alert">{error}</p> : null}
{result ? <div className="onboarding-result" role="status">
<div className="panel-heading"><div><p className="eyebrow">Provisioning plan ready</p><h3>Client created: {result.client.name}</h3><p className="muted">Pending client created; it has no access yet.</p></div><button className="button button-small button-secondary" onClick={() => void copyConfig()}>Copy client config</button></div>
<dl><div><dt>Authentik account</dt><dd>{result.provisioning.oauthUsername}</dd></div><div><dt>Subject</dt><dd>{result.provisioning.subject ?? "Assigned during Authentik provisioning"}</dd></div><div><dt>Endpoint</dt><dd>{result.config.endpoint}</dd></div><div><dt>Scopes</dt><dd>{result.config.scopes.join(", ")}</dd></div><div><dt>Status</dt><dd>{result.provisioning.status}</dd></div></dl>
<ol>{result.provisioning.steps.map((step) => <li key={step}>{step}</li>)}</ol>
<p className="muted">No bearer credential, app password or client secret is returned to this browser.</p>
{copyStatus ? <p className="copy-status">{copyStatus}</p> : null}
</div> : null}
{!activeClientId ? <>
{canCreate ? <>
<div className="form-grid onboarding-form">
<label>Name<input aria-label="Name" value={name} onChange={(event) => setName(event.target.value)} placeholder="Codex workstation" minLength={1} maxLength={128} required aria-describedby="client-name-help" /><small id="client-name-help">A recognizable operator-owned device or workflow name.</small></label>
<label>Client profile<select value={kind} onChange={(event) => setKind(event.target.value as OnboardingClientKind)}><option value="stdio-bridge">Codex · automatic token refresh</option><option value="native-2026">Native MCP 2026 · bearer token</option><option value="compatibility-2025">Compatibility 2025</option><option value="inspector">LAN Inspector</option></select></label>
<label>Project<select aria-label="Project" value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select aria-label="Environment" value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((item) => <option key={item} value={item}>{item}</option>)}</select></label>
</div>
{!normalizedName ? <p className="form-hint">Enter a client name to continue.</p> : null}
{projects.length === 0 ? <p className="notice notice-info">No registered projects are available for this identity.</p> : null}
<button className="button" onClick={() => void create()} disabled={busy !== "" || !readyToCreate}>{busy === "create" ? "Creating pending client…" : <>Create pending client <Icon name="arrow" size={15} /></>}</button>
</> : <div className="notice notice-info">Client creation requires the admin role. An operator can resume an existing onboarding link when one is provided.</div>}
<p className="external-provisioning-note"><span aria-hidden="true">i</span><span><strong>External identity provisioning is a separate administrator task.</strong> The Hub creates a pending identity and shows secret-free setup details; it does not create an Authentik account or credential.</span></p>
</> : <>
<div className="onboarding-identity-bar">
<div><span className="eyebrow">Resumable Hub identity</span><strong>{activeClientId}</strong><small>{visibleStatus?.project ?? (createResult?.client.id === activeClientId ? createResult.client.project : "Project loading")} / {visibleStatus?.environment ?? (createResult?.client.id === activeClientId ? createResult.client.environment : "Environment loading")} · {visibleStatus?.clientStatus ?? "Status loading"}</small></div>
<div className="onboarding-identity-actions">
{statusState === "error" ? <button className="button button-small button-secondary" onClick={() => setRefreshKey((value) => value + 1)}>Retry status</button> : null}
<button className="button button-small button-secondary" onClick={reset}>Start another client</button>
</div>
</div>
{statusState === "loading" ? <p className="onboarding-loading" role="status">Loading saved onboarding state…</p> : null}
{statusError ? <p className="error-text" role="alert">{statusError}</p> : null}
{visibleConfig ? <div className="onboarding-config">
<div className="panel-heading"><div><p className="eyebrow">Secret-free client configuration</p><h4>External hand-off details</h4>{createResult?.client.id === activeClientId ? <p className="onboarding-created-title">Client created: {createResult.client.name}</p> : null}</div><button className="button button-small button-secondary" onClick={() => void copyConfig()} disabled={!visibleProvisioning}>Copy safe config</button></div>
<dl>
<div><dt>Client profile</dt><dd>{visibleConfig.kind}</dd></div>
<div><dt>Protocol version</dt><dd>{visibleConfig.protocolVersion}</dd></div>
<div><dt>Transport</dt><dd>{visibleConfig.transport}</dd></div>
<div><dt>Hub endpoint</dt><dd>{visibleConfig.endpoint}</dd></div>
<div><dt>Client reference</dt><dd>{visibleConfig.clientId}</dd></div>
<div><dt>Scopes</dt><dd>{visibleConfig.scopes.join(", ") || "None listed"}</dd></div>
{visibleConfig.environmentVariable ? <div><dt>Credential environment variable</dt><dd>{visibleConfig.environmentVariable}</dd></div> : null}
{visibleConfig.bridgeCommand ? <div><dt>Bridge command</dt><dd>{visibleConfig.bridgeCommand}</dd></div> : null}
</dl>
{visibleProvisioning ? <>
<div className="onboarding-config-auth"><p><strong>External account:</strong> {visibleProvisioning.oauthUsername}</p><p><strong>Issuer:</strong> {visibleProvisioning.issuer}</p><p><strong>Token endpoint:</strong> {visibleProvisioning.tokenEndpoint}</p><p><strong>OAuth client ID:</strong> {visibleProvisioning.oauthClientId}</p></div>
<ol className="onboarding-handoff-steps">{visibleProvisioning.steps.map((step) => <li key={step}>{step}</li>)}</ol>
</> : <p className="muted">Loading saved external identity hand-off details…</p>}
{configState === "loading" ? <p className="muted" role="status">Loading saved endpoint configuration…</p> : null}
{configState === "error" ? <p className="muted">Saved endpoint configuration could not be loaded. Retry the onboarding status to refresh it.</p> : null}
{copyStatus ? <p className="copy-status" role="status">{copyStatus}</p> : null}
</div> : null}
{visibleStatus ? <div className="onboarding-stage-grid">
<section className="onboarding-stage-card" aria-labelledby="onboarding-provision-title">
<div className="onboarding-stage-heading"><span className="stage-number">2</span><div><p className="eyebrow">Provision</p><h4 id="onboarding-provision-title">External identity hand-off</h4></div></div>
<p className="muted">Complete these steps with the identity administrator. The Hub never provisions the external account, grants OAuth scopes or handles its credential.</p>
<dl className="onboarding-checks">
<CheckRow label="External subject" state={visibleStatus.checks.externalSubject.state} detail={visibleStatus.checks.externalSubject.detail} />
<CheckRow label="Hub binding" state={visibleStatus.checks.hubBinding.state} detail={visibleStatus.checks.hubBinding.detail} />
<CheckRow label="OAuth scopes" state={visibleStatus.checks.externalScope.state} detail={visibleStatus.checks.externalScope.detail} />
</dl>
<div className="external-boundary-note"><strong>Manual prerequisite</strong><span>Provision the external identity, grant only the expected scopes, and store its credential in a user-only secret file. The Hub will not enable this pending client automatically.</span></div>
</section>
<section className="onboarding-stage-card" aria-labelledby="onboarding-verify-title">
<div className="onboarding-stage-heading"><span className="stage-number">3</span><div><p className="eyebrow">Verify</p><h4 id="onboarding-verify-title">Hub checks and MCP proof</h4></div></div>
<dl className="onboarding-checks">
<CheckRow label="Catalog binding" state={visibleStatus.checks.catalog.state} detail={visibleStatus.checks.catalog.detail} />
<CheckRow label="Connector" state={visibleStatus.checks.connector.state} detail={visibleStatus.checks.connector.detail} observedAt={visibleStatus.checks.connector.observedAt} />
<CheckRow label="MCP client proof" state={visibleStatus.checks.mcpProof.state} detail={visibleStatus.checks.mcpProof.detail} />
</dl>
<div className="manual-proof-note"><strong>External verification still required</strong><span>Use an MCP Inspector or the configured client to discover tools, run one approved read-only invocation, then confirm that a cross-project request is denied. The Hub cannot assert those client-side checks from this management session.</span></div>
{visibleStatus.verification.verifiedByManagement ? <p className="onboarding-verified" role="status">Hub checks passed {formatBrusselsTime(visibleStatus.verification.checkedAt)}. External OAuth grants and MCP discovery proof remain separate prerequisites.</p> : visibleStatus.verification.checkedByManagement ? <p className="onboarding-check-recorded" role="status">Hub checks were recorded {formatBrusselsTime(visibleStatus.verification.checkedAt)}, but one or more Hub prerequisites are still pending. This record does not mark external OAuth grants or MCP discovery as complete.</p> : <p className="muted">No Hub check record yet. Recording checks saves only the server-observed safe status and time.</p>}
{canVerify ? <button className="button" onClick={() => void verify()} disabled={busy !== "" || statusState !== "ready" || !visibleStatus} aria-busy={busy === "verify"}>{busy === "verify" ? "Recording Hub checks…" : "Record Hub checks"}</button> : <p className="notice notice-info">Recording Hub checks requires the operator or admin role.</p>}
{visibleStatus.verification.detail ? <p className="muted onboarding-verification-detail">{visibleStatus.verification.detail}</p> : null}
</section>
</div> : null}
</>}
{actionError ? <p className="error-text" role="alert">{actionError}</p> : null}
{actionMessage ? <p className={`onboarding-action-message onboarding-action-${actionTone}`} role="status">{actionMessage}</p> : null}
</section>;
}
+135
View File
@@ -0,0 +1,135 @@
import { useCallback, useEffect, useRef, useState } from "react";
import type { ConnectorDetailInfo, ManagementApiClient } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { ErrorPanel } from "../components/ErrorPanel";
import { LoadingPanel } from "../components/LoadingPanel";
import { formatBrusselsTime } from "../format";
import { basePath } from "../routing";
import { DetailCards } from "./ProjectDetail";
import type { DetailCardItem } from "./ProjectDetail";
import "../styles/details.css";
const sections = ["Overview", "Bindings", "Related tools"] as const;
type Section = typeof sections[number];
export function ConnectorDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
const id = basePath(path).split("/").pop() ?? "connector";
const search = new URLSearchParams(path.split("?")[1] ?? "");
const project = search.get("project");
const environment = search.get("environment");
const requestedSection = search.get("section") ?? "Overview";
const section = sections.includes(requestedSection as Section) ? requestedSection as Section : "Overview";
const [detail, setDetail] = useState<ConnectorDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error" | "notFound">("loading");
const [error, setError] = useState("");
const loadGeneration = useRef(0);
const load = useCallback(async () => {
const generation = ++loadGeneration.current;
setState("loading");
setDetail(null);
setError("");
try {
if (Boolean(project) !== Boolean(environment)) {
setError("Project and environment filters must be supplied together.");
setState("error");
return;
}
const scope = project && environment ? { project, environment } : undefined;
const next = await api.connectorDetail(id, scope);
if (generation !== loadGeneration.current) return;
setDetail(next);
setState("ready");
} catch (caught) {
if (generation !== loadGeneration.current) return;
if (caught instanceof ApiError && caught.status === 404) setState("notFound");
else { setError(safeApiMessage(caught)); setState("error"); }
}
}, [api, environment, id, project]);
useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
if (state === "loading") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><LoadingPanel /></div>;
if (state === "error") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><ErrorPanel message={error} retry={() => void load()}/></div>;
if (state === "notFound" || !detail) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Connector <strong>{id}</strong> is not registered in this scope.</div></section></div>;
const readiness = detail.readiness;
const readinessCard: DetailCardItem = {
id: "readiness",
title: "Connector readiness",
summary: readiness.detail,
status: readiness.status,
fields: [
{ label: "Reachable", value: readiness.reachable === null ? "Unknown" : readiness.reachable ? "Yes" : "No" },
{ label: "Observation source", value: readiness.source },
{ label: "Observed at", value: readiness.observedAt ? formatBrusselsTime(readiness.observedAt) : "No observation recorded" },
],
};
const registrationCard: DetailCardItem = {
id: "registration",
title: "Catalog registration",
summary: detail.registration.detail,
status: detail.registration.state,
fields: [{ label: "Connector type", value: detail.connectorType }],
};
const bindingCards: DetailCardItem[] = detail.bindings.map((binding) => ({
id: `${binding.project}-${binding.environment}`,
title: `${binding.project} · ${binding.environment}`,
status: binding.verification,
fields: [
{ label: "Project", value: binding.project },
{ label: "Environment", value: binding.environment },
{ label: "Verification", value: binding.verification },
],
action: { label: "Open project", onSelect: () => navigate(`/projects/${encodeURIComponent(binding.project)}`) },
}));
const toolCards: DetailCardItem[] = detail.relatedTools.map((tool) => ({
id: tool.canonicalName,
title: tool.canonicalName,
summary: tool.summary,
status: tool.risk,
fields: [
{ label: "Read only", value: tool.readOnly ? "Yes" : "No" },
{ label: "Risk class", value: tool.risk },
],
action: { label: "Open tool catalog", onSelect: () => navigate(`/tools?q=${encodeURIComponent(tool.canonicalName)}`) },
}));
const currentCards = section === "Overview" ? [readinessCard, registrationCard]
: section === "Bindings" ? bindingCards
: toolCards;
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate}/>
<section className="panel resource-page">
<p className="eyebrow">Connector workspace</p>
<h2>{detail.name}</h2>
<p className="muted">{detail.description || "Secret-free connector registration, bindings, and observed readiness."}</p>
<label className="detail-mobile-section"><span>Connector section</span>
<select value={section} onChange={(event) => {
const params = new URLSearchParams();
if (project && environment) { params.set("project", project); params.set("environment", environment); }
params.set("section", event.target.value);
navigate(`/connectors/${encodeURIComponent(id)}?${params.toString()}`);
}}>{sections.map((item) => <option key={item} value={item}>{item}</option>)}</select>
</label>
<nav className="tabs detail-desktop-tabs" aria-label="Connector detail sections">
{sections.map((item) => {
const params = new URLSearchParams();
if (project && environment) { params.set("project", project); params.set("environment", environment); }
params.set("section", item);
return <a key={item} aria-current={section === item ? "page" : undefined} className={section === item ? "tab active" : "tab"}
href={`/connectors/${encodeURIComponent(id)}?${params.toString()}`}
onClick={(event) => { event.preventDefault(); navigate(`/connectors/${encodeURIComponent(id)}?${params.toString()}`); }}>{item}</a>;
})}
</nav>
{readiness.source === "unavailable" && <p className="detail-boundary-note">The Hub has no live or persisted health observation for this connector, so readiness stays unknown.</p>}
<a className="button button-secondary" href="/operations?section=checks" onClick={(event) => { event.preventDefault(); navigate("/operations?section=checks"); }}>Inspect operational checks</a>
{section === "Bindings" && bindingCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No project environments are bound to this connector.</span></div>
: section === "Related tools" && toolCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No registered tools reference this connector.</span></div>
: <DetailCards label={section} cards={currentCards} />}
</section>
</div>;
}
+21 -34
View File
@@ -1,5 +1,5 @@
import type { MouseEvent } from "react";
import type { DashboardSnapshot, Health } from "../contracts";
import { useCallback, useState, type MouseEvent } from "react";
import type { DashboardSnapshot, Health, ManagementApiClient } from "../contracts";
import { isSnapshotStale } from "../contracts";
import { formatBrusselsTime } from "../format";
import { Icon } from "../components/Icon";
@@ -7,37 +7,26 @@ import { McpTopology } from "../components/McpTopology";
import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context";
import { actionInbox } from "../operator-data";
import { refreshLabel, useStatusRefresh } from "../status-refresh";
function healthLabel(value: Health) {
return ({ healthy: "Healthy", degraded: "Degraded", unavailable: "Unavailable", unknown: "Unknown" } as const)[value];
}
type Exception = { title: string; detail: string; status: Health; href: string };
function exceptionsFor(snapshot: DashboardSnapshot, stale: boolean, canReadAudit: boolean): Exception[] {
const exceptions: Exception[] = [];
if (stale) exceptions.push({ title: "Operational data is stale", detail: `Last generated ${formatBrusselsTime(snapshot.generatedAt)}. Refresh before acting on health information.`, status: "degraded", href: "/operations" });
if (snapshot.health.hub !== "healthy") exceptions.push({ title: "Hub health needs investigation", detail: snapshot.health.detail, status: snapshot.health.hub, href: "/operations" });
if (snapshot.health.gateway !== "healthy") exceptions.push({ title: "Gateway health needs investigation", detail: "Trusted ContextForge route availability is not healthy.", status: snapshot.health.gateway, href: "/operations" });
for (const connector of snapshot.connectors.filter((item) => item.state !== "healthy")) {
exceptions.push({ title: `${connector.name} connector is ${healthLabel(connector.state).toLowerCase()}`, detail: `${connector.project} · ${connector.detail}`, status: connector.state, href: "/connectors" });
}
if (!snapshot.drift.known || snapshot.drift.count > 0) exceptions.push({ title: "Catalog reconciliation needs attention", detail: snapshot.drift.known ? `${snapshot.drift.count} drifted project(s): ${snapshot.drift.projects.join(", ") || "details available in Operations"}` : snapshot.drift.detail, status: snapshot.drift.known ? "degraded" : "unknown", href: "/operations" });
if (snapshot.backup.state !== "healthy") exceptions.push({ title: "Backup freshness needs attention", detail: snapshot.backup.detail, status: snapshot.backup.state, href: "/operations" });
if (snapshot.denials.count > 0) exceptions.push({ title: "Recent authorization denials", detail: `${snapshot.denials.count} in ${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}${canReadAudit ? "" : " · detailed audit access requires the auditor role"}`, status: "unknown", href: canReadAudit ? "/audit" : "/operations" });
return exceptions;
}
export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardSnapshot; reload: () => void; navigate: (next: string) => void }) {
export function Dashboard({ snapshot: initialSnapshot, api, navigate }: { snapshot: DashboardSnapshot; api: ManagementApiClient; reload?: () => void; navigate: (next: string) => void }) {
const read = useCallback(() => api.dashboard(), [api]);
const { value, status, error, refresh } = useStatusRefresh(initialSnapshot, read, isSnapshotStale);
const snapshot = value ?? initialSnapshot;
const stale = isSnapshotStale(snapshot);
const exceptions = exceptionsFor(snapshot, stale, useCan("audit.read"));
const exceptions = actionInbox(snapshot, stale, useCan("audit.read")).sort((left, right) => ({ unavailable: 0, degraded: 1, unknown: 2, healthy: 3 })[left.status] - ({ unavailable: 0, degraded: 1, unknown: 2, healthy: 3 })[right.status]);
const [showAllSignals, setShowAllSignals] = useState(false);
const visibleSignals = showAllSignals ? exceptions : exceptions.slice(0, 4);
const connectorHealth: Health = snapshot.connectors.some((connector) => ["degraded", "unavailable"].includes(connector.state)) ? "degraded" : snapshot.connectors.some((connector) => connector.state === "unknown") ? "unknown" : snapshot.connectors.length ? "healthy" : "unknown";
const healthyConnectors = snapshot.connectors.filter((connector) => connector.state === "healthy").length;
const allCurrent = !stale && exceptions.length === 0;
const go = (event: MouseEvent<HTMLAnchorElement>, href: string) => {
event.preventDefault();
navigate(href);
};
return <div className="dashboard">
<section className="hero dashboard-hero">
<div className="hero-content">
@@ -51,7 +40,7 @@ export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardS
<a className="button" href="/operations" onClick={(event) => go(event, "/operations")}>Review operations <Icon name="arrow" size={15} /></a>
<a className="button button-secondary" href="/tools" onClick={(event) => go(event, "/tools")}>Explore catalog</a>
</div>
<div className="hero-meta"><span>Last synchronized</span><strong>{formatBrusselsTime(snapshot.generatedAt)}</strong></div>
<div className="hero-meta"><span className={`freshness freshness-${status}`} role="status">{refreshLabel[status]}</span><span>Last synchronized</span><strong>{formatBrusselsTime(snapshot.generatedAt)}</strong></div>
</div>
<McpTopology
hub={stale ? "degraded" : snapshot.health.hub}
@@ -59,36 +48,34 @@ export function Dashboard({ snapshot, reload, navigate }: { snapshot: DashboardS
connectors={snapshot.connectors.map((connector) => ({ name: connector.name, state: stale ? "degraded" : connector.state }))}
/>
</section>
<section className="metric-grid primary-metrics" aria-label="Aggregate health">
<MetricCard label="Hub control plane" value={stale ? "Stale" : healthLabel(snapshot.health.hub)} detail={snapshot.health.detail} status={stale ? "degraded" : snapshot.health.hub} />
<MetricCard label="Gateway route" value={stale ? "Stale" : healthLabel(snapshot.health.gateway)} detail="Trusted ContextForge route availability" status={stale ? "degraded" : snapshot.health.gateway} />
<MetricCard label="Project environments" value={`${snapshot.projects.length}`} detail={`${new Set(snapshot.projects.map((project) => project.slug)).size} governed projects`} />
<MetricCard label="Connector routes" value={`${healthyConnectors} / ${snapshot.connectors.length}`} detail="Healthy of registered integrations" status={healthyConnectors === snapshot.connectors.length ? "healthy" : "degraded"} />
<MetricCard label="Connector routes" value={`${healthyConnectors} / ${snapshot.connectors.length}`} detail="Healthy of registered integrations" status={stale ? "degraded" : connectorHealth} />
</section>
<section className="panel exception-panel" aria-labelledby="attention-heading">
<div className="panel-heading">
<div><p className="eyebrow">Attention queue</p><h3 id="attention-heading">{exceptions.length ? `${exceptions.length} actionable signal${exceptions.length === 1 ? "" : "s"}` : "No active exceptions"}</h3></div>
<button className="button button-small button-secondary refresh-button" onClick={reload}><Icon name="refresh" size={14} />Refresh</button>
<div><p className="eyebrow">Action Inbox</p><h3 id="attention-heading">{exceptions.length ? `${exceptions.length} actionable signal${exceptions.length === 1 ? "" : "s"}` : "No active exceptions"}</h3></div>
<button className="button button-small button-secondary refresh-button" onClick={() => void refresh()} disabled={status === "refreshing"}><Icon name="refresh" size={14} />Refresh</button>
</div>
{error ? <div className="notice notice-danger" role="alert">{error} The last snapshot is retained; retry with Refresh.</div> : null}
{exceptions.length === 0
? <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>Everything reported is healthy.</strong><small>Hub, gateway, connectors, drift and recovery data are current.</small></span></div>
: <div className="exception-list">{exceptions.map((exception) => <a className="exception-item" href={exception.href} key={`${exception.title}-${exception.detail}`} onClick={(event) => go(event, exception.href)}><div><strong>{exception.title}</strong><small>{exception.detail}</small></div><span className="exception-action"><StatusPill value={exception.status} /><Icon name="arrow" size={15} /></span></a>)}</div>}
: <div className="exception-list">{visibleSignals.map((exception) => <a className="exception-item" href={exception.href} key={`${exception.title}-${exception.detail}`} onClick={(event) => go(event, exception.href)}><div><strong>{exception.title}</strong><small>{exception.project ? `${exception.project} · ` : ""}{exception.detail}</small><small>{exception.impact}</small><span className="signal-next-step">{exception.nextStep}</span><small className="signal-time">Snapshot {formatBrusselsTime(exception.observedAt)}</small></div><span className="exception-action"><StatusPill value={exception.status} /><Icon name="arrow" size={15} /></span></a>)}</div>}
{exceptions.length > 4 && <button className="button button-secondary inbox-expand" aria-expanded={showAllSignals} onClick={() => setShowAllSignals((current) => !current)}>{showAllSignals ? "Show fewer signals" : `Show ${exceptions.length - 4} more signals`}</button>}
</section>
<section className="dashboard-columns">
<article className="panel">
<div className="panel-heading"><div><p className="eyebrow">Projects</p><h3>Project status</h3></div><a href="/projects" onClick={(event) => go(event, "/projects")}>View all <Icon name="arrow" size={14} /></a></div>
{snapshot.projects.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No projects have been registered.</span></div> : <div className="table-wrap" role="region" tabIndex={0} aria-label="Project status table"><table><caption className="sr-only">Project status</caption><thead><tr><th>Project</th><th>Environment</th><th>Health</th><th>Detail</th></tr></thead><tbody>{snapshot.projects.slice(0, 6).map((project) => <tr key={`${project.slug}-${project.environment}`}><td><a className="record-link" href={`/projects/${project.slug}`} onClick={(event) => go(event, `/projects/${project.slug}`)}><strong>{project.name}</strong><small>{project.slug}</small></a></td><td>{project.environment}</td><td><StatusPill value={stale ? "degraded" : project.health} /></td><td>{project.detail}</td></tr>)}</tbody></table></div>}
{snapshot.projects.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No projects have been registered.</span></div> : <><div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label="Project status table"><table><caption className="sr-only">Project status</caption><thead><tr><th>Project</th><th>Environment</th><th>Health</th><th>Detail</th></tr></thead><tbody>{snapshot.projects.slice(0, 6).map((project) => <tr key={`${project.slug}-${project.environment}`}><td><a className="record-link" href={`/projects/${project.slug}`} onClick={(event) => go(event, `/projects/${project.slug}`)}><strong>{project.name}</strong><small>{project.slug}</small></a></td><td>{project.environment}</td><td><StatusPill value={stale ? "degraded" : project.health} /></td><td>{project.detail}</td></tr>)}</tbody></table></div><div className="mobile-record-list" aria-label="Project status records">{snapshot.projects.slice(0, 6).map((project) => <article className="mobile-record" key={`${project.slug}-${project.environment}`}><div className="mobile-record-heading"><a className="record-link" href={`/projects/${encodeURIComponent(project.slug)}`} onClick={(event) => go(event, `/projects/${encodeURIComponent(project.slug)}`)}><strong>{project.name}</strong><small>{project.slug}</small></a><StatusPill value={stale ? "degraded" : project.health} /></div><dl><dt>Environment</dt><dd>{project.environment}</dd><dt>Detail</dt><dd>{project.detail}</dd></dl></article>)}</div></>}
</article>
<article className="panel connector-panel">
<div className="panel-heading"><div><p className="eyebrow">Connectors</p><h3>Integration signal</h3></div><a href="/connectors" onClick={(event) => go(event, "/connectors")}>View all <Icon name="arrow" size={14} /></a></div>
<div className="connector-score"><strong>{healthyConnectors}</strong><span>healthy routes<small>of {snapshot.connectors.length} registered</small></span></div>
<div className="connector-score"><strong>{healthyConnectors}</strong><span>verified healthy routes<small>of {snapshot.connectors.length} registered</small></span></div>
<div className="stack-list">{snapshot.connectors.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connectors registered.</span></div> : snapshot.connectors.slice(0, 5).map((connector) => <div className="stack-item" key={`${connector.project}-${connector.name}`}><div><strong>{connector.name}</strong><small>{connector.project} · {connector.detail}</small></div><StatusPill value={stale ? "degraded" : connector.state} /></div>)}</div>
</article>
</section>
<section className="metric-grid secondary-grid">
<MetricCard label="Active clients" value={`${snapshot.activeClients}`} detail="Enabled management and MCP identities" />
<MetricCard label="Recent denials" value={`${snapshot.denials.count}`} detail={`${snapshot.denials.window}${snapshot.denials.latestTool ? ` · latest ${snapshot.denials.latestTool}` : ""}`} />
+52 -53
View File
@@ -1,81 +1,80 @@
import { useEffect, useState } from "react";
import { createApiClient, safeApiMessage } from "../api";
import type { DashboardSnapshot, OperationsCheck, OperationsSummary } from "../contracts";
import { useCallback, useEffect, useState } from "react";
import { safeApiMessage } from "../api";
import type { DashboardSnapshot, ManagementApiClient, OperationsCheck, OperationsSummary } from "../contracts";
import { isSnapshotStale } from "../contracts";
import { formatBrusselsTime } from "../format";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { Icon } from "../components/Icon";
import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context";
import { aggregateTrend, alertDestination, type TrendBucket } from "../operator-data";
import { refreshLabel, useStatusRefresh } from "../status-refresh";
function SummaryLoading() {
return <div className="inline-loading" role="status"><span className="spinner spinner-small" aria-hidden="true" /><span>Loading operational summary…</span></div>;
}
function hourLabel(hour: string): string {
return new Intl.DateTimeFormat("en-GB", { timeZone: "Europe/Brussels", hour: "2-digit", minute: "2-digit" }).format(new Date(hour));
}
function Trend({ points, label }: { points: TrendBucket[]; label: string }) {
const [selected, setSelected] = useState<TrendBucket | null>(null);
const maximum = Math.max(1, ...points.map((item) => item.success + item.denied + item.error + item.rate_limited));
const description = (point: TrendBucket) => `${formatBrusselsTime(point.hour)}${point.hour !== point.endHour ? ` to ${hourLabel(point.endHour)}` : ""}: ${point.success} success, ${point.denied} denied, ${point.error} errors, ${point.rate_limited} rate limited`;
return <div className={label === "Four-hour call buckets" ? "mobile-trend" : "desktop-trend"}>
<div className="trend-chart" role="group" aria-label={label}>{points.map((point) => {
const total = point.success + point.denied + point.error + point.rate_limited;
return <button type="button" className="trend-column" key={point.hour} aria-label={description(point)} aria-pressed={selected?.hour === point.hour} onFocus={() => setSelected(point)} onClick={() => setSelected(point)}>
<span className="trend-total">{total || ""}</span><span className="trend-stack" style={{ height: `${Math.max(4, (total / maximum) * 100)}%` }}>{total ? <><i className="trend-success" style={{ flexGrow: point.success }} /><i className="trend-denied" style={{ flexGrow: point.denied }} /><i className="trend-error" style={{ flexGrow: point.error }} /><i className="trend-limited" style={{ flexGrow: point.rate_limited }} /></> : <i className="trend-empty" />}</span><small>{hourLabel(point.hour)}</small>
</button>;
})}</div><p className="trend-detail" role="status">{selected ? description(selected) : "Select a time bucket for its outcomes. Times shown in Brussels."}</p>
</div>;
}
export function Operations({ path, navigate, snapshot }: { path: string; navigate: (next: string) => void; snapshot: DashboardSnapshot | null }) {
const [api] = useState(() => createApiClient());
type OperationalRead = { snapshot: DashboardSnapshot; summary: OperationsSummary };
export function Operations({ path, navigate, snapshot: initialSnapshot, api }: { path: string; navigate: (next: string) => void; snapshot: DashboardSnapshot | null; api: ManagementApiClient }) {
const canCheck = useCan("operations.check");
const [summary, setSummary] = useState<OperationsSummary | null>(null);
const canReadAudit = useCan("audit.read");
const read = useCallback(async () => { const [snapshot, summary] = await Promise.all([api.dashboard(), api.operationsSummary()]); return { snapshot, summary }; }, [api]);
const stale = useCallback((value: OperationalRead) => isSnapshotStale(value.snapshot) || Date.now() - Date.parse(value.summary.generatedAt) > 60_000, []);
const { value, status, error: refreshError, refresh } = useStatusRefresh<OperationalRead>(null, read, stale);
const snapshot = value?.snapshot ?? initialSnapshot;
const summary = value?.summary ?? null;
const summaryLoading = !summary && status === "refreshing";
const [check, setCheck] = useState<OperationsCheck | null>(null);
const [busy, setBusy] = useState(false);
const [summaryLoading, setSummaryLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
let active = true;
setSummaryLoading(true);
void Promise.all([api.session(), api.operationsSummary()])
.then(([, value]) => { if (active) setSummary(value); })
.catch((caught) => { if (active) setError(safeApiMessage(caught)); })
.finally(() => { if (active) setSummaryLoading(false); });
return () => { active = false; };
}, [api]);
const section = new URLSearchParams(path.split("?")[1] ?? "").get("section");
useEffect(() => { if (section && ["alerts", "checks", "recovery"].includes(section)) document.getElementById(`operations-${section}`)?.scrollIntoView({ block: "start" }); }, [section, summary]);
const runCheck = async () => {
setBusy(true);
setError("");
try {
setCheck(await api.runOperationsCheck());
setSummary(await api.operationsSummary());
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy(false);
}
if (busy) return;
setBusy(true); setError("");
try { setCheck(await api.runOperationsCheck()); await refresh(); }
catch (caught) { setError(safeApiMessage(caught)); }
finally { setBusy(false); }
};
if (!snapshot) return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate} /><section className="panel resource-page"><SummaryLoading /></section></div>;
if (!snapshot) return <section className="panel">{refreshError ? <div className="notice notice-danger" role="alert">{refreshError}<button type="button" onClick={() => void refresh()}>Retry</button></div> : <SummaryLoading />}</section>;
const audit = summary?.audit24h;
const totalCalls = (audit?.success ?? 0) + (audit?.denied ?? 0) + (audit?.error ?? 0) + (audit?.rate_limited ?? 0);
const maximum = Math.max(1, ...(summary?.trend.map((item) => item.success + item.denied + item.error + item.rate_limited) ?? [1]));
const totalCalls = audit ? audit.success + audit.denied + audit.error + audit.rate_limited : null;
const snapshotStale = isSnapshotStale(snapshot);
return <div className="resource-stack operations-page">
<Breadcrumbs path={path} navigate={navigate} />
<section className="panel resource-page operations-overview">
<div className="panel-heading"><div><p className="eyebrow">Operational health</p><h2>Operations</h2><p className="resource-description">Validate the live connector path, reconcile signal, governed call volume and recovery posture.</p></div>{canCheck ? <button className="button button-small" onClick={() => void runCheck()} disabled={busy}>{busy ? <><span className="spinner spinner-small" aria-hidden="true" />Checking live routes…</> : <><Icon name="refresh" size={14} />Run live checks</>}</button> : null}</div>
<div className="detail-grid"><MetricCard label="Reconcile drift" value={snapshot.drift.known ? `${snapshot.drift.count}` : "Unknown"} detail={snapshot.drift.known ? (snapshot.drift.projects.length ? snapshot.drift.projects.join(", ") : "No drift reported") : snapshot.drift.detail} status={!snapshot.drift.known ? "unknown" : snapshot.drift.count ? "degraded" : "healthy"} /><MetricCard label="Backup freshness" value={snapshot.backup.lastSuccessfulAt ? formatBrusselsTime(snapshot.backup.lastSuccessfulAt) : "Not recorded"} detail={`${snapshot.backup.detail}${snapshot.backup.restoreVerified ? ` · ${snapshot.backup.artifactCount ?? 0} artifacts` : ""}`} status={snapshot.backup.state} /><MetricCard label="Governed calls · 24h" value={`${totalCalls}`} detail={summaryLoading ? "Loading call totals…" : `${audit?.success ?? 0} success · ${audit?.denied ?? 0} denied · ${audit?.error ?? 0} errors · ${audit?.rate_limited ?? 0} limited`} /></div>
{error ? <div className="notice notice-danger" role="alert">{error}</div> : null}
{check ? <div className={check.healthy ? "live-check-summary live-check-healthy" : "live-check-summary live-check-attention"}><div><StatusPill value={check.healthy ? "healthy" : "degraded"} /><strong>{check.healthy ? "All checked connector paths responded" : "One or more connector paths need attention"}</strong><small>Checked {formatBrusselsTime(check.generatedAt)}</small></div><div className="connector-checks" role="status">{Object.entries(check.connectors).map(([name, item]) => <div key={name}><StatusPill value={item.reachable ? "healthy" : "unavailable"} /><strong>{name}</strong><span>{item.status} · {item.detail}</span></div>)}</div></div> : null}
<section className="panel resource-page operations-overview" id="operations-checks">
<div className="panel-heading"><div><p className="eyebrow">Operational health</p><h2>Operations</h2><p className="resource-description">Inspect connector checks, catalog parity, governed calls and recovery evidence.</p><span className={`freshness freshness-${status}`} role="status">{refreshLabel[status]}</span></div><div className="operations-actions"><button type="button" className="button button-small button-secondary" onClick={() => void refresh()} disabled={status === "refreshing"}><Icon name="refresh" size={14} />Refresh status</button>{canCheck ? <button className="button button-small" onClick={() => void runCheck()} disabled={busy}>{busy ? "Checking live routes…" : "Run live checks"}</button> : null}</div></div>
<div className="detail-grid"><MetricCard label="Reconcile drift" value={snapshot.drift.known ? `${snapshot.drift.count}` : "Unknown"} detail={snapshot.drift.known ? (snapshot.drift.projects.length ? snapshot.drift.projects.join(", ") : "No drift reported") : snapshot.drift.detail} status={snapshotStale ? "degraded" : !snapshot.drift.known ? "unknown" : snapshot.drift.count ? "degraded" : "healthy"} /><MetricCard label="Backup freshness" value={snapshot.backup.lastSuccessfulAt ? formatBrusselsTime(snapshot.backup.lastSuccessfulAt) : "Not recorded"} detail={snapshot.backup.detail} status={snapshotStale ? "degraded" : snapshot.backup.state} /><MetricCard label="Governed calls · 24h" value={totalCalls === null ? "Unknown" : `${totalCalls}`} detail={audit ? `${audit.success} success · ${audit.denied} denied · ${audit.error} errors · ${audit.rate_limited} limited` : summaryLoading ? "Loading call totals…" : "Summary unavailable; retry refresh"} /></div>
{error || refreshError ? <div className="notice notice-danger" role="alert">{error || refreshError} Any retained observations keep their original timestamp.</div> : null}<p className="generated-time">Health snapshot {formatBrusselsTime(snapshot.generatedAt)}{snapshotStale ? " · stale" : ""}</p>
{check ? <div className={check.healthy ? "live-check-summary live-check-healthy" : "live-check-summary live-check-attention"}><div><StatusPill value={check.healthy ? "healthy" : "degraded"} /><strong>{check.healthy ? "All checked connector paths responded" : "One or more connector paths need attention"}</strong><small>Checked {formatBrusselsTime(check.generatedAt)}</small></div><div className="connector-checks" role="status">{Object.entries(check.connectors).map(([name, item]) => <div key={name}><StatusPill value={item.reachable ? "healthy" : "unavailable"} /><strong>{name}</strong>{item.connectorRefs?.map((connector) => <a key={connector.id} href={`/connectors/${encodeURIComponent(connector.id)}`} onClick={(event) => { event.preventDefault(); navigate(`/connectors/${encodeURIComponent(connector.id)}`); }}>{connector.name}</a>)}{item.connectorRefsTruncated && <small>First 100 registrations shown</small>}<span>{item.status} · {item.detail}</span></div>)}</div><p className="muted">This is an explicit point-in-time probe; automatic refresh does not run checks.</p></div> : <div className="notice notice-info">{canCheck ? "Run live checks to record a point-in-time connector observation." : "Live connector probes require the operator role."}</div>}
</section>
<section className="operations-grid">
<article className="panel attention-panel">
<div className="panel-heading"><div><p className="eyebrow">Notifications</p><h3>Current attention queue</h3></div>{summary ? <span className="tag">{summary.alerts.length} active</span> : null}</div>
{summaryLoading ? <SummaryLoading /> : summary?.alerts.length ? <div className="alert-list">{summary.alerts.map((alert) => <div className={`notice ${alert.severity === "warning" ? "notice-danger" : "notice-info"}`} key={alert.code}><strong>{alert.code.replaceAll("_", " ")}</strong><span>{alert.message}</span></div>)}</div> : <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>No active operational alerts</strong><small>Current summaries report no condition requiring intervention.</small></span></div>}
<article className="panel attention-panel" id="operations-alerts"><div className="panel-heading"><div><p className="eyebrow">Notifications</p><h3>Current attention queue</h3></div>{summary ? <span className="tag">{summary.alerts.length} reported</span> : null}</div>
{summaryLoading ? <SummaryLoading /> : !summary ? <div className="notice notice-warning">Alerts are unknown while the summary is unavailable.</div> : summary.alerts.length ? <div className="alert-list">{summary.alerts.map((alert) => { const href = alertDestination(alert.code, canReadAudit); return <div className={`notice ${alert.severity === "warning" ? "notice-danger" : "notice-info"}`} key={alert.code}><strong>{alert.severity} · {alert.code.replaceAll("_", " ")}</strong><span>{alert.message}</span><a className="signal-next-step" href={href} onClick={(event) => { event.preventDefault(); navigate(href); }}>Inspect context <Icon name="arrow" size={14} /></a></div>; })}</div> : <div className="empty-line empty-success"><span aria-hidden="true">✓</span><span><strong>No alerts in the latest summary</strong><small>Generated {formatBrusselsTime(summary.generatedAt)}</small></span></div>}
</article>
<article className="panel trend-panel">
<div className="panel-heading"><div><p className="eyebrow">Last 24 hours</p><h3>Governed-call trend</h3></div>{summary ? <small className="generated-time">Updated {formatBrusselsTime(summary.generatedAt)}</small> : null}</div>
<div className="chart-legend" aria-label="Chart legend"><span><i className="legend-success" />Success</span><span><i className="legend-denied" />Denied</span><span><i className="legend-error" />Error</span><span><i className="legend-limited" />Rate limited</span></div>
{summaryLoading ? <SummaryLoading /> : summary?.trend.length ? <div className="trend-chart" role="img" aria-label={`Hourly governed calls. ${audit?.success ?? 0} successful, ${audit?.denied ?? 0} denied, ${audit?.error ?? 0} errors and ${audit?.rate_limited ?? 0} rate limited in the last 24 hours.`}>{summary.trend.map((point) => {
const total = point.success + point.denied + point.error + point.rate_limited;
const height = Math.max(4, (total / maximum) * 100);
return <div className="trend-column" key={point.hour} title={`${formatBrusselsTime(point.hour)} · ${total} calls`}><span className="trend-total">{total || ""}</span><span className="trend-stack" style={{ height: `${height}%` }}>{total ? <><i className="trend-success" style={{ flexGrow: point.success }} /><i className="trend-denied" style={{ flexGrow: point.denied }} /><i className="trend-error" style={{ flexGrow: point.error }} /><i className="trend-limited" style={{ flexGrow: point.rate_limited }} /></> : <i className="trend-empty" />}</span><small>{new Date(point.hour).getHours().toString().padStart(2, "0")}</small></div>;
})}</div> : <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No governed calls in this window</strong><small>The chart will populate when audited traffic is recorded.</small></span></div>}
<article className="panel trend-panel"><div className="panel-heading"><div><p className="eyebrow">Last 24 hours</p><h3>Governed-call trend</h3></div>{summary ? <small className="generated-time">Updated {formatBrusselsTime(summary.generatedAt)}</small> : null}</div>
{summary?.auditTruncated ? <div className="notice notice-warning">The bounded audit window is truncated; totals describe only the returned sample.</div> : null}<div className="chart-legend" aria-label="Chart legend"><span><i className="legend-success" />Success</span><span><i className="legend-denied" />Denied</span><span><i className="legend-error" />Error</span><span><i className="legend-limited" />Rate limited</span></div>
{summaryLoading ? <SummaryLoading /> : summary?.trend.length ? <><Trend points={aggregateTrend(summary.trend, 1)} label="Hourly governed calls" /><Trend points={aggregateTrend(summary.trend, 4)} label="Four-hour call buckets" /></> : <div className="empty-line"><span aria-hidden="true">○</span><span>{summary ? "No call observations in this window" : "Call trend unavailable; refresh to retry"}</span></div>}
</article>
</section>
<section className="panel recovery-panel" id="operations-recovery"><div className="panel-heading"><div><p className="eyebrow">Recovery evidence</p><h3>Backup and restore</h3></div><StatusPill value={snapshotStale ? "degraded" : snapshot.backup.state} /></div><dl className="recovery-record"><dt>Last successful backup</dt><dd>{formatBrusselsTime(snapshot.backup.lastSuccessfulAt)}</dd><dt>Restore verification</dt><dd>{snapshot.backup.restoreVerified === true ? "Recorded as verified" : "Not verified in the current snapshot"}</dd><dt>Recorded artifacts</dt><dd>{snapshot.backup.artifactCount ?? "Unknown"}</dd><dt>Observation</dt><dd>{snapshot.backup.detail}</dd></dl><p className="muted">Recovery is an operator-run process. This browser displays evidence; it does not restore or change production state.</p></section>
<section className="panel approvals-panel"><div><p className="eyebrow">Controlled changes</p><h3>Approvals</h3><p className="muted">Approval workflow is not yet part of the authorized v1 management surface.</p></div><span className="boundary-badge">Intentionally unavailable</span></section>
</div>;
}
+193 -10
View File
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import type { ManagementApiClient, ProjectDetailInfo, ResourceKind, ResourceRecord } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -7,11 +7,62 @@ import { LoadingPanel } from "../components/LoadingPanel";
import { MetricCard } from "../components/MetricCard";
import { basePath } from "../routing";
import { useCan } from "../rbac-context";
import "../styles/details.css";
const projectTabs = ["Summary", "Environments", "MCP Servers", "Capabilities", "Connectors", "Knowledge Sources", "Clients", "Audit"] as const;
type ProjectTab = typeof projectTabs[number];
export interface DetailCardItem {
id: string;
title: string;
summary?: string;
status?: string;
fields: Array<{ label: string; value: string }>;
action?: { label: string; onSelect: () => void };
}
export function DetailCards({ label, cards }: { label: string; cards: DetailCardItem[] }) {
return <div className="detail-card-grid" aria-label={label}>
{cards.map((card) => <article className="detail-card" key={card.id}>
<div className="detail-card-heading">
<h3>{card.title}</h3>
{card.status && <span className="detail-card-status">{card.status}</span>}
</div>
{card.summary && <p className="detail-card-summary">{card.summary}</p>}
<dl className="detail-card-fields">{card.fields.map((field) => <div className="detail-card-field" key={`${card.id}-${field.label}`}>
<dt>{field.label}</dt><dd>{field.value}</dd>
</div>)}</dl>
{card.action && <button className="button button-quiet detail-card-action" type="button" onClick={card.action.onSelect}>{card.action.label}</button>}
</article>)}
</div>;
}
function DetailTable({ label, headers, rows }: { label: string; headers: string[]; rows: { key: string; values: string[] }[] }) {
return <><div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label={`${label} table`}><table><caption className="sr-only">{label}</caption><thead><tr>{headers.map((header) => <th key={header}>{header}</th>)}</tr></thead><tbody>{rows.map((row) => <tr key={row.key}>{row.values.map((value, index) => <td key={`${row.key}-${headers[index]}`}>{value}</td>)}</tr>)}</tbody></table></div><div className="mobile-record-list" aria-label={label}>{rows.map((row) => <article className="mobile-record" key={row.key}><dl>{row.values.map((value, index) => <><dt key={`${row.key}-dt-${headers[index]}`}>{headers[index]}</dt><dd key={`${row.key}-dd-${headers[index]}`}>{value}</dd></>)}</dl></article>)}</div></>;
return <>
<div className="table-wrap desktop-data-table" role="region" tabIndex={0} aria-label={`${label} table`}>
<table><caption className="sr-only">{label}</caption><thead><tr>{headers.map((header) => <th key={header}>{header}</th>)}</tr></thead>
<tbody>{rows.map((row) => <tr key={row.key}>{row.values.map((value, index) => <td key={`${row.key}-${headers[index]}`}>{value}</td>)}</tr>)}</tbody>
</table>
</div>
<div className="mobile-record-list" aria-label={label}>{rows.map((row) => <article className="mobile-record" key={row.key}>
<dl>{row.values.map((value, index) => <div className="mobile-record-field" key={`${row.key}-${headers[index]}`}><dt>{headers[index]}</dt><dd>{value}</dd></div>)}</dl>
</article>)}</div>
</>;
}
async function loadScopedRows(api: ManagementApiClient, kind: ResourceKind, project: string): Promise<{ rows: ResourceRecord[]; total: number; truncated: boolean }> {
const rows: ResourceRecord[] = [];
let cursor: string | undefined;
let total = 0;
let pages = 0;
do {
const page = await api.resource(kind, { filter: "", limit: 100, cursor, project });
rows.push(...page.rows);
total = page.page.total;
cursor = page.page.nextCursor;
pages += 1;
} while (cursor && pages < 10);
return { rows, total, truncated: Boolean(cursor) };
}
export function ProjectDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
@@ -19,17 +70,149 @@ export function ProjectDetail({ path, navigate, api }: { path: string; navigate:
const requestedTab = new URLSearchParams(path.split("?")[1] ?? "").get("tab") ?? "Summary";
const canReadAudit = useCan("audit.read");
const tabs = projectTabs.filter((item) => item !== "Audit" || canReadAudit);
const tab = tabs.includes(requestedTab as typeof tabs[number]) ? requestedTab : "Summary";
const tab = tabs.includes(requestedTab as ProjectTab) ? requestedTab as ProjectTab : "Summary";
const [detail, setDetail] = useState<ProjectDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "error" | "notFound">("loading");
const [state, setState] = useState<"loading" | "ready" | "error" | "notFound">("loading");
const [error, setError] = useState("");
const [scopedRows, setScopedRows] = useState<ResourceRecord[] | null>(null);
const load = async () => { setState("loading"); try { setDetail(await api.projectDetail(slug)); setState("loading"); } catch (caught) { if (caught instanceof ApiError && caught.status === 404) setState("notFound"); else { setError(safeApiMessage(caught)); setState("error"); } } };
useEffect(() => { void load(); }, [slug]);
useEffect(() => { if (tab !== "Clients" && tab !== "Audit") { setScopedRows(null); return; } const kind: ResourceKind = tab === "Clients" ? "clients" : "audit"; let active = true; void api.resource(kind, { filter: "", limit: 100 }).then((page) => { if (active) setScopedRows(page.rows.filter((row) => row.project === slug)); }).catch(() => { if (active) setScopedRows([]); }); return () => { active = false; }; }, [api, tab, slug]);
const loadGeneration = useRef(0);
const [scopedRows, setScopedRows] = useState<ResourceRecord[]>([]);
const [scopedState, setScopedState] = useState<"idle" | "loading" | "ready" | "error">("idle");
const [scopedError, setScopedError] = useState("");
const [scopedTruncated, setScopedTruncated] = useState(false);
const [scopedTotal, setScopedTotal] = useState(0);
const [scopedRevision, setScopedRevision] = useState(0);
const load = useCallback(async () => {
const generation = ++loadGeneration.current;
setState("loading");
setDetail(null);
setError("");
try {
const next = await api.projectDetail(slug);
if (generation !== loadGeneration.current) return;
setDetail(next);
setState("ready");
} catch (caught) {
if (generation !== loadGeneration.current) return;
if (caught instanceof ApiError && caught.status === 404) setState("notFound");
else { setError(safeApiMessage(caught)); setState("error"); }
}
}, [api, slug]);
useEffect(() => { void load(); return () => { loadGeneration.current += 1; }; }, [load]);
useEffect(() => {
if (tab !== "Clients" && tab !== "Audit") { setScopedState("idle"); return; }
const kind: ResourceKind = tab === "Clients" ? "clients" : "audit";
let active = true;
setScopedState("loading");
setScopedError("");
setScopedTruncated(false);
void loadScopedRows(api, kind, slug).then((result) => {
if (!active) return;
setScopedRows(result.rows);
setScopedTotal(result.total);
setScopedTruncated(result.truncated);
setScopedState("ready");
}).catch((caught: unknown) => {
if (!active) return;
setScopedError(safeApiMessage(caught));
setScopedState("error");
});
return () => { active = false; };
}, [api, tab, slug, scopedRevision]);
if (state === "notFound") return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><div className="notice notice-danger" role="alert">Project <strong>{slug}</strong> is not registered.</div></section></div>;
if (state === "error") return <ErrorPanel message={error} retry={() => void load()}/>;
if (!detail) return <LoadingPanel />;
const scopedTable = scopedRows === null ? <LoadingPanel /> : scopedRows.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No {tab.toLowerCase()} records for this project.</span></div> : <DetailTable label={tab} headers={["Name", "Status", "Summary"]} rows={scopedRows.map((row) => ({ key: row.id, values: [row.name, row.status, row.summary] }))} />;
return <div className="resource-stack"><Breadcrumbs path={path} navigate={navigate}/><section className="panel resource-page"><p className="eyebrow">Project workspace</p><h2>{detail.name}</h2><p className="muted">{detail.description || "Scoped inspection for this project. Reads are bounded by project and environment context."}</p><nav className="tabs" aria-label="Project detail sections">{tabs.map((item) => <a key={item} aria-current={tab === item ? "page" : undefined} className={tab === item ? "tab active" : "tab"} href={`/projects/${slug}?tab=${encodeURIComponent(item)}`} onClick={(event) => { event.preventDefault(); navigate(`/projects/${slug}?tab=${encodeURIComponent(item)}`); }}>{item}</a>)}</nav>{tab === "Summary" && <div className="detail-grid"><MetricCard label="Environments" value={`${detail.environments.length}`} detail={detail.environments.map((item) => item.name).join(", ") || "None registered"} status={detail.environments.length ? "healthy" : "unknown"}/><MetricCard label="Clients" value={`${detail.activeClientCount} / ${detail.clientCount}`} detail="Active of total registered clients"/><MetricCard label="Virtual servers" value={`${detail.virtualServerCount}`} detail="Read-only virtual servers"/></div>}{tab === "Environments" && (detail.environments.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No environments registered for this project.</span></div> : <DetailTable label="Environments" headers={["Environment", "Classification"]} rows={detail.environments.map((item) => ({ key: item.name, values: [item.name, item.classification ?? "unclassified"] }))} />)}{tab === "Capabilities" && (detail.capabilityPacks.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No capability packs registered.</span></div> : <div className="stack-list">{detail.capabilityPacks.map((pack) => <div className="stack-item" key={pack}><strong>{pack}</strong></div>)}</div>)}{tab === "Connectors" && (detail.connectorBindings.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No connector bindings registered.</span></div> : <DetailTable label="Connector bindings" headers={["Ref", "Type", "Environments", "Verification"]} rows={detail.connectorBindings.map((item) => ({ key: item.ref, values: [item.ref, item.connectorType ?? "unknown", item.environments.join(", ") || "—", item.verification ?? "unverified"] }))} />)}{tab === "MCP Servers" && <div className="notice notice-info">Per-server inspection is not yet part of the authorized v1 read surface. {detail.virtualServerCount} virtual server(s) are provisioned for this project.</div>}{tab === "Knowledge Sources" && <div className="notice notice-info">Knowledge source listing is not yet part of the authorized v1 read surface.</div>}{(tab === "Clients" || tab === "Audit") && scopedTable}</section></div>;
const scopedRowsContent = scopedState === "loading" ? <LoadingPanel />
: scopedState === "error" ? <div className="notice notice-danger" role="alert"><p>{scopedError}</p><button className="button button-quiet" type="button" onClick={() => setScopedRevision((value) => value + 1)}>Retry</button></div>
: scopedRows.length === 0 ? <div className="empty-line"><span aria-hidden="true">○</span><span>No {tab.toLowerCase()} records for this project.</span></div>
: tab === "Clients" ? <DetailCards label="Project clients" cards={scopedRows.map((row) => ({
id: row.id, title: row.name, status: row.status, summary: row.summary,
fields: [
{ label: "Environment", value: row.environment ?? "Unknown" },
{ label: "Onboarding", value: row.onboardingStage ?? "Not managed by onboarding" },
],
action: row.onboardingStage ? { label: "Resume onboarding", onSelect: () => navigate(`/clients?onboard=${encodeURIComponent(row.id)}`) } : undefined,
}))} />
: <DetailTable label={tab} headers={["Name", "Status", "Summary"]} rows={scopedRows.map((row) => ({ key: row.id, values: [row.name, row.status, row.summary] }))} />;
const connectorCards: DetailCardItem[] = detail.connectorBindings.map((item) => ({
id: item.ref,
title: item.connectorType ?? item.ref,
summary: `Catalog connector binding: ${item.ref}`,
status: item.verification ?? "unverified",
fields: [{ label: "Environments", value: item.environments.join(", ") || "None" }],
action: { label: "Open connector detail", onSelect: () => navigate(`/connectors/${encodeURIComponent(item.ref)}`) },
}));
const serverCards: DetailCardItem[] = detail.mcpServers.map((server) => ({
id: server.slug,
title: server.slug,
summary: server.stateDetail,
status: server.runtimeState,
fields: [
{ label: "Environment", value: server.environment },
{ label: "Endpoint alias", value: server.endpointAlias },
{ label: "Enabled", value: server.enabled === null ? "No persisted state" : server.enabled ? "Yes" : "No" },
{ label: "Default decision", value: server.defaultDecision },
{ label: "Protocol revisions", value: server.protocolRevisions.join(", ") || "None registered" },
],
}));
const knowledgeCards: DetailCardItem[] = detail.knowledgeSources.map((source) => ({
id: source.id,
title: source.uri,
summary: source.accessBoundary,
status: source.verification,
fields: [
{ label: "Included paths", value: source.include.join(", ") || "No path filters registered" },
{ label: "Maximum document size", value: source.maxDocumentBytes === null ? "Unspecified" : `${source.maxDocumentBytes.toLocaleString()} bytes` },
{ label: "Indexes secrets", value: source.indexesSecrets ? "Catalog marks this source as indexing secrets" : "No" },
],
}));
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate}/>
<section className="panel resource-page">
<p className="eyebrow">Project workspace</p><h2>{detail.name}</h2>
<p className="muted">{detail.description || "Scoped inspection for this project. Reads are bounded by project and environment context."}</p>
<label className="detail-mobile-section"><span>Project section</span>
<select value={tab} onChange={(event) => navigate(`/projects/${encodeURIComponent(slug)}?tab=${encodeURIComponent(event.target.value)}`)}>
{tabs.map((item) => <option key={item} value={item}>{item}</option>)}
</select>
</label>
<nav className="tabs detail-desktop-tabs" aria-label="Project detail sections">
{tabs.map((item) => <a key={item} aria-current={tab === item ? "page" : undefined} className={tab === item ? "tab active" : "tab"}
href={`/projects/${encodeURIComponent(slug)}?tab=${encodeURIComponent(item)}`}
onClick={(event) => { event.preventDefault(); navigate(`/projects/${encodeURIComponent(slug)}?tab=${encodeURIComponent(item)}`); }}>{item}</a>)}
</nav>
{tab === "Summary" && <div className="detail-grid">
<MetricCard label="Environments" value={`${detail.environments.length}`} detail={detail.environments.map((item) => item.name).join(", ") || "None registered"} status={detail.environments.length ? "healthy" : "unknown"}/>
<MetricCard label="Clients" value={`${detail.activeClientCount} / ${detail.clientCount}`} detail="Active of total registered clients"/>
<MetricCard label="MCP servers" value={`${detail.virtualServerCount}`} detail="Cataloged project server routes"/>
</div>}
{tab === "Environments" && (detail.environments.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No environments registered for this project.</span></div>
: <DetailCards label="Project environments" cards={detail.environments.map((item) => ({ id: item.name, title: item.name, status: item.classification ?? "unclassified", fields: [{ label: "Classification", value: item.classification ?? "Unclassified" }] }))} />)}
{tab === "Capabilities" && (detail.capabilityPacks.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No capability packs registered.</span></div>
: <DetailCards label="Capability packs" cards={detail.capabilityPacks.map((pack) => ({ id: pack, title: pack, fields: [] }))} />)}
{tab === "Connectors" && (connectorCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No connector bindings registered.</span></div>
: <DetailCards label="Connector bindings" cards={connectorCards} />)}
{tab === "MCP Servers" && (serverCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No MCP servers are registered for this project.</span></div>
: <DetailCards label="MCP servers" cards={serverCards} />)}
{tab === "Knowledge Sources" && <>
<p className="detail-boundary-note">This view shows cataloged source registrations and verification metadata. Original document contents remain behind the scoped Knowledge connector and project policy.</p>
{knowledgeCards.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span>No knowledge sources are registered for this project.</span></div>
: <DetailCards label="Knowledge sources" cards={knowledgeCards} />}
</>}
{(tab === "Clients" || tab === "Audit") && <>
{scopedTruncated && <p className="notice notice-info">Showing the first {scopedRows.length} of at least {scopedTotal} records. Narrow the resource view to inspect more.</p>}
{scopedRowsContent}
</>}
</section>
</div>;
}
+41 -17
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from "react";
import type { ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts";
import type { ClientOnboardingResult, ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts";
import { routeTitle } from "../contracts";
import { safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
@@ -13,6 +13,8 @@ import { useCan } from "../rbac-context";
import { CatalogWorkflow } from "./CatalogWorkflow";
import { ClientOnboarding } from "./ClientOnboarding";
import { PolicySimulator } from "./PolicySimulator";
import { readPreference, writePreference } from "../preferences";
import { RecentToolRuns } from "../components/RecentToolRuns";
import { ToolReadinessModal } from "./ToolReadinessModal";
const descriptions: Record<ResourceKind, string> = {
@@ -27,7 +29,7 @@ const descriptions: Record<ResourceKind, string> = {
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 }) {
export function ResourcePage({ path, kind, api, navigate, onClientCreated }: { path: string; kind: ResourceKind; api: ManagementApiClient; navigate: (next: string) => void; onClientCreated?: (client: ClientOnboardingResult["client"]) => void }) {
const canDisableClients = useCan("client.disable");
const canTestTools = useCan("tool.test");
const params = new URLSearchParams(path.split("?")[1] ?? "");
@@ -38,7 +40,11 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
const [error, setError] = useState("");
const [feedback, setFeedback] = useState<Feedback>(null);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]);
const [density, setDensity] = useState<Density>("comfortable");
const [density, setDensity] = useState<Density>(() => readPreference("hub-density", ["comfortable", "compact"], "comfortable") as Density);
const [busyClientId, setBusyClientId] = useState<string | null>(null);
const reopened = params.get("reopen");
const initialProject = params.get("project") ?? "";
const initialEnvironment = params.get("environment") ?? "";
const [testingTool, setTestingTool] = useState<ResourceRecord | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const cursor = cursorStack[cursorStack.length - 1];
@@ -50,20 +56,34 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
}, [kind, canDisableClients, canTestTools]);
useEffect(() => { setFilterInput(urlFilter); }, [urlFilter]);
useEffect(() => {
if (kind !== "tools" || !reopened || !canTestTools) return;
let active = true;
void api.toolDefinition(reopened).then((definition) => {
if (active) setTestingTool({ id: definition.canonicalName, canonicalName: definition.canonicalName, name: definition.canonicalName, status: "unknown", summary: definition.description, risk: definition.riskClass });
}).catch((caught) => { if (active) setFeedback({ tone: "danger", message: safeApiMessage(caught) }); });
return () => { active = false; };
}, [api, canTestTools, kind, reopened]);
const changeDensity = (next: Density) => { setDensity(next); writePreference("hub-density", next); };
useEffect(() => {
const normalizedFilter = filterInput.trim();
if (normalizedFilter === urlFilter) return;
const timer = window.setTimeout(() => {
navigate(normalizedFilter ? `/${kind}?q=${encodeURIComponent(normalizedFilter)}` : `/${kind}`);
const next = new URLSearchParams();
if (initialProject) next.set("project", initialProject);
if (initialEnvironment) next.set("environment", initialEnvironment);
if (normalizedFilter) next.set("q", normalizedFilter);
navigate(`/${kind}${next.size ? `?${next.toString()}` : ""}`);
}, 260);
return () => window.clearTimeout(timer);
}, [filterInput, kind, navigate, urlFilter]);
useEffect(() => { setCursorStack([undefined]); }, [kind, urlFilter]);
}, [filterInput, initialEnvironment, initialProject, kind, navigate, urlFilter]);
useEffect(() => { setCursorStack([undefined]); }, [initialEnvironment, initialProject, kind, urlFilter]);
useEffect(() => {
let active = true;
setState("loading");
setError("");
void api.resource(kind, { filter: urlFilter, cursor, limit: 25 })
void api.resource(kind, { filter: urlFilter, cursor, limit: 25, project: initialProject || undefined, environment: initialEnvironment || undefined })
.then((value) => {
if (!active) return;
setData(value);
@@ -75,7 +95,7 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
setState("error");
});
return () => { active = false; };
}, [api, cursor, kind, refreshKey, urlFilter]);
}, [api, cursor, initialEnvironment, initialProject, kind, refreshKey, urlFilter]);
const nextPage = () => {
if (data?.page.nextCursor) setCursorStack((stack) => [...stack, data.page.nextCursor]);
@@ -83,6 +103,8 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
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") => {
if (busyClientId) return;
setBusyClientId(id);
setFeedback({ tone: "info", message: "Submitting server-authorized client action…" });
try {
const result = await api.clientAction(id, verb);
@@ -90,15 +112,17 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
retry();
} catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
}
} finally { setBusyClientId(null); }
};
const config = async (id: string) => {
if (busyClientId) return;
setBusyClientId(id);
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) });
}
} finally { setBusyClientId(null); }
};
if (!hasCurrentData && state === "error") return <ErrorPanel message={error} retry={retry} />;
@@ -118,8 +142,8 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
</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>
<button className={density === "comfortable" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "comfortable"} onClick={() => changeDensity("comfortable")}>Comfortable</button>
<button className={density === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => changeDensity("compact")}>Compact</button>
</div>
</div>
<div className={isRefreshing ? "resource-results results-refreshing" : "resource-results"}>
@@ -127,17 +151,17 @@ export function ResourcePage({ path, kind, api, navigate }: { path: string; kind
{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} />}
: <DataTable data={data} kind={kind} density={density} visibleColumns={visibleColumns} onClientAction={action} onConfig={config} onTest={setTestingTool} navigate={navigate} busyClientId={busyClientId} />}
</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><button className="button button-small button-secondary" aria-label="Previous page" onClick={previousPage} disabled={isRefreshing || 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={isRefreshing || !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 === "tools" ? <><RecentToolRuns api={api} navigate={navigate} /><CatalogWorkflow api={api} initialProject={initialProject} /></> : 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}
{kind === "clients" ? <><ClientOnboarding api={api} clientId={params.get("onboard") ?? undefined} navigate={navigate} onCreated={(client) => { retry(); onClientCreated?.(client); }} /><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 profile, endpoint and scopes. 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} initialProject={initialProject} initialEnvironment={initialEnvironment} onClose={() => { setTestingTool(null); if (reopened) navigate("/tools"); }} /> : null}
</div>;
}
+7 -6
View File
@@ -1,15 +1,16 @@
import { useState } from "react";
import { readPreference, writePreference } from "../preferences";
import { MetricCard } from "../components/MetricCard";
import { Breadcrumbs } from "../components/Breadcrumbs";
// Unset localStorage means the CSS falls back to prefers-color-scheme, whose
// default in this design is dark -- so an absent stored value is treated as
// "dark" here too, keeping the toggle's active button in sync with what's
// actually on screen on first load.
function currentTheme(): "light" | "dark" { return localStorage.getItem("hub-theme") === "light" ? "light" : "dark"; }
function currentTheme(): "light" | "dark" {
const rendered = document.documentElement.dataset.theme;
const fallback = rendered === "light" || rendered === "dark" ? rendered : window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
return readPreference("hub-theme", ["light", "dark"], fallback) as "light" | "dark";
}
export function SettingsPage({ path, navigate }: { path: string; navigate: (next: string) => void }) {
const [theme, setThemeState] = useState<"light" | "dark">(currentTheme);
const applyTheme = (next: "light" | "dark") => { document.documentElement.setAttribute("data-theme", next); localStorage.setItem("hub-theme", next); setThemeState(next); };
const applyTheme = (next: "light" | "dark") => { document.documentElement.setAttribute("data-theme", next); writePreference("hub-theme", next); setThemeState(next); };
return <section className="panel resource-page"><Breadcrumbs path={path} navigate={navigate}/><p className="eyebrow">Control-plane preferences</p><h2>Platform</h2><p className="muted">Platform settings are server-managed and scoped by management authorization. Sensitive values are write-only.</p><div className="detail-grid"><MetricCard label="Session" value="Same-origin" detail="Authentik session checked by the BFF" status="healthy"/><MetricCard label="Audit" value="Immutable" detail="Corrections append linked events"/><MetricCard label="Credentials" value="Write-only" detail="Secrets are never read back"/></div><div className="theme-toggle"><span>Appearance</span><button className={theme === "dark" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "dark"} onClick={() => applyTheme("dark")}>Dark</button><button className={theme === "light" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={theme === "light"} onClick={() => applyTheme("light")}>Light</button></div><div className="notice notice-info" role="status">No writable platform settings are available in this browser until the control API publishes an authorized configuration schema.</div></section>;
}
+169 -69
View File
@@ -1,103 +1,203 @@
import { useEffect, useMemo, useState } from "react";
import { safeApiMessage } from "../api";
import { Modal } from "../components/Modal";
import { SchemaInputFields } from "../components/SchemaInputFields";
import { StatusPill } from "../components/StatusPill";
import type { ManagementApiClient, ResourceRecord, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "../contracts";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
import { defaultSchemaInputValues, parseSchemaArguments } from "../schema-input";
import { recordToolRun } from "../tool-history";
type Property = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string];
function defaultArguments(definition: ToolDefinition | null): Record<string, unknown> {
const result: Record<string, unknown> = {};
for (const [name, property] of Object.entries(definition?.inputSchema.properties ?? {})) {
if (property.default !== undefined) result[name] = property.default;
}
return result;
function invocationOutput(invocation: ToolInvocationResult): unknown {
return invocation.structuredContent ?? invocation.content ?? invocation.error ?? null;
}
function fieldValue(value: unknown): string {
if (value === undefined || value === null) return "";
return typeof value === "object" ? JSON.stringify(value) : String(value);
}
function parseField(raw: string, property: Property): unknown {
const type = Array.isArray(property.type) ? property.type.find((item) => item !== "null") : property.type;
if (raw === "") return undefined;
if (type === "integer" || type === "number") return Number(raw);
if (type === "boolean") return raw === "true";
if (type === "array" || type === "object") return JSON.parse(raw);
return raw;
}
function recordInvocation(metadata: Record<string, unknown>): void {
function renderOutput(invocation: ToolInvocationResult): string {
try {
const parsed: unknown = JSON.parse(sessionStorage.getItem("itworx-tool-history-v1") ?? "[]");
const history = Array.isArray(parsed) ? parsed : [];
sessionStorage.setItem(
"itworx-tool-history-v1",
JSON.stringify([metadata, ...history].slice(0, 20)),
);
return JSON.stringify(invocationOutput(invocation), null, 2) ?? "No output was returned.";
} catch {
// Invocation succeeded even when browser storage is disabled or corrupt.
return "The result could not be displayed as JSON.";
}
}
export function ToolReadinessModal({ tool, api, onClose }: { tool: ResourceRecord; api: ManagementApiClient; onClose: () => void }) {
export function ToolReadinessModal({
tool,
api,
onClose,
initialProject,
initialEnvironment,
}: {
tool: ResourceRecord;
api: ManagementApiClient;
onClose: () => void;
initialProject?: string;
initialEnvironment?: string;
}) {
const canTest = useCan("tool.test");
const canInvoke = useCan("tool.invoke");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const [project, setProject] = useState(initialProject ?? "");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [environment, setEnvironment] = useState(initialEnvironment ?? "");
const [definition, setDefinition] = useState<ToolDefinition | null>(null);
const [argumentsValue, setArgumentsValue] = useState<Record<string, unknown>>({});
const [rawArguments, setRawArguments] = useState<Record<string, string>>({});
const [result, setResult] = useState<ToolReadinessResult | null>(null);
const [resultContext, setResultContext] = useState<string | null>(null);
const [invocation, setInvocation] = useState<ToolInvocationResult | null>(null);
const [invocationContext, setInvocationContext] = useState<string | null>(null);
const [error, setError] = useState("");
const [copyStatus, setCopyStatus] = useState("");
const [busy, setBusy] = useState<"readiness" | "invoke" | "">("");
const canonicalName = tool.canonicalName;
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [project, projects]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
useEffect(() => { const canonicalName = tool.canonicalName; if (!canonicalName) return; let active = true; void api.toolDefinition(canonicalName).then((value) => { if (active) { setDefinition(value); setArgumentsValue(defaultArguments(value)); } }).catch((caught) => { if (active) setError(safeApiMessage(caught)); }); return () => { active = false; }; }, [api, tool.canonicalName]);
useEffect(() => {
if (!projects.length) return;
if (initialProject) {
setProject(projects.some((item) => item.id === initialProject) ? initialProject : "");
return;
}
setProject((current) => projects.some((item) => item.id === current) ? current : projects[0]?.id ?? "");
}, [initialProject, projects]);
const required = useMemo(() => new Set(definition?.inputSchema.required ?? []), [definition]);
const selectable = Boolean(tool.canonicalName && project && environment && definition?.readOnly);
const update = (name: string, raw: string, property: Property) => {
try {
const parsed = parseField(raw, property);
setError("");
setArgumentsValue((current) => { const next = { ...current }; if (parsed === undefined) delete next[name]; else next[name] = parsed; return next; });
} catch { setError(`${name} must contain valid JSON.`); }
useEffect(() => {
if (!project) {
setEnvironment("");
return;
}
if (initialEnvironment) {
setEnvironment(environments.includes(initialEnvironment) ? initialEnvironment : "");
return;
}
setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? "");
}, [environments, initialEnvironment, project]);
useEffect(() => {
setDefinition(null);
setRawArguments({});
setResult(null);
setResultContext(null);
setInvocation(null);
setInvocationContext(null);
setError("");
setCopyStatus("");
if (!canonicalName) return;
let active = true;
void api.toolDefinition(canonicalName).then((value) => {
if (!active) return;
setDefinition(value);
setRawArguments(defaultSchemaInputValues(value.inputSchema));
}).catch((caught) => {
if (active) setError(safeApiMessage(caught));
});
return () => { active = false; };
}, [api, canonicalName]);
const parsedInput = useMemo(() => definition
? parseSchemaArguments(rawArguments, definition.inputSchema)
: { arguments: {}, errors: {}, valid: false }, [definition, rawArguments]);
const requestContext = JSON.stringify([canonicalName, project, environment, parsedInput.arguments]);
const currentResult = resultContext === requestContext ? result : null;
const currentInvocation = invocationContext === requestContext ? invocation : null;
const selectable = Boolean(canonicalName && project && environment && definition?.readOnly && parsedInput.valid);
const clearRunState = () => {
setResult(null);
setResultContext(null);
setInvocation(null);
setInvocationContext(null);
setCopyStatus("");
};
const updateArgument = (name: string, value: string) => {
setRawArguments((current) => ({ ...current, [name]: value }));
clearRunState();
setError("");
};
const changeProject = (value: string) => {
setProject(value);
setEnvironment("");
clearRunState();
setError("");
};
const changeEnvironment = (value: string) => {
setEnvironment(value);
clearRunState();
setError("");
};
const readiness = async () => {
if (!selectable || !tool.canonicalName) return;
setBusy("readiness"); setError(""); setResult(null); setInvocation(null);
try { setResult(await api.testTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue })); }
catch (caught) { setError(safeApiMessage(caught)); }
finally { setBusy(""); }
};
const invoke = async () => {
if (!selectable || !tool.canonicalName || !result?.ready) return;
setBusy("invoke"); setError(""); setInvocation(null);
if (!selectable || !canTest || !canonicalName) return;
const context = requestContext;
const input = parsedInput.arguments;
setBusy("readiness");
setError("");
setCopyStatus("");
clearRunState();
try {
const response = await api.invokeTool({ project, environment, tool: tool.canonicalName, arguments: argumentsValue });
setInvocation(response);
recordInvocation({ tool: tool.canonicalName, project, environment, ok: response.ok, traceId: response.traceId, at: new Date().toISOString() });
} catch (caught) { setError(safeApiMessage(caught)); }
finally { setBusy(""); }
const response = await api.testTool({ project, environment, tool: canonicalName, arguments: input });
setResult(response);
setResultContext(context);
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy("");
}
};
const copyResult = async () => { if (invocation) await navigator.clipboard.writeText(JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)); };
const invoke = async () => {
if (!selectable || !canInvoke || !canonicalName || !currentResult?.ready) return;
const context = requestContext;
const input = parsedInput.arguments;
setBusy("invoke");
setError("");
setCopyStatus("");
setInvocation(null);
setInvocationContext(null);
try {
const response = await api.invokeTool({ project, environment, tool: canonicalName, arguments: input });
setInvocation(response);
setInvocationContext(context);
recordToolRun({ tool: canonicalName, project, environment, ok: response.ok, traceId: response.traceId, at: new Date().toISOString() });
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy("");
}
};
const copyResult = async () => {
if (!currentInvocation) return;
try {
await navigator.clipboard.writeText(renderOutput(currentInvocation));
setCopyStatus("Displayed result copied. It is not saved in recent tool history.");
} catch {
setCopyStatus("Clipboard access is unavailable. Select the displayed result to copy it.");
}
};
const projectContextMissing = Boolean(initialProject && projects.length > 0 && !projects.some((item) => item.id === initialProject));
const environmentContextMissing = Boolean(initialEnvironment && project && environments.length > 0 && !environments.includes(initialEnvironment));
return <Modal open title={`Tool console · ${tool.name}`} onClose={onClose}>
<p className="muted">Run a schema-validated readiness check, then invoke this cataloged read-only tool through the audited operator path.</p>
<div className="form-grid readiness-form">
<label>Project<select value={project} onChange={(event) => setProject(event.target.value)} disabled={projects.length === 0}>{projects.length === 0 ? <option value="">No registered projects</option> : projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select value={environment} onChange={(event) => setEnvironment(event.target.value)} disabled={environments.length === 0}>{environments.length === 0 ? <option value="">No registered environments</option> : environments.map((name) => <option key={name} value={name}>{name}</option>)}</select></label>
{Object.entries(definition?.inputSchema.properties ?? {}).map(([name, property]) => <label key={name}>{property.title ?? name}{required.has(name) ? " *" : ""}{property.enum ? <select value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)}><option value="">Select…</option>{property.enum.map((option) => <option key={String(option)} value={String(option)}>{String(option)}</option>)}</select> : property.type === "boolean" ? <select value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)}><option value="">Select…</option><option value="true">Yes</option><option value="false">No</option></select> : <input type={property.type === "integer" || property.type === "number" ? "number" : "text"} min={property.minimum} max={property.maximum} minLength={property.minLength} maxLength={property.maxLength} value={fieldValue(argumentsValue[name])} onChange={(event) => update(name, event.target.value, property)} placeholder={property.description ?? name} />}</label>)}
<div className="tool-console" aria-busy={Boolean(busy)}>
<p className="muted">Check this cataloged read-only route, then invoke it through the audited operator path. Arguments and output stay in this page and are never saved in recent history.</p>
<div className="form-grid readiness-form tool-scope-form">
<label>Project<select aria-label="Project" value={project} onChange={(event) => changeProject(event.target.value)} disabled={projects.length === 0 || Boolean(busy)}><option value="">{projects.length === 0 ? "No registered projects" : "Select a project…"}</option>{projects.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label>
<label>Environment<select aria-label="Environment" value={environment} onChange={(event) => changeEnvironment(event.target.value)} disabled={!project || environments.length === 0 || Boolean(busy)}><option value="">{!project ? "Select a project first" : environments.length === 0 ? "No registered environments" : "Select an environment…"}</option>{environments.map((name) => <option key={name} value={name}>{name}</option>)}</select></label>
</div>
{projectContextMissing ? <p className="notice notice-info">The project from this recent run is no longer registered. Choose a current project to continue.</p> : null}
{environmentContextMissing ? <p className="notice notice-info">The environment from this recent run is not registered for this project. Choose a current environment to continue.</p> : null}
{definition ? <SchemaInputFields key={canonicalName} schema={definition.inputSchema} rawValues={rawArguments} errors={parsedInput.errors} onChange={updateArgument} disabled={Boolean(busy)} /> : error ? null : <p className="muted" role="status">Loading the registered input schema…</p>}
{definition && !definition.readOnly ? <p className="notice notice-info">This tool is not registered as read-only, so this console will not invoke it.</p> : null}
{!canTest ? <p className="notice notice-info">Tool readiness requires the operator or admin role.</p> : null}
{Object.keys(parsedInput.errors).length > 0 ? <p className="tool-input-summary" role="status">Fix the marked schema inputs before running a readiness check.</p> : null}
<div className="tool-console-actions">
<button className="button button-secondary" onClick={() => void readiness()} disabled={Boolean(busy) || !selectable || !canTest} aria-busy={busy === "readiness"}>{busy === "readiness" ? "Checking route…" : "Run readiness check"}</button>
<button className="button" onClick={() => void invoke()} disabled={Boolean(busy) || !canInvoke || !currentResult?.ready} title={!canInvoke ? "Operator or admin role required" : !currentResult?.ready ? "Run a successful readiness check for the current inputs first" : "Invoke the read-only tool"}>{busy === "invoke" ? "Running tool…" : "Run tool"}</button>
</div>
{error ? <p className="error-text" role="alert">{error}</p> : null}
{currentResult ? <div className="readiness-result" role="status"><div className="readiness-summary"><StatusPill value={currentResult.ready ? "healthy" : "degraded"}/><strong>{currentResult.ready ? "Ready" : "Not ready"}</strong><span>{currentResult.project} / {currentResult.environment}</span></div><dl className="readiness-checks"><div><dt>Input</dt><dd><StatusPill value={currentResult.checks.input.valid ? "healthy" : "degraded"}/>{currentResult.checks.input.valid ? "Schema valid" : currentResult.checks.input.errors.join(" · ")}</dd></div><div><dt>Policy</dt><dd><StatusPill value={currentResult.checks.policy.allowed ? "healthy" : "degraded"}/>{currentResult.checks.policy.decision} · {currentResult.checks.policy.reasonCode}</dd></div><div><dt>Connector</dt><dd><StatusPill value={currentResult.checks.connector.reachable ? "healthy" : "unavailable"}/>{currentResult.checks.connector.status} · {currentResult.checks.connector.detail}</dd></div></dl><p className="muted">{currentResult.note}</p><small>Readiness trace {currentResult.checks.policy.traceId}</small></div> : null}
{currentInvocation ? <div className="tool-result" role="status"><div className="panel-heading"><div><p className="eyebrow">Invocation result</p><h3>{currentInvocation.ok ? "Completed" : "Controlled error"}</h3></div><button className="button button-small button-secondary" onClick={() => void copyResult()}>Copy displayed result</button></div><pre aria-label="Tool invocation output">{renderOutput(currentInvocation)}</pre><small>Audit trace {currentInvocation.traceId}</small>{copyStatus ? <p className="copy-status" role="status">{copyStatus}</p> : null}</div> : null}
</div>
<div className="tool-console-actions"><button className="button button-secondary" onClick={() => void readiness()} disabled={Boolean(busy) || !selectable} aria-busy={busy === "readiness"}>{busy === "readiness" ? "Checking…" : "Run readiness check"}</button><button className="button" onClick={() => void invoke()} disabled={Boolean(busy) || !canInvoke || !result?.ready} title={!canInvoke ? "Operator or admin role required" : !result?.ready ? "Run a successful readiness check first" : "Invoke read-only tool"}>{busy === "invoke" ? "Running…" : "Run tool"}</button></div>
{error ? <p className="error-text" role="alert">{error}</p> : null}
{result ? <div className="readiness-result" role="status"><div className="readiness-summary"><StatusPill value={result.ready ? "healthy" : "degraded"}/><strong>{result.ready ? "Ready" : "Not ready"}</strong><span>{result.project} / {result.environment}</span></div><dl className="readiness-checks"><div><dt>Input</dt><dd><StatusPill value={result.checks.input.valid ? "healthy" : "degraded"}/>{result.checks.input.valid ? "Schema valid" : result.checks.input.errors.join(" · ")}</dd></div><div><dt>Policy</dt><dd><StatusPill value={result.checks.policy.allowed ? "healthy" : "degraded"}/>{result.checks.policy.decision} · {result.checks.policy.reasonCode}</dd></div><div><dt>Connector</dt><dd><StatusPill value={result.checks.connector.reachable ? "healthy" : "unavailable"}/>{result.checks.connector.status} · {result.checks.connector.detail}</dd></div></dl><p className="muted">{result.note}</p><small>Readiness trace {result.checks.policy.traceId}</small></div> : null}
{invocation ? <div className="tool-result" role="status"><div className="panel-heading"><div><p className="eyebrow">Invocation result</p><h3>{invocation.ok ? "Completed" : "Controlled error"}</h3></div><button className="button button-small button-secondary" onClick={() => void copyResult()}>Copy result</button></div><pre>{JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)}</pre><small>Audit trace {invocation.traceId}</small></div> : null}
</Modal>;
}
+7
View File
@@ -0,0 +1,7 @@
/** Only presentation values belong here. Storage may be denied by the browser. */
export function readPreference(key: "hub-theme" | "hub-density", values: readonly string[], fallback: string): string {
try { const stored = localStorage.getItem(key); return stored && values.includes(stored) ? stored : fallback; } catch { return fallback; }
}
export function writePreference(key: "hub-theme" | "hub-density", value: string): void {
try { localStorage.setItem(key, value); } catch { /* In-memory interaction continues when storage is unavailable. */ }
}
+2
View File
@@ -12,6 +12,7 @@ export type ManagementAction =
| "operations.check"
| "client.read"
| "client.bootstrap"
| "client.verify"
| "client.disable"
| "client.revoke"
| "client.create"
@@ -32,6 +33,7 @@ const PERMISSIONS: Record<ManagementAction, readonly ManagementRole[]> = {
"operations.check": ["operator", "admin"],
"client.read": ["viewer", "auditor", "operator", "admin"],
"client.bootstrap": ["operator", "admin"],
"client.verify": ["operator", "admin"],
"client.disable": ["operator", "admin"],
"client.revoke": ["operator", "admin"],
"client.create": ["admin"],
+8 -1
View File
@@ -13,4 +13,11 @@ export function usePath() {
return [path, navigate] as const;
}
export function basePath(path: string) { return (path.split("?")[0] ?? "").replace(/\/$/, "") || "/"; }
export function resourceKind(path: string): ResourceKind | null { const base = basePath(path); if (base.startsWith("/projects/") && base !== "/projects") return "projects"; return resourceRoutes[base] ?? null; }
export function projectDetailId(path: string): string | null { return decodeDetailId(path, "projects"); }
export function connectorDetailId(path: string): string | null { return decodeDetailId(path, "connectors"); }
function decodeDetailId(path: string, collection: "projects" | "connectors"): string | null {
const match = new RegExp(`^/${collection}/([^/]+)$`).exec(basePath(path));
if (!match?.[1]) return null;
try { return decodeURIComponent(match[1]); } catch { return null; }
}
export function resourceKind(path: string): ResourceKind | null { const base = basePath(path); if (projectDetailId(base)) return "projects"; if (connectorDetailId(base)) return null; return resourceRoutes[base] ?? null; }
+148
View File
@@ -0,0 +1,148 @@
import type { ToolDefinition } from "./contracts";
export type ToolInputProperty = NonNullable<ToolDefinition["inputSchema"]["properties"]>[string];
export type ToolInputSchema = ToolDefinition["inputSchema"];
export interface ParsedSchemaArguments {
arguments: Record<string, unknown>;
errors: Record<string, string>;
valid: boolean;
}
export const NULL_INPUT_VALUE = "__schema_null__";
function allowedTypes(property: ToolInputProperty): string[] {
const types = Array.isArray(property.type) ? property.type : [property.type ?? "string"];
return types.filter((type) => type !== "null");
}
function supportsNull(property: ToolInputProperty): boolean {
return Array.isArray(property.type) && property.type.includes("null");
}
function optionValue(raw: string): unknown {
try {
return JSON.parse(raw) as unknown;
} catch {
return raw;
}
}
function matchesEnum(value: unknown, values: unknown[] | undefined): boolean {
if (!values) return true;
return values.some((option) => {
if (Object.is(option, value)) return true;
try {
return JSON.stringify(option) === JSON.stringify(value);
} catch {
return false;
}
});
}
function boundsError(value: number | string, property: ToolInputProperty): string | null {
if (typeof value === "number") {
if (property.minimum !== undefined && value < property.minimum) return `Enter a number no less than ${property.minimum}.`;
if (property.maximum !== undefined && value > property.maximum) return `Enter a number no greater than ${property.maximum}.`;
}
if (typeof value === "string") {
if (property.minLength !== undefined && value.length < property.minLength) return `Enter at least ${property.minLength} characters.`;
if (property.maxLength !== undefined && value.length > property.maxLength) return `Use no more than ${property.maxLength} characters.`;
}
return null;
}
function parseForType(raw: string, type: string, property: ToolInputProperty): { value?: unknown; error?: string } {
if (type === "integer" || type === "number") {
const numeric = Number(raw);
if (!Number.isFinite(numeric)) return { error: "Enter a valid number." };
if (type === "integer" && !Number.isSafeInteger(numeric)) return { error: "Enter a whole number." };
return { value: numeric, error: boundsError(numeric, property) ?? undefined };
}
if (type === "boolean") {
if (raw === "true") return { value: true };
if (raw === "false") return { value: false };
return { error: "Choose yes or no." };
}
if (type === "array" || type === "object") {
let value: unknown;
try {
value = JSON.parse(raw) as unknown;
} catch {
return { error: type === "array" ? "Enter valid JSON for this array." : "Enter valid JSON for this object." };
}
if (type === "array" && !Array.isArray(value)) return { error: "Enter a JSON array." };
if (type === "object" && (typeof value !== "object" || value === null || Array.isArray(value))) return { error: "Enter a JSON object." };
return { value };
}
if (type === "string") return { value: raw, error: boundsError(raw, property) ?? undefined };
return { error: "This tool input type cannot be entered here." };
}
function parseField(raw: string, property: ToolInputProperty): { value?: unknown; error?: string } {
if (raw === "") return {};
if (raw === NULL_INPUT_VALUE) return supportsNull(property) ? { value: null } : { error: "This field does not accept null." };
if (property.enum) {
const value = optionValue(raw);
return matchesEnum(value, property.enum)
? { value }
: { error: "Choose a value listed by this tool's schema." };
}
const types = allowedTypes(property);
if (types.length === 0) return { error: "This field does not accept a value." };
// Prefer the most specific scalar/structured representation for unions;
// a plain string remains the fallback when another declared type rejects it.
const orderedTypes = [...types].sort((left, right) => {
const priority = (type: string) => ({ integer: 0, number: 1, boolean: 2, object: 3, array: 4, string: 5 }[type as "integer"] ?? 6);
return priority(left) - priority(right);
});
let firstError: string | undefined;
for (const type of orderedTypes) {
const parsed = parseForType(raw, type, property);
if (!parsed.error) return parsed;
firstError ??= parsed.error;
}
return { error: firstError ?? "This tool input is invalid." };
}
function displayValue(value: unknown, property: ToolInputProperty): string {
if (value === null) return supportsNull(property) ? NULL_INPUT_VALUE : "";
if (property.enum || typeof value === "object") {
try {
return JSON.stringify(value) ?? "";
} catch {
return "";
}
}
return String(value);
}
export function defaultSchemaInputValues(schema: ToolInputSchema): Record<string, string> {
const raw: Record<string, string> = {};
for (const [name, property] of Object.entries(schema.properties ?? {})) {
if (property.default !== undefined) raw[name] = displayValue(property.default, property);
}
return raw;
}
export function parseSchemaArguments(rawValues: Record<string, string>, schema: ToolInputSchema): ParsedSchemaArguments {
const args: Record<string, unknown> = {};
const errors: Record<string, string> = {};
const properties = schema.properties ?? {};
const required = new Set(schema.required ?? []);
for (const name of required) {
if (!properties[name] || rawValues[name] === undefined || rawValues[name] === "") errors[name] = "This field is required.";
}
for (const [name, property] of Object.entries(properties)) {
const raw = rawValues[name] ?? "";
if (raw === "") continue;
const parsed = parseField(raw, property);
if (parsed.error) errors[name] = parsed.error;
else if (parsed.value !== undefined) args[name] = parsed.value;
}
return { arguments: args, errors, valid: Object.keys(errors).length === 0 };
}
+54
View File
@@ -0,0 +1,54 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { safeApiMessage } from "./api";
export type RefreshStatus = "current" | "refreshing" | "stale" | "disconnected" | "paused";
export const refreshLabel: Record<RefreshStatus, string> = { current: "Current · auto refresh", refreshing: "Refreshing…", stale: "Stale · refresh needed", disconnected: "Disconnected · retry available", paused: "Auto refresh paused" };
/** Read-only refresh: no overlapping calls, hidden-tab suspension, bounded backoff. */
export function useStatusRefresh<T>(initial: T | null, read: () => Promise<T>, stale: (value: T) => boolean) {
const [value, setValue] = useState(initial);
const [status, setStatus] = useState<RefreshStatus>(initial ? (stale(initial) ? "stale" : "current") : "refreshing");
const [error, setError] = useState("");
const active = useRef(false);
const pending = useRef(false);
const failures = useRef(0);
const readRef = useRef(read);
const staleRef = useRef(stale);
const timer = useRef<number | undefined>(undefined);
useEffect(() => { readRef.current = read; staleRef.current = stale; }, [read, stale]);
const refresh = useCallback(async () => {
if (pending.current || !active.current) return;
if (document.hidden) { setStatus("paused"); return; }
pending.current = true;
window.clearTimeout(timer.current);
setStatus("refreshing");
try {
const next = await readRef.current();
if (active.current) { setValue(next); setError(""); setStatus(staleRef.current(next) ? "stale" : "current"); failures.current = 0; }
} catch (caught) {
if (active.current) { failures.current += 1; setError(safeApiMessage(caught)); setStatus("disconnected"); }
} finally {
pending.current = false;
if (active.current && !document.hidden) timer.current = window.setTimeout(() => { void refresh(); }, Math.min(120_000, 30_000 * 2 ** Math.min(failures.current, 2)));
}
}, []);
useEffect(() => {
active.current = true;
if (document.hidden) setStatus("paused");
else if (initial) timer.current = window.setTimeout(() => { void refresh(); }, 30_000);
else void refresh();
const visibility = () => {
window.clearTimeout(timer.current);
if (document.hidden) setStatus("paused");
else void refresh();
};
document.addEventListener("visibilitychange", visibility);
return () => { active.current = false; window.clearTimeout(timer.current); document.removeEventListener("visibilitychange", visibility); };
}, [refresh]);
useEffect(() => {
if (!value) return;
const freshness = window.setInterval(() => { if (!document.hidden && staleRef.current(value)) setStatus((current) => current === "current" ? "stale" : current); }, 10_000);
return () => window.clearInterval(freshness);
}, [value]);
return { value, status, error, refresh };
}
+126 -163
View File
@@ -73,12 +73,8 @@
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; background: var(--surface-0); background-image: radial-gradient(1200px 640px at 14% -8%, var(--surface-2) 0%, transparent 55%); background-attachment: fixed; }
.skip-link { position: fixed; z-index: 1000; top: 10px; left: 10px; transform: translateY(-180%); padding: 9px 12px; border-radius: 3px; background: var(--paper-bright); color: var(--surface-0); font-weight: 700; text-decoration: none; box-shadow: var(--shadow-2); }
.skip-link:focus { transform: translateY(0); }
button, a, input, select { font: inherit; }
@@ -88,38 +84,34 @@ button { font-family: var(--font-sans); }
/* Shell */
.app-shell { display: flex; min-height: 100vh; }
.preauth-shell { min-height: 100vh; display: grid; place-items: start center; }
.sidebar { width: 240px; background: var(--surface-1); border-right: 1px solid var(--line); padding: 28px 16px; display: flex; flex-direction: column; flex-shrink: 0; position: relative; }
.sidebar::after { content: ""; position: absolute; top: 0; right: -1px; bottom: 0; width: 1px; background: linear-gradient(180deg, transparent, var(--accent-dim) 18%, var(--accent-dim) 82%, transparent); opacity: 0.55; }
.brand { display: flex; gap: 12px; align-items: center; text-decoration: none; padding: 0 10px 30px; }
.brand strong, .brand small { display: block; }
.brand strong { font-family: var(--font-sans); font-weight: 700; letter-spacing: 0.01em; color: var(--paper-bright); }
.brand small { color: var(--ink-dim); margin-top: 3px; font-size: 11px; letter-spacing: 0.04em; text-transform: uppercase; }
.brand-mark { display: grid; place-items: center; width: 36px; height: 36px; border-radius: 10px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); box-shadow: 0 0 0 1px var(--accent-dim), 0 2px 10px -2px #000a; flex-shrink: 0; }
.brand-mark svg { display: block; }
nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.nav-group { display: grid; gap: 3px; margin: 0 0 14px; }
.nav-group > p { margin: 8px 12px 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 9.5px; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; }
.nav-link { display: flex; align-items: center; gap: 10px; color: var(--ink-dim); padding: 10px 12px; border-radius: 7px; text-decoration: none; font-size: 13.5px; font-weight: 500; position: relative; transition: color 120ms ease, background-color 120ms ease; }
.nav-link::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--line); flex-shrink: 0; transition: background-color 120ms ease, box-shadow 120ms ease; }
.nav-link:hover, .nav-link:focus-visible { color: var(--paper-bright); background: var(--surface-2-hover); }
.nav-link.active { color: var(--paper-bright); background: var(--surface-2); font-weight: 600; }
.nav-link.active::before { background: var(--accent); box-shadow: 0 0 6px 1px #00b0fe99; }
.sidebar-foot { margin-top: auto; padding: 16px 12px 0; border-top: 1px solid var(--line); color: var(--ink-dim); font-size: 12px; line-height: 1.6; }
.sidebar-foot small { color: var(--ink-faint); }
.secure-dot { display: inline-block; width: 7px; height: 7px; background: var(--status-success); border-radius: 50%; margin-right: 7px; box-shadow: 0 0 5px 0 #34d39999; }
/* Main */
.main-content { flex: 1; min-width: 0; padding: 32px clamp(20px, 4vw, 60px) 56px; }
.topbar { display: flex; justify-content: space-between; align-items: flex-start; gap: 20px; margin-bottom: 34px; }
.topbar h1, .hero h2, .panel h2, .panel h3 { margin: 0; color: var(--paper-bright); letter-spacing: -0.01em; font-family: var(--font-sans); font-weight: 700; }
.topbar h1 { font-size: 27px; }
.eyebrow { text-transform: uppercase; letter-spacing: 0.11em; font-size: 12px; font-weight: 600; color: var(--accent); margin: 0 0 9px; font-family: var(--font-mono); }
.topbar-actions { display: flex; align-items: center; gap: 16px; }
.session-label { color: var(--ink-dim); font-size: 12px; font-family: var(--font-mono); }
.avatar { display: grid; place-items: center; width: 34px; height: 34px; background: var(--surface-2); border: 1px solid var(--line); border-radius: 50%; color: var(--paper); text-decoration: none; font-weight: 700; font-family: var(--font-sans); }
.avatar:hover, .avatar:focus-visible { border-color: var(--accent-dim); }
@@ -129,26 +121,22 @@ nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.hero::before { content: ""; position: absolute; inset: 0 0 auto 0; height: 2px; background: linear-gradient(90deg, var(--accent-dim), var(--accent) 30%, var(--accent-dim) 60%, transparent); opacity: 0.7; }
.hero h2 { font-size: clamp(22px, 3vw, 31px); }
.hero-copy { color: var(--ink-dim); margin: 10px 0 0; max-width: 46ch; }
.hero-meta { min-width: 190px; color: var(--ink-faint); font-size: 11px; text-align: right; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; }
.hero-meta strong { display: block; color: var(--paper); margin-top: 8px; font-size: 15px; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; text-transform: none; }
/* Cards + panels */
.metric-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 14px; margin-bottom: 20px; }
.metric-card, .panel { border: 1px solid var(--line); background: var(--surface-1); border-radius: 4px; }
.metric-card { min-height: 134px; padding: 19px; position: relative; }
.metric-card::before { content: ""; position: absolute; top: 0; left: 16px; right: 16px; height: 2px; background: var(--line-soft); }
.metric-heading, .panel-heading { display: flex; justify-content: space-between; gap: 10px; align-items: center; }
.metric-heading span:first-child { color: var(--ink-dim); font-size: 12px; font-weight: 500; letter-spacing: 0.02em; }
.metric-card strong { display: block; font-size: 26px; color: var(--paper-bright); margin: 18px 0 8px; font-weight: 700; font-variant-numeric: tabular-nums; }
.metric-card p, .panel p { color: var(--ink-dim); font-size: 12px; margin: 0; line-height: 1.55; }
.dashboard-columns { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 18px; margin-bottom: 20px; }
.panel { padding: 23px; }
.panel-heading { align-items: flex-start; margin-bottom: 19px; }
.panel-heading h3 { font-size: 16.5px; }
.panel-heading a { color: var(--accent); font-size: 12px; text-decoration: none; font-weight: 600; }
.panel-heading a:hover { text-decoration: underline; }
.exception-panel { margin-bottom: 20px; }
.exception-list { display: grid; gap: 4px; }
.exception-item { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 12px 0; border-bottom: 1px solid var(--line-soft); text-decoration: none; }
.exception-item:last-child { border-bottom: 0; }
@@ -157,10 +145,10 @@ nav[aria-label="Primary navigation"] { display: grid; gap: 3px; }
.exception-item strong { color: var(--paper); font-size: 12.5px; }
.exception-item small { max-width: 78ch; margin-top: 3px; color: var(--ink-faint); font-size: 11px; line-height: 1.45; }
.table-wrap { overflow-x: auto; }
.mobile-record-list { display: none; }
table { border-collapse: collapse; width: 100%; font-size: 12.5px; text-align: left; }
th { color: var(--ink-faint); font-weight: 600; padding: 9px 11px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-mono); }
td { color: var(--ink-dim); padding: 13px 11px; border-bottom: 1px solid var(--line-soft); white-space: nowrap; }
td strong, td small, .stack-item strong, .stack-item small { display: block; }
td strong, .stack-item strong { color: var(--paper); font-family: var(--font-sans); font-weight: 600; letter-spacing: 0.01em; }
@@ -185,14 +173,12 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.notice-info { border-left-color: var(--status-info); }
.notice button { color: var(--accent); background: transparent; border: 0; cursor: pointer; padding: 0 0 0 8px; text-decoration: underline; font-weight: 600; }
.secondary-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
/* State panels */
.state-panel, .auth-panel, .resource-page { max-width: 720px; margin: 10vh auto 0; text-align: center; padding: 50px 32px; }
.state-panel h2, .auth-panel h2, .auth-panel h1, .resource-page h2 { margin-bottom: 12px; font-size: 22px; }
.state-panel p, .auth-panel p, .resource-page p { max-width: 500px; margin: 0 auto 22px; color: var(--ink-dim); }
.preauth-brand { display: inline-flex; margin-bottom: 30px; padding-bottom: 0; text-align: left; }
.auth-help { display: block; max-width: 390px; margin: 21px auto 0; color: var(--ink-faint); font-size: 11px; line-height: 1.5; }
.state-icon { display: grid; place-items: center; width: 44px; height: 44px; margin: 0 auto 18px; border-radius: 50%; background: var(--surface-2); border: 1px solid var(--accent-dim); color: var(--accent); font-size: 21px; font-family: var(--font-sans); }
.spinner { width: 26px; height: 26px; margin: 0 auto 20px; border: 3px solid var(--line); border-top-color: var(--accent); border-radius: 50%; animation: spin 0.8s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
@@ -206,11 +192,7 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.empty-line { display: flex; align-items: center; justify-content: center; gap: 9px; padding: 19px; border: 1px dashed var(--line); border-radius: 3px; color: var(--ink-faint); font-size: 12px; }
.muted { color: var(--ink-dim); }
@media (max-width: 940px) {
.sidebar { width: 212px; }
.metric-grid, .secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-columns { grid-template-columns: 1fr; }
}
/* Compact navigation drawer + trigger -- hidden entirely above 840px, where
the persistent sidebar (rendered unconditionally in app.tsx) is used
instead. Kept as its own rule set, not nested inside the 840px media
@@ -223,45 +205,9 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.nav-backdrop { display: none; }
.sidebar-head { display: contents; }
@media (max-width: 840px) {
.app-shell { display: block; }
.nav-trigger { display: flex; }
.topbar { align-items: center; }
/* Real slide-in drawer, not a squeezed copy of the desktop sidebar:
fixed off-canvas panel, translated into view only while open, above a
dedicated backdrop that closes it on click (Escape closes it too, via
app.tsx). */
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; }
.sidebar-open { transform: translateX(0); }
.sidebar-head { display: flex; align-items: center; justify-content: space-between; }
.brand { padding: 0 4px 14px; }
.nav-close { display: block; }
nav[aria-label="Primary navigation"] { display: grid; }
.nav-link { font-size: 13.5px; padding: 10px 12px; }
.sidebar-foot { display: block; }
.nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; }
.main-content { padding: 24px 14px 42px; }
.preauth-shell .main-content { padding-top: 10px; }
.topbar { margin-bottom: 24px; gap: 12px; }
.topbar h1 { font-size: 22px; }
.session-label { display: none; }
.hero { display: block; padding: 21px; }
.hero-meta { text-align: left; margin-top: 19px; }
.metric-card { min-height: 120px; padding: 15px; }
.metric-card strong { font-size: 21px; margin-top: 15px; }
.panel { min-width: 0; padding: 18px; }
.dashboard-columns > * { min-width: 0; }
}
@media (prefers-reduced-motion: reduce) {
.sidebar { transition: none; }
}
/* Resource / detail views */
.breadcrumbs { display: flex; gap: 9px; align-items: center; color: var(--ink-faint); font-size: 12px; margin-bottom: 17px; font-family: var(--font-mono); }
.session-menu { display: flex; align-items: center; gap: 12px; margin-left: auto; }
.session-identity { display: flex; align-items: center; gap: 8px; text-align: right; line-height: 1.25; }
.session-identity > span:last-child { display: grid; }
.session-identity strong { color: var(--paper-bright); font-size: 12px; max-width: 240px; overflow: hidden; text-overflow: ellipsis; }
@@ -276,7 +222,7 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.tag { border: 1px solid var(--line); background: var(--surface-2); border-radius: 999px; color: var(--ink-dim); padding: 5px 10px; font-size: 10.5px; white-space: nowrap; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.04em; }
.filter-row { display: flex; align-items: end; gap: 14px; margin: 19px 0; }
.filter-field, .form-grid label { display: grid; gap: 6px; color: var(--ink-dim); font-size: 12px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.03em; }
.filter-field { flex: 1; max-width: 440px; }
.filter-help { color: var(--ink-faint); font-size: 12px; padding-bottom: 11px; font-family: var(--font-mono); text-transform: none; letter-spacing: 0; }
.filter-field input, .form-grid input, .form-grid select, .form-grid textarea { width: 100%; border: 1px solid var(--line); border-radius: 3px; background: var(--surface-0); color: var(--paper); padding: 10px 11px; outline: none; font-size: 13px; text-transform: none; letter-spacing: 0; font-weight: 400; transition: border-color 120ms ease, box-shadow 120ms ease; }
.filter-field input:focus, .form-grid input:focus, .form-grid select:focus, .form-grid textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 2px #00b0fe33; }
@@ -284,11 +230,11 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.risk-label { display: inline-flex; align-items: center; min-height: 24px; padding: 3px 8px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); font-family: var(--font-mono); font-size: 11px; }
.inline-actions { display: flex; gap: 5px; }
.button-small { padding: 7px 10px; font-size: 10.5px; }
.table-controls { display: flex; gap: 10px; }
.table-controls .active { border-color: var(--accent); }
.button-danger { color: #f6dcd7; background: var(--status-danger-dim); border-color: #7a3b30; }
.button:disabled { cursor: not-allowed; opacity: 0.4; filter: none; }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--ink-faint); font-size: 11px; margin-top: 16px; font-family: var(--font-mono); }
.workflow-panel { max-width: none; }
.workflow-panel h3 { margin-bottom: 9px; }
.form-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; margin: 19px 0; }
@@ -313,8 +259,8 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.alert-list { display: grid; gap: 10px; margin-top: 16px; }
.alert-list .notice { display: grid; grid-template-columns: minmax(130px, .3fr) 1fr; gap: 14px; }
.alert-list .notice strong { text-transform: uppercase; font: 11px/1.4 var(--font-mono); }
.trend-chart { display: flex; align-items: end; gap: 5px; height: 180px; margin-top: 18px; padding: 12px 8px 0; border: 1px solid var(--line); background: var(--surface-0); overflow-x: auto; }
.trend-column { display: grid; grid-template-rows: 1fr 18px; align-items: end; justify-items: center; min-width: 20px; height: 100%; flex: 1; }
.trend-bar { display: block; width: min(12px, 70%); min-height: 4px; border-radius: 2px 2px 0 0; background: var(--accent); }
.trend-column small { color: var(--ink-faint); font: 9px/1 var(--font-mono); }
.onboarding-result { margin-top: 18px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); }
@@ -354,29 +300,6 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
.theme-toggle { display: flex; align-items: center; gap: 10px; margin-bottom: 19px; }
.theme-toggle span { color: var(--ink-dim); font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; }
@media (max-width: 640px) {
.filter-row, .form-grid { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.filter-help { padding: 0; }
.inline-actions { flex-wrap: wrap; }
.detail-grid { grid-template-columns: 1fr; }
.resource-stack .resource-page { padding: 18px; }
.resource-stack .panel-heading { flex-wrap: wrap; }
.session-identity { display: none; }
.connector-checks > div, .alert-list .notice { grid-template-columns: auto 1fr; }
.connector-checks span { grid-column: 1 / -1; }
.desktop-data-table { display: none; }
.mobile-record-list { display: grid; gap: 10px; }
.mobile-record { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
.mobile-record-heading { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.mobile-record strong, .mobile-record small { display: block; }
.mobile-record strong { color: var(--paper-bright); overflow-wrap: anywhere; }
.mobile-record small { margin-top: 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 10.5px; overflow-wrap: anywhere; }
.mobile-record dl { display: grid; grid-template-columns: minmax(88px, 0.42fr) 1fr; gap: 8px 12px; margin: 14px 0; font-size: 12px; }
.mobile-record dt { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.mobile-record dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
}
/* Modal */
.modal-overlay { position: fixed; inset: 0; background: #000a; display: grid; place-items: center; z-index: 100; padding: var(--space-4); }
.modal { background: var(--surface-1); border: 1px solid var(--line); border-radius: var(--radius-md); box-shadow: var(--shadow-2); max-width: 760px; width: 100%; max-height: calc(100vh - 32px); overflow-y: auto; padding: var(--space-6); }
@@ -398,16 +321,9 @@ td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-fam
sharpen hierarchy and interaction without changing the established
security-oriented dark control-plane identity. */
body {
min-height: 100vh;
line-height: 1.5;
background-image:
linear-gradient(var(--line-soft) 1px, transparent 1px),
body { margin: 0; min-width: 320px; background: var(--surface-0); background-image: linear-gradient(var(--line-soft) 1px, transparent 1px),
linear-gradient(90deg, var(--line-soft) 1px, transparent 1px),
radial-gradient(900px 540px at 8% -10%, color-mix(in srgb, var(--accent-dim) 34%, transparent), transparent 62%);
background-size: 64px 64px, 64px 64px, auto;
background-attachment: fixed;
}
radial-gradient(900px 540px at 8% -10%, color-mix(in srgb, var(--accent-dim) 34%, transparent), transparent 62%); background-attachment: fixed; min-height: 100vh; line-height: 1.5; background-size: 64px 64px, 64px 64px, auto; }
body::before {
content: "";
position: fixed;
@@ -420,41 +336,27 @@ body::before {
.icon { display: block; flex: 0 0 auto; }
/* Authenticated shell */
.sidebar {
position: sticky;
top: 0;
z-index: 20;
width: 252px;
height: 100vh;
padding: 25px 17px 20px;
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
backdrop-filter: blur(18px);
}
.sidebar { width: 252px; background: color-mix(in srgb, var(--surface-1) 96%, transparent); border-right: 1px solid var(--line); padding: 25px 17px 20px; display: flex; flex-direction: column; flex-shrink: 0; position: sticky; top: 0; z-index: 20; height: 100vh; backdrop-filter: blur(18px); }
.sidebar .brand { padding: 0 9px 25px; }
.brand-mark {
width: 38px;
height: 38px;
border-radius: 9px;
box-shadow: 0 0 0 1px var(--accent-dim), 0 8px 22px -10px color-mix(in srgb, var(--accent) 60%, transparent);
}
.nav-group { margin-bottom: 11px; }
.nav-link { gap: 11px; min-height: 40px; padding: 9px 11px; border: 1px solid transparent; }
.nav-link::before { display: none; }
.brand-mark { display: grid; place-items: center; width: 38px; height: 38px; border-radius: 9px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); box-shadow: 0 0 0 1px var(--accent-dim), 0 8px 22px -10px color-mix(in srgb, var(--accent) 60%, transparent); flex-shrink: 0; }
.nav-group { display: grid; gap: 3px; margin: 0 0 14px; margin-bottom: 11px; }
.nav-link { display: flex; align-items: center; gap: 11px; color: var(--ink-dim); padding: 9px 11px; border-radius: 7px; text-decoration: none; font-size: 13.5px; font-weight: 500; position: relative; transition: color 120ms ease, background-color 120ms ease; min-height: 40px; border: 1px solid transparent; }
.nav-link::before { content: ""; width: 5px; height: 5px; border-radius: 50%; background: var(--line); flex-shrink: 0; transition: background-color 120ms ease, box-shadow 120ms ease; display: none; }
.nav-link .icon { color: var(--ink-faint); transition: color var(--motion-fast) ease, transform var(--motion-fast) ease; }
.nav-link:hover .icon, .nav-link:focus-visible .icon { color: var(--paper); transform: translateX(1px); }
.nav-link.active { border-color: color-mix(in srgb, var(--accent-dim) 72%, var(--line)); background: color-mix(in srgb, var(--surface-2) 90%, var(--accent-dim)); }
.nav-link.active { color: var(--paper-bright); background: color-mix(in srgb, var(--surface-2) 90%, var(--accent-dim)); font-weight: 600; border-color: color-mix(in srgb, var(--accent-dim) 72%, var(--line)); }
.nav-link.active .icon { color: var(--accent-bright); }
.sidebar-foot { display: grid; gap: 3px; }
.sidebar-foot { margin-top: auto; padding: 16px 12px 0; border-top: 1px solid var(--line); color: var(--ink-dim); font-size: 12px; line-height: 1.6; display: grid; gap: 3px; }
.sidebar-foot > span { color: var(--paper); font-size: 11.5px; }
.sidebar-foot small { font-family: var(--font-mono); font-size: 9.5px; }
.sidebar-foot small { color: var(--ink-faint); font-family: var(--font-mono); font-size: 9.5px; }
.main-content { padding: 28px clamp(22px, 3.6vw, 58px) 64px; }
.main-content { flex: 1; min-width: 0; padding: 28px clamp(22px, 3.6vw, 58px) 64px; }
.topbar, .dashboard, .resource-stack { width: min(100%, 1480px); margin-inline: auto; }
.topbar { align-items: center; min-height: 52px; margin-bottom: 29px; }
.topbar { display: flex; justify-content: space-between; align-items: center; gap: 20px; margin-bottom: 29px; min-height: 52px; }
.topbar-title { min-width: 0; }
.topbar-title .eyebrow { margin-bottom: 4px; font-size: 9.5px; }
.topbar h1 { font-size: clamp(23px, 2.2vw, 29px); }
.topbar-actions { min-width: 0; }
.topbar-actions { display: flex; align-items: center; gap: 16px; min-width: 0; }
.command-trigger {
display: inline-flex;
align-items: center;
@@ -471,11 +373,11 @@ body::before {
}
.command-trigger:hover, .command-trigger:focus-visible { color: var(--paper-bright); border-color: var(--ink-faint); background: var(--surface-2); }
kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); border-radius: 4px; padding: 2px 6px; color: var(--ink-faint); background: var(--surface-0); font: 9px/1.5 var(--font-mono); }
.session-menu { gap: 9px; }
.session-menu { display: flex; align-items: center; gap: 9px; margin-left: auto; }
.session-menu > .tag { padding-inline: 8px; }
/* Public gateway */
.preauth-shell { display: block; min-height: 100vh; }
.preauth-shell { min-height: 100vh; display: block; place-items: start center; }
.preauth-shell .main-content { width: 100%; min-height: 100vh; padding: clamp(18px, 3vw, 42px); }
.auth-layout {
display: grid;
@@ -497,7 +399,7 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.auth-actions { display: flex; align-items: center; gap: 18px; margin-top: 31px; }
.auth-actions > span { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); }
.auth-button { gap: 10px; min-height: 45px; }
.auth-help { max-width: 58ch; margin: 17px 0 0; color: var(--ink-faint); font-size: 10.5px; }
.auth-help { display: block; max-width: 58ch; margin: 17px 0 0; color: var(--ink-faint); font-size: 10.5px; line-height: 1.5; }
.auth-trust-list { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; margin: clamp(42px, 7vh, 74px) 0 0; border: 1px solid var(--line); background: var(--line); }
.auth-trust-list > div { padding: 13px; background: var(--surface-1); }
.auth-trust-list dt { color: var(--paper); font-size: 11px; font-weight: 650; }
@@ -563,18 +465,18 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.dashboard-hero h2 { max-width: 14ch; font-size: clamp(30px, 3vw, 47px); line-height: 1.04; letter-spacing: -.045em; }
.dashboard-hero .hero-copy { max-width: 62ch; margin-top: 15px; font-size: 13px; line-height: 1.65; }
.hero-actions { display: flex; align-items: center; gap: 9px; margin-top: 24px; }
.hero-meta { margin-top: 27px; text-align: left; }
.hero-meta strong { display: inline; margin: 0 0 0 10px; font-size: 10.5px; }
.hero-meta { min-width: 190px; color: var(--ink-faint); font-size: 11px; text-align: left; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.08em; margin-top: 27px; }
.hero-meta strong { display: inline; color: var(--paper); margin-top: 8px; font-size: 10.5px; letter-spacing: 0.02em; font-variant-numeric: tabular-nums; text-transform: none; margin: 0 0 0 10px; }
.dashboard-hero .topology { align-self: center; }
.primary-metrics { margin: 0; }
.metric-card, .panel { border-color: color-mix(in srgb, var(--line) 88%, transparent); box-shadow: var(--shadow-1); }
.metric-card { overflow: hidden; transition: border-color var(--motion-base) ease, transform var(--motion-base) ease, background-color var(--motion-base) ease; }
.metric-card, .panel { border: 1px solid var(--line); background: var(--surface-1); border-radius: 4px; border-color: color-mix(in srgb, var(--line) 88%, transparent); box-shadow: var(--shadow-1); }
.metric-card { min-height: 134px; padding: 19px; position: relative; overflow: hidden; transition: border-color var(--motion-base) ease, transform var(--motion-base) ease, background-color var(--motion-base) ease; }
.metric-card:hover { border-color: var(--ink-faint); transform: translateY(-1px); background: color-mix(in srgb, var(--surface-1) 93%, var(--surface-2-hover)); }
.metric-card::before { left: 0; right: auto; width: 3px; height: 100%; background: var(--line); }
.metric-card::before { content: ""; position: absolute; top: 0; left: 0; right: auto; height: 100%; background: var(--line); width: 3px; }
.metric-card:has(.status-healthy)::before { background: var(--status-success); }
.metric-card:has(.status-degraded)::before { background: var(--accent); }
.metric-card:has(.status-unavailable)::before { background: var(--status-danger); }
.exception-panel { margin: 0; }
.exception-panel { margin-bottom: 20px; margin: 0; }
.exception-action { display: inline-flex; align-items: center; gap: 9px; }
.exception-action > .icon { color: var(--ink-faint); transition: transform var(--motion-fast) ease; }
.exception-item:hover .exception-action > .icon { transform: translateX(3px); color: var(--accent); }
@@ -584,8 +486,8 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.empty-line small { margin-top: 3px; color: var(--ink-faint); font-size: 10.5px; }
.empty-success { justify-content: flex-start; border-style: solid; border-color: color-mix(in srgb, var(--status-success) 24%, var(--line)); background: color-mix(in srgb, var(--status-success-dim) 34%, transparent); }
.empty-success > span:first-child { display: grid; place-items: center; width: 26px; height: 26px; border-radius: 50%; color: var(--status-success); background: var(--status-success-dim); font-weight: 700; }
.dashboard-columns { margin: 0; }
.panel-heading a { display: inline-flex; align-items: center; gap: 5px; }
.dashboard-columns { display: grid; grid-template-columns: minmax(0, 1.3fr) minmax(300px, 0.7fr); gap: 18px; margin-bottom: 20px; margin: 0; }
.panel-heading a { color: var(--accent); font-size: 12px; text-decoration: none; font-weight: 600; display: inline-flex; align-items: center; gap: 5px; }
.record-link { text-decoration: none; }
.record-link:hover strong, .record-link:focus-visible strong { color: var(--accent-bright); }
.connector-score { display: flex; align-items: center; gap: 13px; margin: -3px 0 11px; padding: 0 0 16px; border-bottom: 1px solid var(--line); }
@@ -624,18 +526,18 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.record-count strong { color: var(--paper-bright); font-size: 17px; font-variant-numeric: tabular-nums; }
.record-count small { color: var(--ink-faint); font: 8.5px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
.resource-toolbar { display: flex; align-items: flex-end; justify-content: space-between; gap: 18px; margin: 19px 0 16px; }
.filter-field { max-width: 540px; }
.filter-field { flex: 1; max-width: 540px; }
.filter-input-wrap { display: flex; align-items: center; gap: 9px; min-height: 42px; padding: 0 10px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-0); color: var(--ink-faint); transition: border-color var(--motion-fast) ease, box-shadow var(--motion-fast) ease; }
.filter-input-wrap:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
.filter-input-wrap input { flex: 1; min-width: 0; padding: 0; border: 0; outline: 0; box-shadow: none !important; }
.filter-clear { display: grid; place-items: center; width: 25px; height: 25px; border: 0; border-radius: 50%; color: var(--ink-faint); background: transparent; cursor: pointer; }
.filter-clear:hover, .filter-clear:focus-visible { color: var(--paper); background: var(--surface-2-hover); }
.table-controls { align-items: center; }
.table-controls { display: flex; gap: 10px; align-items: center; }
.table-controls > span { color: var(--ink-faint); font: 9px/1 var(--font-mono); text-transform: uppercase; letter-spacing: .06em; }
.resource-results { position: relative; min-height: 84px; }
.results-refreshing .desktop-data-table, .results-refreshing .mobile-record-list { opacity: .5; transition: opacity var(--motion-fast) ease; }
.refresh-indicator { position: absolute; z-index: 3; top: 12px; left: 50%; display: inline-flex; align-items: center; gap: 8px; transform: translateX(-50%); padding: 7px 10px; border: 1px solid var(--line); border-radius: var(--radius-pill); color: var(--paper); background: var(--surface-2); box-shadow: var(--shadow-2); font-size: 10px; }
.table-wrap { max-width: 100%; overflow-x: auto; border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
.table-wrap { overflow-x: auto; max-width: 100%; border: 1px solid var(--line-soft); border-radius: var(--radius-md); }
.table-wrap table { min-width: 820px; }
.desktop-data-table table { table-layout: fixed; }
.desktop-data-table th:nth-child(1) { width: 24%; }
@@ -655,8 +557,8 @@ kbd { border: 1px solid var(--line); border-bottom-color: var(--ink-faint); bord
.table-kind-audit th:nth-child(5) { width: 20%; }
tbody tr { transition: background-color var(--motion-fast) ease; }
tbody tr:hover { background: color-mix(in srgb, var(--surface-2-hover) 62%, transparent); }
th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.pagination { min-height: 38px; margin-top: 11px; }
th { color: var(--ink-faint); font-weight: 600; padding: 9px 11px; border-bottom: 1px solid var(--line); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; font-family: var(--font-mono); background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.pagination { display: flex; align-items: center; justify-content: space-between; gap: 10px; color: var(--ink-faint); font-size: 11px; margin-top: 11px; font-family: var(--font-mono); min-height: 38px; }
.pagination > div { display: flex; gap: 6px; }
.pagination .button { display: inline-flex; align-items: center; gap: 5px; }
.client-safety, .approvals-panel { display: flex; justify-content: space-between; align-items: center; gap: 24px; }
@@ -700,8 +602,8 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.legend-denied, .trend-denied { background: var(--accent); }
.legend-error, .trend-error { background: var(--status-danger); }
.legend-limited, .trend-limited { background: var(--status-unknown); }
.trend-chart { gap: 4px; height: 230px; padding: 16px 10px 0; }
.trend-column { grid-template-rows: 16px 1fr 18px; min-width: 19px; }
.trend-chart { display: flex; align-items: end; gap: 4px; height: 230px; margin-top: 18px; padding: 16px 10px 0; border: 1px solid var(--line); background: var(--surface-0); overflow-x: auto; }
.trend-column { display: grid; grid-template-rows: 16px 1fr 18px; align-items: end; justify-items: center; min-width: 19px; height: 100%; flex: 1; }
.trend-total { color: var(--ink-faint); font: 8px/1 var(--font-mono); }
.trend-stack { display: flex; flex-direction: column-reverse; width: min(15px, 75%); min-height: 4px; overflow: hidden; border-radius: 3px 3px 1px 1px; background: var(--surface-2); transition: filter var(--motion-fast) ease; }
.trend-column:hover .trend-stack { filter: brightness(1.28); }
@@ -730,8 +632,10 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.state-footnote { display: block; margin-top: 20px; color: var(--ink-faint); font: 9.5px/1.5 var(--font-mono); }
/* Responsive composition */
/* Responsive layout: tablet, mobile, then narrow phones. */
@media (max-width: 1220px) {
.dashboard-hero { grid-template-columns: minmax(0, 1fr); }
.dashboard-hero { grid-template-columns: minmax(0, 1fr); }
.dashboard-hero h2 { max-width: 20ch; }
.secondary-grid { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.onboarding-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
@@ -740,7 +644,7 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
}
@media (max-width: 980px) {
.auth-layout { grid-template-columns: 1fr; }
.auth-layout { grid-template-columns: 1fr; }
.auth-copy .preauth-brand { margin-bottom: 46px; }
.auth-copy h1 { max-width: 16ch; }
.auth-visual { border-top: 1px solid var(--line); border-left: 0; }
@@ -750,23 +654,58 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.command-trigger span, .command-trigger kbd { display: none; }
}
@media (max-width: 940px) {
.sidebar { width: 212px; }
.metric-grid, .secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.dashboard-columns { grid-template-columns: 1fr; }
}
@media (max-width: 840px) {
/* Real slide-in drawer, not a squeezed copy of the desktop sidebar:
fixed off-canvas panel, translated into view only while open, above a
dedicated backdrop that closes it on click (Escape closes it too, via
app.tsx). */
.brand { padding: 0 4px 14px; }
.main-content { padding: 24px 14px 42px; }
.preauth-shell .main-content { padding-top: 10px; }
.topbar h1 { font-size: 22px; }
.session-label { display: none; }
.hero { display: block; padding: 21px; }
.hero-meta { text-align: left; margin-top: 19px; }
.metric-card { min-height: 120px; padding: 15px; }
.metric-card strong { font-size: 21px; margin-top: 15px; }
.panel { min-width: 0; padding: 18px; }
.dashboard-columns > * { min-width: 0; }
.app-shell { display: block; }
.nav-trigger { display: flex; }
.topbar { align-items: center; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); height: 100vh; overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; }
.topbar { align-items: center; margin-bottom: 24px; gap: 12px; }
.sidebar { position: fixed; inset: 0 auto 0 0; z-index: 210; width: min(84vw, 300px); overflow-y: auto; transform: translateX(-100%); transition: transform var(--motion-base) ease; box-shadow: var(--shadow-2); padding: 20px 16px; height: 100vh; }
.sidebar-open { transform: translateX(0); }
.sidebar-head { display: flex; align-items: center; justify-content: space-between; }
.sidebar .brand { padding: 0 4px 14px; }
.nav-close { display: block; }
nav[aria-label="Primary navigation"] { display: grid; }
.nav-link { font-size: 13.5px; padding: 10px 12px; }
.sidebar-foot { display: block; }
.nav-backdrop { display: block; position: fixed; inset: 0; background: #00000099; z-index: 200; }
}
@media (max-width: 760px) {
.session-menu > .tag { display: none; }
.session-menu > .tag { display: none; }
.resource-toolbar { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.table-controls { flex-wrap: wrap; }
@@ -776,8 +715,29 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
}
@media (max-width: 640px) {
.filter-row, .form-grid { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.filter-help { padding: 0; }
.inline-actions { flex-wrap: wrap; }
.detail-grid { grid-template-columns: 1fr; }
.resource-stack .resource-page { padding: 18px; }
.resource-stack .panel-heading { flex-wrap: wrap; }
.session-identity { display: none; }
.connector-checks > div, .alert-list .notice { grid-template-columns: auto 1fr; }
.connector-checks span { grid-column: 1 / -1; }
.desktop-data-table { display: none; }
.mobile-record-list { display: grid; gap: 10px; }
.mobile-record { padding: 14px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-2); }
.mobile-record-heading { display: flex; justify-content: space-between; align-items: start; gap: 10px; }
.mobile-record strong, .mobile-record small { display: block; }
.mobile-record strong { color: var(--paper-bright); overflow-wrap: anywhere; }
.mobile-record small { margin-top: 3px; color: var(--ink-faint); font-family: var(--font-mono); font-size: 10.5px; overflow-wrap: anywhere; }
.mobile-record dl { display: grid; grid-template-columns: minmax(88px, 0.42fr) 1fr; gap: 8px 12px; margin: 14px 0; font-size: 12px; }
.mobile-record dt { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; }
.mobile-record dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
body { background-size: 42px 42px, 42px 42px, auto; }
.sidebar { position: fixed; height: 100vh; width: min(86vw, 310px); }
.nav-backdrop { border: 0; padding: 0; }
.main-content { padding: 19px 14px 44px; }
.topbar { min-height: 44px; margin-bottom: 22px; }
@@ -786,8 +746,6 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.topbar h1 { font-size: 20px; }
.command-trigger { width: 36px; justify-content: center; padding: 0; }
.session-menu { margin: 0; }
.session-menu .button { min-width: 36px; padding-inline: 8px; font-size: 0; }
.session-menu .button::after { content: "↗"; font-size: 14px; }
.preauth-shell .main-content { padding: 10px; }
.auth-layout { min-height: calc(100vh - 20px); border-radius: var(--radius-lg); }
.auth-copy, .auth-visual { padding: 30px 22px; }
@@ -835,7 +793,6 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
.pagination > div { width: 100%; }
.pagination .button { flex: 1; }
.onboarding-steps li { display: grid; justify-items: center; text-align: center; }
.onboarding-steps li > div { display: none; }
.onboarding-form { grid-template-columns: 1fr; }
.client-safety, .approvals-panel { display: grid; }
.boundary-badge { justify-self: start; }
@@ -849,7 +806,7 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
}
@media (max-width: 420px) {
.metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 102px; }
.auth-copy h1 { font-size: 36px; }
.auth-visual-intro { align-items: flex-start; }
@@ -857,6 +814,12 @@ th { background: color-mix(in srgb, var(--surface-2) 62%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after { animation-duration: 0.001ms !important; animation-iteration-count: 1 !important; transition-duration: 0.001ms !important; }
.sidebar { transition: none; }
.topology-flow path, .state-orbit span, .loading-skeleton span { animation: none; }
.metric-card:hover { transform: none; }
}
+130
View File
@@ -0,0 +1,130 @@
.detail-mobile-section { display: none; }
.detail-card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin: 16px 0;
}
.detail-card {
min-width: 0;
padding: 16px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-1);
box-shadow: var(--shadow-1);
}
.detail-card-heading {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
}
.detail-card h3 {
min-width: 0;
margin: 0;
color: var(--paper-bright);
font-size: 14px;
line-height: 1.45;
overflow-wrap: anywhere;
}
.detail-card-status {
flex: 0 0 auto;
max-width: 48%;
padding: 3px 7px;
border: 1px solid var(--line);
border-radius: var(--radius-pill);
color: var(--ink-dim);
font: 10px/1.4 var(--font-mono);
text-align: right;
overflow-wrap: anywhere;
}
.detail-card-summary {
margin: 9px 0 0;
color: var(--ink-dim);
font-size: 12px;
line-height: 1.55;
overflow-wrap: anywhere;
}
.detail-card-fields {
display: grid;
gap: 9px;
margin: 14px 0 0;
}
.detail-card-field {
display: grid;
grid-template-columns: minmax(90px, 0.45fr) minmax(0, 1fr);
align-items: start;
gap: 8px 14px;
padding-top: 8px;
border-top: 1px solid var(--line-soft);
}
.detail-card-field dt {
color: var(--ink-faint);
font: 10px/1.45 var(--font-mono);
letter-spacing: 0.04em;
text-transform: uppercase;
}
.detail-card-field dd {
min-width: 0;
margin: 0;
color: var(--paper);
font-size: 12px;
line-height: 1.5;
overflow-wrap: anywhere;
}
.detail-card-action { margin-top: 14px; }
.detail-boundary-note {
margin: 12px 0;
padding: 11px 13px;
border-left: 2px solid var(--status-info);
background: var(--surface-2);
color: var(--ink-dim);
font-size: 12px;
line-height: 1.55;
}
.mobile-record-field { display: contents; }
@media (max-width: 640px) {
.detail-mobile-section {
display: grid;
gap: 7px;
margin: 18px 0;
color: var(--ink-dim);
font-size: 12px;
}
.detail-mobile-section span {
color: var(--ink-faint);
font: 10px/1.4 var(--font-mono);
letter-spacing: 0.05em;
text-transform: uppercase;
}
.detail-mobile-section select {
width: 100%;
min-height: 44px;
padding: 8px 12px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
background: var(--surface-2);
color: var(--paper-bright);
font: inherit;
}
.detail-desktop-tabs { display: none; }
.detail-card-grid { grid-template-columns: 1fr; gap: 10px; margin: 12px 0; }
.detail-card { padding: 14px; }
}
+63
View File
@@ -0,0 +1,63 @@
/* Shared operator surfaces; component-specific shell/workflows follow. */
.dashboard, .dashboard > *, .dashboard-columns > * { min-width: 0; }
.inbox-expand { margin-top: 12px; }
.hero-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }
.hero-meta strong { margin: 0; }
.freshness { display: inline-flex; align-items: center; gap: 6px; font: 11px/1.5 var(--font-mono); color: var(--ink-dim); }
.freshness::before { content: ""; width: 6px; height: 6px; border-radius: 50%; background: var(--status-success); }
.freshness-stale::before, .freshness-disconnected::before { background: var(--status-danger); }
.freshness-refreshing::before, .freshness-paused::before { background: var(--ink-faint); }
.signal-next-step { display: inline-flex; gap: 6px; align-items: center; margin-top: 8px; color: var(--accent); font-size: 12px; font-weight: 600; }
.exception-item > div { min-width: 0; overflow-wrap: anywhere; }
.exception-item .signal-time { font-size: 10px; }
.operations-actions { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 8px; }
.operations-overview .panel-heading { gap: 18px; }
.recovery-record { display: grid; grid-template-columns: minmax(130px, .4fr) 1fr; gap: 10px 16px; font-size: 12px; }
.recovery-record dt { color: var(--ink-faint); }
.recovery-record dd { margin: 0; overflow-wrap: anywhere; }
[id^="operations-"] { scroll-margin-top: 20px; }
.alert-list .signal-next-step { grid-column: 1 / -1; }
.trend-chart { display: grid; grid-template-columns: repeat(24, minmax(0, 1fr)); gap: 3px; overflow: visible; }
.trend-column { border: 0; padding: 0; min-width: 0; background: transparent; color: var(--paper); cursor: pointer; border-radius: 3px; }
.trend-column[aria-pressed="true"] { background: var(--surface-2); outline: 1px solid var(--accent); }
.trend-column small { font-size: 8px; }
.trend-detail { min-height: 36px; margin-top: 12px !important; overflow-wrap: anywhere; }
.mobile-trend, .mobile-client-actions { display: none; }
.desktop-client-actions { display: inline-flex; flex-wrap: wrap; gap: 6px; }
.client-actions { align-items: flex-start; }
.metric-card strong, .metric-card p { overflow-wrap: anywhere; }
@media (max-width: 640px) {
.table-controls { display: none; }
.desktop-client-actions, .desktop-trend { display: none; }
.mobile-client-actions, .mobile-trend { display: block; }
.mobile-client-actions { flex: 1; }
.mobile-client-actions summary { display: flex; align-items: center; justify-content: center; min-height: 44px; padding: 8px 12px; border: 1px solid var(--line); border-radius: 4px; color: var(--paper); font-size: 12px; cursor: pointer; }
.mobile-client-actions summary::after { content: "+"; margin-left: 8px; }
.mobile-client-actions[open] summary::after { content: "−"; }
.mobile-client-actions > div { display: grid; gap: 8px; padding-top: 8px; }
.mobile-trend .trend-chart { grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 2px; padding-inline: 0; }
.mobile-trend .trend-column { min-height: 44px; }
.mobile-trend .trend-column small { font-size: 9px; }
.recovery-record { grid-template-columns: 1fr; gap: 4px; }
.recovery-record dd { margin-bottom: 10px; }
.operations-actions { justify-content: flex-start; width: 100%; }
.operations-actions .button { flex: 1; }
.button, .filter-clear, .tab, .theme-toggle button, .panel-heading a { min-height: 44px; }
.button { display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
.panel-heading a { display: inline-flex; align-items: center; }
.filter-clear { min-width: 44px; }
.filter-input-wrap input { padding-right: 48px; }
input, select, textarea { font-size: 16px; }
.exception-item { display: grid; grid-template-columns: minmax(0, 1fr) auto; }
.exception-action .status { font-size: 10px; padding-inline: 7px; }
}
/* Content, rather than a device label, sets the metric wrapping threshold. */
@media (max-width: 480px) {
.metric-grid, .secondary-grid { grid-template-columns: 1fr; }
.metric-card { min-height: 102px; }
}
.client-created-notice { margin: 0 0 1rem; flex-wrap: wrap; overflow-wrap: anywhere; }
.client-created-notice a, .client-created-notice button { display: inline-flex; align-items: center; min-height: 44px; padding: .5rem .75rem; }
+190
View File
@@ -0,0 +1,190 @@
/* Focus and compact-screen behavior for the application shell and dialogs. */
.nav-trigger,
.nav-close,
.modal-close {
min-width: 44px;
min-height: 44px;
}
.nav-link,
.command-result {
min-height: 44px;
}
.command-result-active {
border-color: var(--line);
color: var(--paper);
background: var(--surface-2);
}
.command-group { display: grid; align-content: start; gap: 3px; }
.command-group + .command-group { margin-top: 8px; }
.command-group h3 {
margin: 8px 10px 2px;
color: var(--ink-faint);
font: 600 10px/1.4 var(--font-mono);
letter-spacing: .08em;
text-transform: uppercase;
}
.command-search-error {
margin: 10px 2px;
padding: 11px 12px;
border: 1px solid color-mix(in srgb, var(--status-danger) 45%, var(--line));
border-radius: var(--radius-md);
color: var(--status-danger);
background: var(--status-danger-dim);
font-size: 12px;
}
.session-signout { min-height: 44px; white-space: nowrap; }
.session-error { max-width: min(80vw, 320px); overflow-wrap: anywhere; }
@media (max-width: 840px) {
.app-shell { min-height: 100dvh; }
.sidebar {
top: 0;
bottom: 0;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
padding-top: calc(20px + env(safe-area-inset-top));
padding-left: calc(16px + env(safe-area-inset-left));
padding-right: calc(16px + env(safe-area-inset-right));
padding-bottom: calc(20px + env(safe-area-inset-bottom));
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.sidebar .nav-link { min-height: 44px; }
.nav-backdrop { touch-action: none; }
.main-content {
min-height: 100dvh;
padding-top: calc(19px + env(safe-area-inset-top));
padding-left: calc(14px + env(safe-area-inset-left));
padding-right: calc(14px + env(safe-area-inset-right));
padding-bottom: calc(44px + env(safe-area-inset-bottom));
}
}
@media (max-width: 640px) {
.topbar { gap: 8px; }
.topbar-title { flex: 1 1 auto; }
.topbar-actions { flex: 0 0 auto; gap: 6px; }
.nav-trigger,
.command-trigger { min-width: 44px; min-height: 44px; }
.session-menu { position: relative; flex: 0 0 auto; }
.session-menu .session-identity {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.session-menu .session-signout {
min-width: 72px;
min-height: 44px;
padding: 0 9px;
font-size: 11px;
}
.session-menu .session-signout::after { content: none; }
.session-error {
position: absolute;
z-index: 5;
top: calc(100% + 5px);
right: 0;
width: min(80vw, 320px);
padding: 8px;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
background: var(--surface-1);
}
.modal-overlay {
padding: max(12px, env(safe-area-inset-top)) max(12px, env(safe-area-inset-right)) max(12px, env(safe-area-inset-bottom)) max(12px, env(safe-area-inset-left));
place-items: center;
}
.modal-overlay:has(.command-modal),
.modal-overlay:has(.readiness-form) { padding: 0; place-items: stretch; }
.modal.command-modal {
width: 100%;
height: 100dvh;
max-height: 100dvh;
max-width: none;
margin: 0;
padding: 0;
padding-top: env(safe-area-inset-top);
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
padding-bottom: env(safe-area-inset-bottom);
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.command-modal .modal-heading {
flex: 0 0 auto;
padding: 10px 12px 9px;
}
.command-modal .modal-body {
display: flex;
flex: 1 1 auto;
min-height: 0;
flex-direction: column;
overflow: hidden;
padding: 10px 12px 8px;
}
.command-results {
align-content: start;
flex: 1 1 auto;
min-height: 0;
max-height: none;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
.command-hint { flex: 0 0 auto; padding-bottom: 2px; }
.modal:has(.readiness-form) {
width: 100%;
height: 100dvh;
max-height: 100dvh;
max-width: none;
margin: 0;
padding: 0;
padding-left: env(safe-area-inset-left);
padding-right: env(safe-area-inset-right);
border: 0;
border-radius: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.modal:has(.readiness-form) .modal-heading {
flex: 0 0 auto;
margin: 0;
padding: calc(8px + env(safe-area-inset-top)) 12px 8px;
border-bottom: 1px solid var(--line);
}
.modal:has(.readiness-form) .modal-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
padding: 14px 14px calc(18px + env(safe-area-inset-bottom));
}
/* Short phones keep confirmations compact while the dialog itself stays
within the dynamic viewport and can scroll if its copy needs more room. */
.modal:not(.command-modal):not(:has(.readiness-form)) {
place-self: center;
width: min(100%, 760px);
height: auto;
max-height: calc(100dvh - env(safe-area-inset-top) - env(safe-area-inset-bottom) - 24px);
overflow-y: auto;
}
}
+99
View File
@@ -0,0 +1,99 @@
.onboarding-steps li.complete > span { border-color: var(--status-success); color: var(--status-success); }
.onboarding-steps li.complete strong { color: var(--status-success); }
.onboarding-identity-bar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 17px 0; padding: 13px 15px; border: 1px solid var(--line); background: var(--surface-2); }
.onboarding-identity-bar > div:first-child { display: grid; gap: 4px; min-width: 0; }
.onboarding-identity-bar strong { color: var(--paper-bright); font: 11px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.onboarding-identity-bar small { color: var(--ink-faint); font-size: 11px; }
.onboarding-identity-actions { display: flex; flex-wrap: wrap; gap: 7px; }
.onboarding-created-title { margin: 5px 0 0; color: var(--paper); font-size: 13px; }
.onboarding-config { margin-top: 15px; padding: 15px; border: 1px solid var(--line); border-left: 3px solid var(--accent); background: var(--surface-2); }
.onboarding-config .panel-heading { align-items: center; }
.onboarding-config h4, .onboarding-stage-card h4 { margin: 0; color: var(--paper-bright); font-size: 15px; }
.onboarding-config dl { display: grid; gap: 8px; margin: 14px 0; }
.onboarding-config dl > div { display: grid; grid-template-columns: minmax(105px, .34fr) minmax(0, 1fr); gap: 12px; padding-top: 8px; border-top: 1px solid var(--line); }
.onboarding-config dt, .onboarding-checks dt { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); text-transform: uppercase; letter-spacing: .04em; }
.onboarding-config dd { min-width: 0; margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
.onboarding-config-auth { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 6px 16px; margin: 14px 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-1); }
.onboarding-config-auth p { min-width: 0; margin: 0; color: var(--ink-dim); font-size: 11px; overflow-wrap: anywhere; }
.onboarding-config-auth strong { color: var(--paper); }
.onboarding-handoff-steps { display: grid; gap: 7px; margin: 12px 0 0; padding-left: 19px; }
.onboarding-handoff-steps li { color: var(--ink-dim); font-size: 11px; line-height: 1.55; }
.onboarding-stage-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; margin-top: 15px; }
.onboarding-stage-card { min-width: 0; padding: 15px; border: 1px solid var(--line); background: var(--surface-1); }
.onboarding-stage-heading { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; }
.onboarding-stage-heading .eyebrow { margin: 0 0 4px; }
.stage-number { display: grid; place-items: center; flex: 0 0 auto; width: 30px; height: 30px; border: 1px solid var(--accent-dim); border-radius: 50%; color: var(--accent); background: var(--accent-dim); font: 11px var(--font-mono); }
.onboarding-checks { display: grid; gap: 8px; margin: 14px 0; }
.onboarding-check { display: grid; grid-template-columns: minmax(82px, .35fr) minmax(0, 1fr); align-items: start; gap: 10px; padding-top: 9px; border-top: 1px solid var(--line); }
.onboarding-check dd { display: grid; grid-template-columns: max-content minmax(0, 1fr); align-items: start; gap: 7px 9px; min-width: 0; margin: 0; color: var(--ink-dim); font-size: 11px; }
.onboarding-check .status { grid-row: span 2; }
.onboarding-check-detail { min-width: 0; overflow-wrap: anywhere; }
.onboarding-check dd > small { grid-column: 2; color: var(--ink-faint); font: 9px/1.4 var(--font-mono); }
.external-boundary-note, .manual-proof-note { display: grid; gap: 4px; margin-top: 13px; padding: 11px; border: 1px solid var(--line); background: var(--surface-2); }
.external-boundary-note strong, .manual-proof-note strong { color: var(--paper); font-size: 11px; }
.external-boundary-note span, .manual-proof-note span { color: var(--ink-dim); font-size: 10.5px; line-height: 1.55; }
.onboarding-verified, .onboarding-action-success { margin: 12px 0; padding: 10px 12px; border: 1px solid color-mix(in srgb, var(--status-success) 35%, var(--line)); color: var(--status-success); background: var(--status-success-dim); font-size: 11px; line-height: 1.5; }
.onboarding-action-info { margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); color: var(--ink-dim); background: var(--surface-2); font-size: 11px; line-height: 1.5; }
.onboarding-check-recorded { margin: 12px 0; padding: 10px 12px; border: 1px solid var(--line); color: var(--ink-dim); background: var(--surface-2); font-size: 11px; line-height: 1.5; }
.onboarding-verification-detail { margin: 10px 0 0; font-size: 10.5px; overflow-wrap: anywhere; }
.onboarding-loading { display: flex; align-items: center; gap: 8px; min-height: 44px; color: var(--ink-faint); font-size: 11px; }
.schema-inputs { margin-top: 14px; }
.schema-input-field { display: grid; align-content: start; gap: 6px; min-width: 0; }
.schema-input-label { color: var(--paper); font-size: 11px; font-weight: 600; }
.required-mark { color: var(--accent); }
.schema-input-field input, .schema-input-field select, .schema-input-field textarea { min-width: 0; }
.schema-input-field textarea { resize: vertical; font-family: var(--font-mono); font-size: 11px; line-height: 1.5; }
.schema-input-field > small { color: var(--ink-faint); font-size: 10px; font-weight: 400; line-height: 1.45; }
.schema-input-field .schema-field-error { color: var(--status-danger); font-weight: 600; }
.schema-null-choice { width: max-content !important; max-width: 100%; }
.schema-null-value { padding: 10px 11px; border: 1px dashed var(--line); color: var(--ink-faint); font-size: 11px; }
.schema-input-empty, .tool-input-summary { margin: 12px 0; color: var(--ink-faint); font-size: 11px; }
.tool-console-actions { flex-wrap: wrap; }
.tool-result { border-left-color: var(--accent); }
.tool-result pre { max-height: min(54dvh, 560px); max-width: 100%; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; tab-size: 2; }
.tool-result .copy-status { overflow-wrap: anywhere; }
.recent-tool-runs { max-width: none; }
.recent-run-list { display: grid; gap: 9px; margin: 14px 0 0; padding: 0; list-style: none; }
.recent-run { display: flex; align-items: center; justify-content: space-between; gap: 14px; min-width: 0; padding: 12px; border: 1px solid var(--line); background: var(--surface-1); }
.recent-run-main { min-width: 0; flex: 1; }
.recent-run-heading { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; }
.recent-run-tool { color: var(--paper-bright); font: 11px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.recent-run-outcome { display: inline-flex; align-items: center; min-height: 22px; padding: 3px 7px; border: 1px solid var(--line); border-radius: 999px; font: 9px/1.2 var(--font-mono); }
.recent-run-success { color: var(--status-success); border-color: color-mix(in srgb, var(--status-success) 38%, var(--line)); }
.recent-run-error { color: var(--status-danger); border-color: color-mix(in srgb, var(--status-danger) 38%, var(--line)); }
.recent-run dl { display: flex; flex-wrap: wrap; gap: 5px 14px; margin: 8px 0 0; }
.recent-run dl > div { display: flex; flex-wrap: wrap; gap: 4px; min-width: 0; }
.recent-run dt { color: var(--ink-faint); font: 9px/1.5 var(--font-mono); text-transform: uppercase; }
.recent-run dd { min-width: 0; margin: 0; color: var(--ink-dim); font-size: 10px; overflow-wrap: anywhere; }
.recent-run dd code { color: var(--ink-faint); font: 9px/1.5 var(--font-mono); overflow-wrap: anywhere; }
.recent-run-open { flex: 0 0 auto; }
@media (max-width: 760px) {
.onboarding-steps { gap: 6px; }
.onboarding-steps li { gap: 5px; }
.onboarding-steps li > div { display: grid; min-width: 0; }
.onboarding-steps strong { font-size: 10px; }
.onboarding-steps small { font-size: 9px; line-height: 1.35; }
.onboarding-stage-grid { grid-template-columns: 1fr; }
.recent-run { align-items: stretch; flex-direction: column; }
.recent-run-open { width: 100%; min-height: 44px; }
}
@media (max-width: 520px) {
.onboarding-identity-bar { align-items: stretch; flex-direction: column; }
.onboarding-identity-actions { display: grid; grid-template-columns: 1fr; }
.onboarding-identity-actions .button { min-height: 44px; }
.onboarding-config .panel-heading { align-items: stretch; flex-direction: column; }
.onboarding-config .panel-heading > .button { min-height: 44px; }
.onboarding-config dl > div { grid-template-columns: 1fr; gap: 3px; }
.onboarding-config-auth { grid-template-columns: 1fr; }
.onboarding-check { grid-template-columns: 1fr; gap: 5px; }
.onboarding-check dd { grid-template-columns: max-content minmax(0, 1fr); }
.onboarding-check dd > small { grid-column: 2; }
.tool-console-actions { display: grid; grid-template-columns: 1fr; }
.tool-console-actions .button { min-height: 44px; }
.tool-result .panel-heading { align-items: stretch; flex-direction: column; }
.tool-result .panel-heading > .button { align-self: flex-start; min-height: 40px; }
}
+82
View File
@@ -0,0 +1,82 @@
/** Browser-local history contains only the identifiers needed to reopen a
* tool's project scope. Invocation arguments, output and error text stay out
* of storage. */
export const TOOL_HISTORY_KEY = "itworx-tool-history-v1";
export const TOOL_HISTORY_EVENT = "itworx:tool-history-updated";
export const MAX_TOOL_RUNS = 20;
export interface ToolRunMetadata {
tool: string;
project: string;
environment: string;
ok: boolean;
traceId: string;
at: string;
}
type HistoryStorage = Pick<Storage, "getItem" | "setItem">;
function browserSessionStorage(): HistoryStorage | null {
try {
return typeof window === "undefined" ? null : window.sessionStorage;
} catch {
return null;
}
}
function boundedString(value: unknown, maxLength: number, pattern: RegExp): string | null {
if (typeof value !== "string") return null;
const normalized = value.trim();
return normalized.length > 0 && normalized.length <= maxLength && pattern.test(normalized)
? normalized
: null;
}
function normalizeToolRun(value: unknown): ToolRunMetadata | null {
if (typeof value !== "object" || value === null || Array.isArray(value)) return null;
const candidate = value as Record<string, unknown>;
const tool = boundedString(candidate.tool, 192, /^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
const project = boundedString(candidate.project, 63, /^[a-z0-9][a-z0-9-]*$/);
const environment = boundedString(candidate.environment, 32, /^[a-z0-9][a-z0-9-]*$/);
const traceId = boundedString(candidate.traceId, 256, /^[A-Za-z0-9][A-Za-z0-9._:-]*$/);
const timestamp = typeof candidate.at === "string" ? Date.parse(candidate.at) : Number.NaN;
if (!tool || !project || !environment || !traceId || typeof candidate.ok !== "boolean" || !Number.isFinite(timestamp)) return null;
return {
tool,
project,
environment,
ok: candidate.ok,
traceId,
at: new Date(timestamp).toISOString(),
};
}
export function readToolHistory(storage = browserSessionStorage()): ToolRunMetadata[] {
if (!storage) return [];
try {
const raw = storage.getItem(TOOL_HISTORY_KEY);
if (!raw) return [];
const parsed: unknown = JSON.parse(raw);
if (!Array.isArray(parsed)) return [];
return parsed.slice(0, MAX_TOOL_RUNS).flatMap((entry) => {
const normalized = normalizeToolRun(entry);
return normalized ? [normalized] : [];
});
} catch {
return [];
}
}
export function recordToolRun(value: ToolRunMetadata, storage = browserSessionStorage()): void {
if (!storage) return;
const run = normalizeToolRun(value);
if (!run) return;
try {
const history = readToolHistory(storage).filter((entry) => entry.traceId !== run.traceId);
storage.setItem(TOOL_HISTORY_KEY, JSON.stringify([run, ...history].slice(0, MAX_TOOL_RUNS)));
if (typeof window !== "undefined") window.dispatchEvent(new Event(TOOL_HISTORY_EVENT));
} catch {
// Browser history is optional; never turn a completed invocation into a failure.
}
}
+24 -4
View File
@@ -10,12 +10,32 @@ describe("management API safety contracts", () => {
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } }));
});
it("maps protected client actions and never invents returned secret material", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }) });
it("projects only safe client profile fields from the generated configuration", async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({
clientId: "client-1",
kind: "stdio-bridge",
protocolVersion: "2026-07-28",
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
transport: "stdio",
scopes: ["tools:read"],
credentialReference: "authentik://mcp-client/private-reference",
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
bridgeCommand: "itworx-mcp-bridge",
}) });
vi.stubGlobal("fetch", fetchMock);
const config = await createApiClient().generatedConfig("client-1");
expect(config).toEqual({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] });
expect(JSON.stringify(config)).not.toContain("secret");
expect(config).toEqual({
clientId: "client-1",
kind: "stdio-bridge",
protocolVersion: "2026-07-28",
endpoint: "https://mcp.example.test/mcp/ludarium/prod",
transport: "stdio",
scopes: ["tools:read"],
environmentVariable: "ITWORX_MCP_ACCESS_TOKEN",
bridgeCommand: "itworx-mcp-bridge",
});
expect(config).not.toHaveProperty("credentialReference");
expect(JSON.stringify(config)).not.toContain("authentik://mcp-client/private-reference");
expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" }));
});
it("fails closed without CSRF and sends the session-bound token on writes", async () => {
+4
View File
@@ -3,6 +3,10 @@ import { isSnapshotStale, routeTitle } from "../src/contracts";
import { formatBrusselsTime, healthLabel } from "../src/format";
describe("management shell contracts", () => {
it("labels connector detail breadcrumbs as a registered route", () => {
expect(routeTitle("/connectors/knowledge-default")).toBe("Connector detail");
expect(routeTitle("/connectors/knowledge-default/")).toBe("Connector detail");
});
it("maps required routes to stable titles", () => { expect(routeTitle("/")).toBe("Overview"); expect(routeTitle("/projects/ludarium")).toBe("Project detail"); expect(routeTitle("/audit")).toBe("Audit"); expect(routeTitle("/missing")).toBe("Not found"); });
it("marks invalid and old snapshots stale instead of implying health", () => { const snapshot = { generatedAt: "2026-08-01T10:00:00Z", staleAfterSeconds: 30 } as Parameters<typeof isSnapshotStale>[0]; expect(isSnapshotStale(snapshot, Date.parse("2026-08-01T10:00:31Z"))).toBe(true); expect(isSnapshotStale({ ...snapshot, generatedAt: "not-a-date" })).toBe(true); });
it("formats operator times in Brussels and labels state textually", () => { expect(formatBrusselsTime("2026-08-01T10:00:00Z")).toContain("12:00"); expect(formatBrusselsTime(null)).toBe("Never recorded"); expect(healthLabel("unavailable")).toBe("Unavailable"); });
+4 -2
View File
@@ -16,8 +16,10 @@ test.describe("authentication", () => {
await expect(page).toHaveURL(/\/$/);
await expect(page.getByText("Authenticated", { exact: true })).toBeVisible();
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
// Real backend data, not a placeholder: Ludarium is the seeded catalog project.
await expect(page.getByText("Ludarium", { exact: true })).toBeVisible();
// Assert a real bounded dashboard row without depending on catalog ordering.
const snapshot = await (await page.request.get("/api/dashboard")).json();
expect(snapshot.projects.length).toBeGreaterThan(0);
await expect(page.locator(`[href="/projects/${snapshot.projects[0].slug}"]`).first()).toContainText(snapshot.projects[0].name);
});
test("an authenticated user can sign out and stale management data disappears", async ({ page }) => {
+2 -1
View File
@@ -18,7 +18,8 @@ test.describe("mobile navigation drawer", () => {
await trigger.click();
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
await expect(trigger).toHaveAttribute("aria-expanded", "true");
await expect(page.locator(".nav-trigger")).toHaveAttribute("aria-expanded", "true");
await expect(page.getByRole("dialog", { name: "Application navigation" })).toBeVisible();
});
test("closes via its own close button", async ({ page }) => {
+194
View File
@@ -0,0 +1,194 @@
import AxeBuilder from "@axe-core/playwright";
import { expect, loginAs, test } from "./fixtures";
const darkReview = process.env.WP400_DARK_REVIEW === "1";
test.use({ colorScheme: darkReview ? "dark" : "light" });
const viewports = [
{ width: 320, height: 568 }, { width: 360, height: 800 }, { width: 390, height: 844 },
{ width: 430, height: 932 }, { width: 768, height: 1024 }, { width: 1280, height: 800 },
{ width: 1440, height: 900 }, { width: 844, height: 390 },
];
for (const viewport of viewports) {
test(`operator layout and accessible surfaces at ${viewport.width}x${viewport.height}`, async ({ page }, info) => {
test.setTimeout(180_000);
await page.setViewportSize(viewport);
await loginAs(page, "hub-admin");
for (const path of ["/", "/projects", "/servers", "/tools", "/connectors", "/clients", "/policies", "/audit", "/operations", "/settings", "/projects/ludarium", "/connectors/knowledge-default"]) {
await page.goto(path);
await expect(page.locator(".topbar")).toBeVisible();
if (viewport.width <= 840) {
const target = await page.locator(".nav-trigger").boundingBox();
expect(target?.width).toBeGreaterThanOrEqual(44);
expect(target?.height).toBeGreaterThanOrEqual(44);
}
await expect(page.locator(".loading-state")).toHaveCount(0, { timeout: 15_000 });
const dimensions = await page.evaluate(() => ({ document: document.documentElement.scrollWidth, viewport: innerWidth }));
expect(dimensions.document, path).toBeLessThanOrEqual(dimensions.viewport + 1);
if (path === "/" && viewport.width <= 430) {
await expect(page.getByLabel("Project status records")).toBeVisible();
await expect(page.getByLabel("Project status table")).toBeHidden();
const grid = await page.locator(".primary-metrics").evaluate((element) => getComputedStyle(element).gridTemplateColumns);
expect(grid.split(" ")).toHaveLength(1);
}
if (path === "/tools" && viewport.width <= 430) await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
if (viewport.width === 390 || viewport.width === 1440) {
const result = await new AxeBuilder({ page }).analyze();
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? "")), path).toEqual([]);
}
if (process.env.SOL_VISUAL_REVIEW && ["/", "/tools", "/clients", "/operations", "/projects/ludarium", "/connectors/knowledge-default"].includes(path)) {
const themeSuffix = darkReview ? "-dark" : "";
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}.png`), fullPage: true });
await page.screenshot({ path: info.outputPath(`${path.replaceAll("/", "_") || "dashboard"}-${viewport.width}${themeSuffix}-viewport.png`) });
}
}
});
}
test("mobile client actions require an explicit disclosure and confirmation", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-admin");
await page.goto("/clients");
const name = `mobile-safety-${Date.now()}`;
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create pending client" }).click();
const card = page.locator(".mobile-record").filter({ hasText: name }).first();
await expect(card.getByRole("button", { name: "Config", exact: true })).toBeVisible();
await expect(card.getByRole("button", { name: "Revoke", exact: true })).toBeHidden();
await card.locator("summary").click();
await card.getByRole("button", { name: "Revoke", exact: true }).click();
await expect(page.getByRole("dialog", { name: /Revoke/ })).toBeVisible();
await page.getByRole("button", { name: "Cancel", exact: true }).click();
});
test("desktop density persists while mobile cards have no inert density control", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/tools");
await page.getByRole("button", { name: "Compact", exact: true }).click();
await page.reload();
await expect(page.getByRole("button", { name: "Compact", exact: true })).toHaveAttribute("aria-pressed", "true");
await page.setViewportSize({ width: 430, height: 932 });
await expect(page.getByRole("group", { name: "Table density" })).toBeHidden();
});
test("phone trend keeps outcome details and operations errors never imply no alerts", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.route("**/api/operations/summary", async (route) => {
const response = await route.fetch();
const summary = await response.json();
summary.trend = Array.from({ length: 24 }, (_, hour) => ({ hour: new Date(Date.UTC(2026, 9, 1, hour)).toISOString(), success: hour, denied: 1, error: 0, rate_limited: 0 }));
await route.fulfill({ json: summary });
});
await page.goto("/operations");
const mobile = page.getByRole("group", { name: "Four-hour call buckets" });
await expect(mobile).toBeVisible();
await expect(mobile.getByRole("button")).toHaveCount(6);
await mobile.getByRole("button").first().click();
await expect(page.locator(".mobile-trend .trend-detail")).toContainText("success");
await page.unroute("**/api/operations/summary");
await page.route("**/api/operations/summary", (route) => route.fulfill({ status: 503, body: "unavailable" }));
await page.reload();
await expect(page.getByText("Alerts are unknown while the summary is unavailable.")).toBeVisible();
await expect(page.getByText("No alerts in the latest summary")).toHaveCount(0);
});
test("status refresh backs off, reports disconnection and does not invoke probes", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.clock.install();
await page.goto("/");
await expect(page.getByText("Current · auto refresh")).toBeVisible();
let reads = 0;
let writes = 0;
page.on("request", (request) => { if (request.method() === "POST" && request.url().includes("/api/")) writes += 1; });
await page.route("**/api/dashboard", (route) => { reads += 1; return route.fulfill({ status: 503, body: "unavailable" }); });
await page.clock.fastForward(31_000);
await expect(page.getByText("Disconnected · retry available")).toBeVisible();
expect(reads).toBe(1);
await page.clock.fastForward(30_000);
expect(reads).toBe(1);
await page.clock.fastForward(31_000);
await expect.poll(() => reads).toBe(2);
expect(writes).toBe(0);
});
test("a hidden dashboard pauses scheduled refresh and never sends an automatic write", async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.clock.install();
await page.addInitScript(() => {
Object.defineProperty(document, "hidden", {
configurable: true,
get: () => sessionStorage.getItem("e2e-document-hidden") !== "false",
});
});
let dashboardReads = 0;
let apiWrites = 0;
page.on("request", (request) => {
const url = new URL(request.url());
if (url.pathname === "/api/dashboard" && request.method() === "GET") dashboardReads += 1;
if (url.pathname.startsWith("/api/") && request.method() === "POST") apiWrites += 1;
});
await page.goto("/");
await expect(page.getByText("Auto refresh paused")).toBeVisible();
expect(dashboardReads).toBe(1); // The one initial snapshot; no timer-driven read.
await page.clock.fastForward(120_000);
expect(dashboardReads).toBe(1);
expect(apiWrites).toBe(0);
});
test("the dashboard action inbox starts with four signals and expands the rest on demand", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await loginAs(page, "hub-viewer");
await page.route("**/api/dashboard", async (route) => {
const response = await route.fetch();
const snapshot = await response.json();
snapshot.generatedAt = new Date().toISOString();
snapshot.staleAfterSeconds = 3600;
snapshot.health = { hub: "healthy", gateway: "healthy", detail: "Fixture is current." };
snapshot.connectors = Array.from({ length: 6 }, (_, index) => ({
id: `inbox-fixture-${index + 1}`,
name: `Inbox fixture ${index + 1}`,
project: "ludarium",
state: "degraded",
detail: "A controlled connector needs review.",
registrationState: "registered",
readiness: { state: "unknown", detail: "No live probe was requested.", observedAt: null, source: "not-probed" },
}));
snapshot.drift = { count: 0, projects: [], known: true, detail: "No drift." };
snapshot.backup = { state: "healthy", lastSuccessfulAt: null, detail: "Fixture backup state." };
snapshot.denials = { count: 0, window: "24h" };
await route.fulfill({ json: snapshot });
});
await page.goto("/");
const inbox = page.getByRole("region", { name: "6 actionable signals" });
await expect(inbox.locator(".exception-item")).toHaveCount(4);
await expect(inbox.getByRole("link", { name: /Inbox fixture 1/ })).toBeVisible();
await expect(inbox.getByRole("link", { name: /Inbox fixture 4/ })).toBeVisible();
await expect(inbox.getByRole("link", { name: /Inbox fixture 5/ })).toHaveCount(0);
await inbox.getByRole("button", { name: "Show 2 more signals" }).click();
await expect(inbox.locator(".exception-item")).toHaveCount(6);
await expect(inbox.getByRole("link", { name: /Inbox fixture 6/ })).toBeVisible();
await inbox.getByRole("button", { name: "Show fewer signals" }).click();
await expect(inbox.locator(".exception-item")).toHaveCount(4);
});
test("project and connector detail links resolve to registered records", async ({ page }) => {
await loginAs(page, "hub-admin");
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto("/connectors/knowledge-default");
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
await page.goto("/projects/ludarium?tab=MCP%20Servers");
await expect(page.getByLabel("MCP servers", { exact: true }).getByRole("heading", { name: "ludarium-prod-readonly", exact: true })).toBeVisible();
await page.goto("/projects/ludarium?tab=Knowledge%20Sources");
await expect(page.getByLabel("Knowledge sources", { exact: true }).getByRole("heading", { name: "discovery://repository/ludarium", exact: true })).toBeVisible();
});
+118
View File
@@ -0,0 +1,118 @@
import { expect, loginAs, test } from "./fixtures";
test.describe("operator review regressions", () => {
test.beforeEach(async ({ page }) => { await loginAs(page, "hub-admin"); });
test("filter changes retain the URL project and environment at the API boundary", async ({ page }) => {
await page.goto("/connectors?project=ludarium&environment=prod");
await expect(page.getByLabel("Filter records")).toBeVisible();
const response = page.waitForResponse((value) => {
const url = new URL(value.url());
return url.pathname === "/api/connectors" && url.searchParams.get("q") === "knowledge-default";
});
await page.getByLabel("Filter records").fill("knowledge-default");
const actual = new URL((await response).url());
expect(actual.searchParams.get("project")).toBe("ludarium");
expect(actual.searchParams.get("environment")).toBe("prod");
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("knowledge-default");
expect(new URL(page.url()).searchParams.get("project")).toBe("ludarium");
expect(new URL(page.url()).searchParams.get("environment")).toBe("prod");
await expect(page.getByRole("link", { name: "Knowledge Default", exact: true })).toBeVisible();
});
test("catalog scope stays locked until an explicit apply finishes", async ({ page }) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
await page.route("**/api/catalog/preview", (route) => route.fulfill({ json: { valid: true, changes: ["Review fixture change"], errors: [] } }));
await page.route("**/api/catalog/apply", async (route) => { await pending; await route.fulfill({ json: { accepted: true, message: "Reviewed catalog apply completed" } }); });
await page.goto("/tools?project=ludarium");
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
await workflow.getByRole("button", { name: "Validate and preview" }).click();
await workflow.getByRole("button", { name: "Apply approved catalog" }).click();
await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeDisabled();
await expect(workflow.getByRole("button", { name: "Applying…" })).toBeDisabled();
release();
await expect(workflow.getByText("Reviewed catalog apply completed")).toBeVisible();
await expect(workflow.getByRole("combobox", { name: "Project", exact: true })).toBeEnabled();
});
test("a pending client created after SPA navigation retains a visible resume path", async ({ page }) => {
let release!: () => void;
const pending = new Promise<void>((resolve) => { release = resolve; });
let createdId = "";
await page.route("**/api/client-management", async (route) => {
if (route.request().method() !== "POST") { await route.continue(); return; }
const response = await route.fetch();
createdId = ((await response.json()) as { client: { id: string } }).client.id;
await pending;
await route.fulfill({ response });
});
await page.goto("/clients");
const name = `creation-navigation-${Date.now()}`;
await page.getByLabel("Name", { exact: true }).fill(name);
await page.getByRole("button", { name: "Create pending client" }).click();
await expect.poll(() => createdId).not.toBe("");
await page.getByRole("navigation", { name: "Primary navigation" }).getByRole("link", { name: "Operations", exact: true }).click();
await expect(page.getByRole("heading", { name: "Operations", level: 1 })).toBeVisible();
release();
const notice = page.locator(".client-created-notice");
await expect(notice).toContainText(name);
expect(new URL(page.url()).pathname).toBe("/operations");
await expect(notice.getByRole("link", { name: "Resume onboarding" })).toHaveAttribute("href", `/clients?onboard=${createdId}`);
await notice.getByRole("link", { name: "Resume onboarding" }).click();
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
expect(new URL(page.url()).searchParams.get("onboard")).toBe(createdId);
});
test("operation-check results link to registered connector identities", async ({ page }) => {
await page.route("**/api/operations/check", (route) => route.fulfill({ json: {
generatedAt: new Date().toISOString(), healthy: true,
connectors: { "itworx-knowledge": { reachable: true, status: "healthy", detail: "Bounded contract fixture", connectorRefs: [{ id: "knowledge-default", name: "Knowledge Default" }], connectorRefsTruncated: false } },
} }));
await page.goto("/operations");
await page.getByRole("button", { name: "Run live checks" }).click();
const registration = page.locator(".connector-checks").getByRole("link", { name: "Knowledge Default", exact: true });
await expect(registration).toHaveAttribute("href", "/connectors/knowledge-default");
await registration.click();
await expect(page.getByRole("heading", { name: "Knowledge Default", exact: true })).toBeVisible();
});
test("Quick Jump sends an ordinary client to its filtered records instead of an unavailable onboarding workflow", async ({ page }) => {
await page.route("**/api/clients?*", (route) => route.fulfill({ json: {
kind: "clients", generatedAt: new Date().toISOString(),
rows: [{ id: "ordinary-fixture-client", name: "Ordinary fixture client", status: "healthy", summary: "Existing client", project: "ludarium", environment: "prod" }],
page: { total: 1, nextCursor: null },
} }));
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("combobox").fill("ordinary-fixture-client");
await dialog.getByRole("group", { name: "Clients", exact: true }).getByRole("option", { name: /Ordinary fixture client/ }).click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await expect(page.locator(".desktop-data-table").getByText("Existing client", { exact: true })).toBeVisible();
const desktopRecord = page.locator(".desktop-data-table").getByRole("link", { name: "Ordinary fixture client", exact: true });
await expect(desktopRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
await desktopRecord.click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await page.setViewportSize({ width: 390, height: 844 });
const mobileRecord = page.locator(".mobile-record-list").getByRole("link", { name: "Ordinary fixture client", exact: true });
await expect(mobileRecord).toHaveAttribute("href", "/clients?q=ordinary-fixture-client");
await mobileRecord.click();
await expect(page).toHaveURL(/\/clients\?q=ordinary-fixture-client$/);
await expect(page.getByText(/Resumable Hub identity/)).toHaveCount(0);
});
test("Quick Jump retains the selected audit trace as its destination filter", async ({ page }) => {
await page.route("**/api/audit?*", (route) => route.fulfill({ json: {
kind: "audit", generatedAt: new Date().toISOString(),
rows: [{ id: "audit-fixture", name: "tool.invocation", status: "healthy", summary: "Fixture event", traceId: "review-fixture-trace", project: "ludarium" }],
page: { total: 1, nextCursor: null },
} }));
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("combobox").fill("review-fixture-trace");
await dialog.getByRole("group", { name: "Audit", exact: true }).getByRole("option", { name: /tool.invocation/ }).click();
await expect(page).toHaveURL(/\/audit\?q=review-fixture-trace$/);
await expect(page.getByLabel("Filter records")).toHaveValue("review-fixture-trace");
});
});
+144
View File
@@ -0,0 +1,144 @@
import { expect, loginAs, test } from "./fixtures";
test.use({ viewport: { width: 390, height: 844 } });
test.describe("mobile operator shell", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-viewer");
await page.goto("/");
});
test("navigation isolates the page, traps focus, locks scroll, and restores focus on Escape", async ({ page }, testInfo) => {
const trigger = page.getByRole("button", { name: "Open navigation" });
const main = page.locator("main");
await trigger.focus();
await trigger.press("Enter");
const drawer = page.getByRole("dialog", { name: "Application navigation" });
const close = drawer.getByRole("button", { name: "Close navigation" });
await expect(drawer).toBeVisible();
await expect(drawer).toHaveAttribute("aria-modal", "true");
await expect(close).toBeFocused();
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-drawer.png"), animations: "disabled" });
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(true);
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await drawer.getByRole("link", { name: "ITWorx MCP Hub" }).focus();
await page.keyboard.press("Shift+Tab");
await expect(drawer.getByRole("link", { name: "Platform" })).toBeFocused();
await page.keyboard.press("Tab");
await expect(drawer.getByRole("link", { name: "ITWorx MCP Hub" })).toBeFocused();
await page.keyboard.press("Escape");
await expect(drawer).toHaveCount(0);
await expect(trigger).toBeFocused();
await expect.poll(() => main.evaluate((element) => (element as HTMLElement).inert)).toBe(false);
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("");
});
test("short phones can scroll the full drawer while the page stays locked", async ({ page }) => {
await page.setViewportSize({ width: 320, height: 520 });
await page.getByRole("button", { name: "Open navigation" }).click();
const drawer = page.getByRole("dialog", { name: "Application navigation" });
await expect(drawer).toBeVisible();
const metrics = await drawer.evaluate((element) => ({
height: element.getBoundingClientRect().height,
clientHeight: element.clientHeight,
scrollHeight: element.scrollHeight,
overflowY: getComputedStyle(element).overflowY,
}));
expect(metrics.height).toBeLessThanOrEqual(521);
expect(metrics.overflowY).toBe("auto");
expect(metrics.scrollHeight).toBeGreaterThan(metrics.clientHeight);
await drawer.evaluate((element) => { element.scrollTop = element.scrollHeight; });
await expect(drawer.getByRole("link", { name: "Platform" })).toBeInViewport();
});
test("Quick Jump searches grouped entities with keyboard control and never requests audit for a viewer", async ({ page }, testInfo) => {
const auditRequests: string[] = [];
page.on("request", (request) => {
if (new URL(request.url()).pathname === "/api/audit") auditRequests.push(request.url());
});
await page.getByRole("button", { name: "Open quick jump" }).click();
const dialog = page.getByRole("dialog", { name: "Quick jump" });
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await expect(input).toBeFocused();
await expect(dialog).toHaveAttribute("aria-modal", "true");
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
await input.fill("Ludarium");
const project = dialog.getByRole("group", { name: "Projects", exact: true }).getByRole("option", { name: /Ludarium/i });
await expect(project).toBeVisible();
expect((await project.boundingBox())!.height).toBeLessThanOrEqual(120);
if (process.env.SOL_VISUAL_REVIEW === "1") await page.screenshot({ path: testInfo.outputPath("mobile-quick-jump.png"), animations: "disabled" });
await expect(dialog.getByRole("group", { name: "Projects" })).toBeVisible();
await input.press("ArrowDown");
await expect(input).toHaveAttribute("aria-activedescendant", /quick-jump-option-/);
await input.press("Enter");
await expect(page).toHaveURL(/\/projects\/ludarium$/);
expect(auditRequests).toEqual([]);
});
test("Quick Jump keeps recent navigation in memory and shows search failures", async ({ page }) => {
await page.getByRole("button", { name: "Open quick jump" }).click();
let dialog = page.getByRole("dialog", { name: "Quick jump" });
await dialog.getByRole("option", { name: "Catalog" }).click();
await expect(page).toHaveURL(/\/tools$/);
await page.getByRole("button", { name: "Open quick jump" }).click();
dialog = page.getByRole("dialog", { name: "Quick jump" });
await expect(dialog.getByRole("group", { name: "Recent destinations" }).getByRole("option", { name: "Catalog" })).toBeVisible();
await page.route("**/api/projects?*", (route) => route.fulfill({ status: 503, body: "unavailable" }));
const input = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await input.fill("Ludarium");
await expect(dialog.getByRole("alert")).toHaveText("One resource type could not be searched. Results may be incomplete.");
});
test("sign-out remains explicit and reports its busy state on a narrow screen", async ({ page }) => {
await page.route("**/auth/logout", async (route) => {
await new Promise((resolve) => setTimeout(resolve, 300));
await route.fulfill({ status: 503, body: "unavailable" });
});
const signOut = page.getByRole("button", { name: "Sign out" });
await expect(signOut).toBeVisible();
await expect(signOut).toHaveText("Sign out");
await signOut.click();
const busyButton = page.getByRole("button", { name: "Signing out…" });
await expect(busyButton).toBeDisabled();
await expect(page.getByRole("alert")).toContainText("control API is unavailable");
await expect(page.getByRole("button", { name: "Sign out" })).toBeEnabled();
});
});
test.describe("mobile workflow dialog", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("large tool workflow fills the dynamic viewport and scrolls inside the dialog", async ({ page }, testInfo) => {
await loginAs(page, "hub-operator");
await page.goto("/tools");
const trigger = page.locator(".mobile-record-list").getByRole("button", { name: "Readiness" }).first();
await trigger.click();
const dialog = page.getByRole("dialog", { name: /Tool console/ });
await expect(dialog).toBeVisible();
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
await expect.poll(() => page.locator("main").evaluate((element) => Boolean(element.closest("[inert]")))).toBe(true);
const layout = await dialog.evaluate((element) => ({
height: element.getBoundingClientRect().height,
overflow: getComputedStyle(element).overflowY,
bodyOverflow: getComputedStyle(element.querySelector(".modal-body")!).overflowY,
}));
expect(layout.height).toBeGreaterThan(800);
expect(layout.overflow).toBe("hidden");
expect(layout.bodyOverflow).toBe("auto");
if (process.env.SOL_VISUAL_REVIEW === "1") {
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeEnabled();
await page.screenshot({ path: testInfo.outputPath("mobile-tool-workflow.png"), animations: "disabled" });
}
await page.keyboard.press("Escape");
await expect(dialog).toHaveCount(0);
await expect(trigger).toBeFocused();
});
});
@@ -0,0 +1,329 @@
import { expect, loginAs, test } from "./fixtures";
function projectDetailFixture(slug: string) {
return {
slug,
name: `Project ${slug.toUpperCase()}`,
description: `Delayed route fixture for ${slug}.`,
environments: [{ name: "prod", classification: "production" }],
capabilityPacks: [],
connectorBindings: [],
mcpServers: [],
knowledgeSources: [],
virtualServerCount: 0,
clientCount: 0,
activeClientCount: 0,
};
}
function connectorDetailFixture(id: string) {
return {
id,
name: `Connector ${id.toUpperCase()}`,
connectorType: "fixture",
description: `Delayed route fixture for ${id}.`,
registration: { state: "registered" as const, detail: "Fixture registration." },
readiness: { reachable: null, status: "unknown", detail: "No probe was requested.", observedAt: null, source: "unavailable" as const },
bindings: [],
relatedTools: [],
};
}
test.describe("operator workflow details", () => {
test.beforeEach(async ({ page }) => {
await loginAs(page, "hub-admin");
});
test("client onboarding resumes from its durable identity and never treats a Hub check as MCP proof", async ({ page }) => {
await page.goto("/clients");
const clientName = `onboarding-resume-${Date.now()}`;
await page.getByLabel("Name").fill(clientName);
await page.getByLabel("Client profile").selectOption("native-2026");
const createResponsePromise = page.waitForResponse((response) => {
const url = new URL(response.url());
return response.request().method() === "POST" && url.pathname === "/api/client-management";
});
await page.getByRole("button", { name: "Create pending client" }).click();
const created = await (await createResponsePromise).json() as {
client: { id: string };
bootstrap: { reference: string };
config: { kind: string; protocolVersion: string; transport: string; credentialReference: string };
provisioning: { oauthUsername: string; issuer: string; tokenEndpoint: string; oauthClientId: string };
};
await expect(page.getByText(`Client created: ${clientName}`)).toBeVisible();
await expect.poll(() => page.url()).toMatch(/\/clients\?onboard=[0-9a-f-]+$/i);
const savedUrl = page.url();
expect(new URL(savedUrl).searchParams.get("onboard")).toBe(created.client.id);
await page.reload();
await expect(page.getByText(/Resumable Hub identity/)).toBeVisible();
const handoff = page.locator(".onboarding-config-auth");
const configPanel = page.locator(".onboarding-config");
const configValue = (label: string) => configPanel.locator("dl > div").filter({ hasText: label }).locator("dd");
await expect(configValue("Client profile")).toHaveText(created.config.kind);
await expect(configValue("Protocol version")).toHaveText(created.config.protocolVersion);
await expect(configValue("Transport")).toHaveText(created.config.transport);
await expect(configPanel.getByText("Credential environment variable", { exact: true })).toHaveCount(0);
await expect(configPanel.getByText("Bridge command", { exact: true })).toHaveCount(0);
await expect(handoff).toContainText(`External account: ${created.provisioning.oauthUsername}`);
await expect(handoff).toContainText(`Issuer: ${created.provisioning.issuer}`);
await expect(handoff).toContainText(`Token endpoint: ${created.provisioning.tokenEndpoint}`);
await expect(handoff).toContainText(`OAuth client ID: ${created.provisioning.oauthClientId}`);
const resumedText = await page.locator("body").innerText();
expect(resumedText).not.toContain(created.bootstrap.reference);
expect(resumedText).not.toContain(created.config.credentialReference);
await expect(page.getByText("OAuth scopes", { exact: true })).toBeVisible();
await expect(page.getByText(/External verification still required/)).toBeVisible();
await expect(page.getByRole("button", { name: "Record Hub checks" })).toBeEnabled();
await page.getByRole("button", { name: "Record Hub checks" }).click();
await expect(page.locator(".onboarding-check-recorded")).toContainText("Hub checks were recorded");
await expect(page.getByText(/does not mark external OAuth grants or MCP discovery as complete/)).toBeVisible();
await expect(page.locator(".onboarding-identity-bar small")).toContainText("· pending");
expect(page.url()).toBe(savedUrl);
});
test("a delayed verify response cannot overwrite the onboarding identity after the route changes", async ({ page }) => {
const onboardingStatus = (clientId: string, subjectDetail: string, verified = false) => ({
clientId,
project: "ludarium",
environment: "prod",
clientStatus: "pending",
provisioning: {
status: "pending-authentik-service-account",
subject: null,
issuer: "https://identity.example.invalid",
oauthUsername: `svc-mcp-codex-ludarium-${clientId}`,
tokenEndpoint: "https://identity.example.invalid/application/o/token/",
oauthClientId: "hub-fixture-client",
steps: ["Complete external account setup with the administrator."],
},
checks: {
externalSubject: { state: "pending", detail: subjectDetail },
externalScope: { state: "unavailable", detail: "External OAuth grants are not visible to the Hub." },
hubBinding: { state: "pending", detail: "The client remains disabled." },
catalog: { state: "unknown", detail: "Catalog check has not passed." },
connector: { state: "unknown", detail: "Connector state is unknown." , observedAt: null },
mcpProof: { state: "unavailable", detail: "MCP discovery and invocation must be confirmed externally." },
},
verification: { checkedAt: verified ? "2026-10-01T10:00:00.000Z" : null, checkedByManagement: verified, verifiedByManagement: verified, hubChecksPassed: verified, detail: verified ? "Client A checks passed." : "No verification record." },
});
let releaseVerify!: () => void;
const verifyGate = new Promise<void>((resolve) => { releaseVerify = resolve; });
await page.route("**/api/client-management/a/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("a", "Client A is awaiting its external subject.") }));
await page.route("**/api/client-management/a/config", (route) => route.fulfill({ json: { clientId: "a", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
await page.route("**/api/client-management/b/onboarding-status", (route) => route.fulfill({ json: onboardingStatus("b", "Client B is awaiting its external subject.") }));
await page.route("**/api/client-management/b/config", (route) => route.fulfill({ json: { clientId: "b", endpoint: "https://hub.example.invalid/mcp", scopes: ["project:ludarium:read"] } }));
await page.route("**/api/client-management/a/verify", async (route) => {
await verifyGate;
await route.fulfill({ json: onboardingStatus("a", "Client A is verified.", true) });
});
await page.goto("/clients?onboard=a");
await expect(page.getByText("Client A is awaiting its external subject.")).toBeVisible();
const verifyRequest = page.waitForRequest((request) => request.url().endsWith("/api/client-management/a/verify"));
await page.getByRole("button", { name: "Record Hub checks" }).click();
await verifyRequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/clients?onboard=b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
releaseVerify();
await expect(page.getByText("Client B is awaiting its external subject.")).toBeVisible();
await expect(page.getByText("Client A is verified.")).toHaveCount(0);
await expect(page.getByText(/Hub checks passed/)).toHaveCount(0);
await expect(page).toHaveURL(/\/clients\?onboard=b$/);
});
test("a delayed catalog preview for the previous project cannot be shown or applied", async ({ page }) => {
await page.route((url) => url.pathname === "/api/projects" && url.searchParams.has("limit"), (route) => route.fulfill({ json: {
kind: "projects",
generatedAt: "2026-10-01T10:00:00.000Z",
rows: [
{ id: "ludarium", name: "Ludarium", status: "healthy", summary: "Fixture project A" },
{ id: "forgeflow", name: "ForgeFlow", status: "healthy", summary: "Fixture project B" },
],
page: { total: 2 },
} }));
let releasePreview!: () => void;
const previewGate = new Promise<void>((resolve) => { releasePreview = resolve; });
const previewProjects: string[] = [];
await page.route("**/api/catalog/preview", async (route) => {
const body = route.request().postDataJSON() as { project: string };
previewProjects.push(body.project);
if (body.project === "ludarium") {
await previewGate;
await route.fulfill({ json: { valid: true, changes: ["Project A private preview"], errors: [] } });
return;
}
await route.fulfill({ json: { valid: true, changes: ["Project B preview"], errors: [] } });
});
await page.goto("/tools");
const workflow = page.locator(".workflow-panel").filter({ has: page.getByRole("heading", { name: "Catalog validation and preview" }) });
const project = workflow.getByLabel("Project");
await expect(project.locator("option")).toHaveCount(2);
await project.selectOption("ludarium");
const previewRequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/catalog/preview");
await workflow.getByRole("button", { name: "Validate and preview" }).click();
await previewRequest;
try {
await project.selectOption("forgeflow");
await expect(workflow.locator(".diff-box")).toHaveCount(0);
releasePreview();
await expect(workflow.getByText("Project A private preview")).toHaveCount(0);
await expect(workflow.getByRole("button", { name: "Apply approved catalog" })).toHaveCount(0);
expect(previewProjects).toEqual(["ludarium"]);
} finally {
releasePreview();
}
});
test("a delayed project detail response is ignored after navigating to another project", async ({ page }) => {
let releaseProjectA!: () => void;
const projectGate = new Promise<void>((resolve) => { releaseProjectA = resolve; });
await page.route("**/api/projects/a", async (route) => {
await projectGate;
await route.fulfill({ json: projectDetailFixture("a") });
});
await page.route("**/api/projects/b", (route) => route.fulfill({ json: projectDetailFixture("b") }));
const projectARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/projects/a");
await page.goto("/projects/a");
await projectARequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/projects/b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
try {
await expect(page.getByRole("heading", { name: "Project B", exact: true })).toBeVisible();
releaseProjectA();
await expect(page.getByRole("heading", { name: "Project A", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/projects\/b$/);
} finally {
releaseProjectA();
}
});
test("a delayed connector detail response is ignored after navigating to another connector", async ({ page }) => {
let releaseConnectorA!: () => void;
const connectorGate = new Promise<void>((resolve) => { releaseConnectorA = resolve; });
await page.route("**/api/connectors/a", async (route) => {
await connectorGate;
await route.fulfill({ json: connectorDetailFixture("a") });
});
await page.route("**/api/connectors/b", (route) => route.fulfill({ json: connectorDetailFixture("b") }));
const connectorARequest = page.waitForRequest((request) => new URL(request.url()).pathname === "/api/connectors/a");
await page.goto("/connectors/a");
await connectorARequest;
await page.evaluate(() => {
window.history.pushState({}, "", "/connectors/b");
window.dispatchEvent(new PopStateEvent("popstate"));
});
try {
await expect(page.getByRole("heading", { name: "Connector B", exact: true })).toBeVisible();
releaseConnectorA();
await expect(page.getByRole("heading", { name: "Connector A", exact: true })).toHaveCount(0);
await expect(page).toHaveURL(/\/connectors\/b$/);
} finally {
releaseConnectorA();
}
});
test("typed tool arguments reach the readiness API and recent runs retain metadata only", async ({ page }) => {
const capturedRequests: Array<Record<string, unknown>> = [];
await page.route(/\/api\/tools(?:\?.*)?$/, (route) => route.fulfill({ json: {
kind: "tools",
generatedAt: "2026-10-01T10:00:00.000Z",
rows: [{ id: "typed-tool", name: "Typed Tool", status: "healthy", summary: "Typed schema fixture", project: "ludarium", environment: "prod", risk: "R1", canonicalName: "test.typed" }],
page: { total: 1 },
} }));
await page.route("**/api/tool-definitions/test.typed", (route) => route.fulfill({ json: {
canonicalName: "test.typed",
description: "Typed schema fixture",
readOnly: true,
riskClass: "R1",
inputSchema: { type: "object", required: ["count", "enabled", "mode", "tags", "filters"], properties: {
count: { type: "integer", title: "Count", minimum: 1, maximum: 10 },
enabled: { type: "boolean", title: "Enabled" },
mode: { type: "string", title: "Mode", enum: ["safe", "full"] },
tags: { type: "array", title: "Tags" },
filters: { type: "object", title: "Filters" },
} },
limits: { timeoutMs: 1000, maxOutputBytes: 2048, maxItems: 10 },
} }));
await page.route("**/api/tools/test", async (route) => {
capturedRequests.push(route.request().postDataJSON() as Record<string, unknown>);
await route.fulfill({ json: {
ready: true,
tool: "test.typed",
project: "ludarium",
environment: "prod",
note: "Readiness only; no MCP tool invocation was performed.",
checks: {
catalog: { valid: true, readOnly: true, connectorType: "fixture" },
input: { valid: true, errors: [] },
binding: { valid: true, detail: "Fixture binding" },
policy: { allowed: true, decision: "allow", reasonCode: "FIXTURE_ALLOWED", traceId: "ready-trace" },
connector: { reachable: true, status: "healthy", detail: "Fixture route" },
},
} });
});
await page.route("**/api/tools/invoke", (route) => route.fulfill({ json: {
ok: false,
traceId: "trace-typed-001",
tool: "test.typed",
content: [{ type: "text", text: "controlled fixture result" }],
error: { code: "controlled", message: "The fixture returned a controlled error." },
} }));
await page.goto("/tools");
await page.getByRole("button", { name: "Readiness" }).click();
const dialog = page.getByRole("dialog");
await expect(dialog.getByRole("button", { name: "Run readiness check" })).toBeDisabled();
await dialog.getByLabel("Project").selectOption("ludarium");
await dialog.getByLabel("Environment").selectOption("prod");
await dialog.getByLabel("Count").fill("4");
await dialog.getByLabel("Enabled").selectOption("true");
await dialog.getByLabel("Mode").selectOption({ label: "full" });
await dialog.getByLabel("Tags").fill('["never-store-tags"]');
await dialog.getByLabel("Filters").fill('{"author":"never-store-filter"}');
await dialog.getByRole("button", { name: "Run readiness check" }).click();
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
expect(capturedRequests[0]).toMatchObject({
project: "ludarium",
environment: "prod",
tool: "test.typed",
arguments: { count: 4, enabled: true, mode: "full", tags: ["never-store-tags"], filters: { author: "never-store-filter" } },
});
await dialog.getByLabel("Count").fill("5");
await expect(dialog.getByText("Ready", { exact: true })).toHaveCount(0);
await expect(dialog.getByRole("button", { name: "Run tool" })).toBeDisabled();
await dialog.getByRole("button", { name: "Run readiness check" }).click();
await expect(dialog.getByText("Ready", { exact: true })).toBeVisible();
await dialog.getByRole("button", { name: "Run tool" }).click();
await expect(dialog.getByRole("heading", { name: "Controlled error" })).toBeVisible();
const storedHistory = await page.evaluate(() => sessionStorage.getItem("itworx-tool-history-v1"));
expect(storedHistory).toContain("trace-typed-001");
expect(storedHistory).not.toContain("never-store-tags");
expect(storedHistory).not.toContain("never-store-filter");
expect(storedHistory).not.toContain("controlled fixture result");
await dialog.getByRole("button", { name: "Close dialog" }).click();
const recentRuns = page.getByRole("region", { name: "Recent Tool Runs" });
await expect(recentRuns.getByText("Controlled error")).toBeVisible();
await expect(recentRuns.getByText("trace-typed-001")).toBeVisible();
await recentRuns.getByRole("button", { name: "Open tool context" }).click();
await expect(page).toHaveURL(/\/tools\?reopen=test\.typed&project=ludarium&environment=prod/);
await expect(page.getByRole("dialog", { name: /Tool console/ })).toBeVisible();
await expect(page.getByRole("dialog").getByLabel("Project")).toHaveValue("ludarium");
await expect(page.getByRole("dialog").getByLabel("Environment")).toHaveValue("prod");
});
});
+5 -5
View File
@@ -33,12 +33,12 @@ test.describe("product-quality interaction and composition", () => {
const dialog = page.getByRole("dialog", { name: "Quick jump" });
await expect(dialog).toBeVisible();
const search = dialog.getByLabel("Find a workspace");
const search = dialog.getByRole("combobox", { name: "Find a workspace or resource" });
await expect(search).toBeFocused();
await expect(dialog.getByRole("button", { name: /Audit/ })).toHaveCount(0);
await expect(dialog.getByRole("option", { name: /Audit/ })).toHaveCount(0);
await search.fill("catalog");
await dialog.getByRole("button", { name: /Catalog/ }).click();
await dialog.getByRole("group", { name: "Build", exact: true }).getByRole("option", { name: /Catalog/ }).click();
await expect(page).toHaveURL(/\/tools$/);
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
});
@@ -48,7 +48,7 @@ test.describe("product-quality interaction and composition", () => {
await page.goto("/tools");
const filter = page.getByLabel("Filter records");
await filter.fill(" Fleet Ops ");
await expect(page).toHaveURL(/\/tools\?q=Fleet%20Ops$/);
await expect.poll(() => new URL(page.url()).searchParams.get("q")).toBe("Fleet Ops");
await expect(page.getByText("Updating results…")).toHaveCount(0);
await filter.fill(" ");
@@ -79,7 +79,7 @@ test.describe("product-quality interaction and composition", () => {
await page.setViewportSize({ width: 1440, height: 900 });
await page.goto("/tools");
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible({ timeout: 15_000 });
await expect(page.getByLabel("Filter records")).toBeVisible();
await expectNoPageOverflow(page);
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false });
+8 -6
View File
@@ -29,13 +29,13 @@ test.describe("management workflows, driven end to end from the browser", () =>
}) => {
await page.goto("/projects/ludarium?tab=Environments");
await expect(page.getByLabel("Project detail sections").getByRole("link", { name: "Environments" })).toHaveAttribute("aria-current", "page");
await expect(page.getByRole("cell", { name: "prod", exact: true })).toBeVisible();
await expect(page.getByLabel("Project environments", { exact: true }).getByRole("heading", { name: "prod", exact: true })).toBeVisible();
// ludarium never registered a staging environment (docs/known-state, and
// tests/test_wp230_ui_completion.py's equivalent backend assertion).
await expect(page.getByRole("cell", { name: "staging" })).toHaveCount(0);
await expect(page.getByLabel("Project environments", { exact: true }).getByRole("heading", { name: "staging", exact: true })).toHaveCount(0);
await page.getByLabel("Project detail sections").getByRole("link", { name: "Connectors" }).click();
await expect(page.getByRole("cell", { name: "knowledge", exact: true })).toBeVisible();
await expect(page.getByLabel("Connector bindings", { exact: true }).getByRole("heading", { name: "knowledge", exact: true })).toBeVisible();
});
test("an unregistered project is a real 404, not a fabricated default", async ({ page }) => {
@@ -63,12 +63,14 @@ test.describe("management workflows, driven end to end from the browser", () =>
await page.goto("/tools");
await page.getByRole("button", { name: "Validate and preview" }).click();
const outcome = page.getByText(/Valid preview|Validation blocked/);
await expect(outcome).toBeVisible();
await expect(outcome).toBeVisible({ timeout: 15_000 });
const applyButton = page.getByRole("button", { name: "Apply approved catalog" });
if (await outcome.filter({ hasText: "Valid preview" }).count()) {
await expect(applyButton).toBeEnabled();
await applyButton.click();
await expect(page.getByRole("dialog")).toContainText("All registered project routes may be updated");
await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
} else {
await expect(applyButton).toBeDisabled();
}
@@ -167,10 +169,10 @@ test.describe("management workflows, driven end to end from the browser", () =>
test("tool readiness validates the full registered path without invoking the tool", async ({ page }) => {
await page.goto("/tools");
await page.getByRole("button", { name: "Validate and preview" }).click();
await expect(page.getByText("Valid preview")).toBeVisible();
await expect(page.getByText("Valid preview")).toBeVisible({ timeout: 15_000 });
await page.getByRole("button", { name: "Apply approved catalog" }).click();
await page.getByRole("button", { name: "Confirm apply" }).click();
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible();
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible({ timeout: 15_000 });
await page.getByLabel("Filter records").fill("Fleet Ops Operations Summary");
const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ });
+51
View File
@@ -0,0 +1,51 @@
import { describe, expect, it } from "vitest";
import type { DashboardSnapshot } from "../src/contracts";
import { actionInbox, aggregateTrend, alertDestination } from "../src/operator-data";
const snapshot: DashboardSnapshot = {
generatedAt: "2026-10-01T10:00:00Z", staleAfterSeconds: 90,
health: { hub: "healthy", gateway: "healthy", detail: "Available" },
projects: [], connectors: [{ id: "gitea-alpha", name: "gitea", project: "alpha", state: "degraded", registrationState: "registered", readiness: { state: "unknown", detail: "Not probed", observedAt: null, source: "not-probed" }, detail: "Readiness failed" }],
denials: { count: 2, window: "24h", latestTool: "gitea.repository.get" },
drift: { count: 1, projects: ["alpha"], known: true, detail: "Catalog differs" },
activeClients: 1, backup: { state: "degraded", lastSuccessfulAt: null, detail: "No backup observed" },
version: { application: "1.0.0", schema: "1" },
};
describe("operator signals", () => {
it("routes a connector signal to its exact project binding context", () => {
const item = actionInbox(snapshot, false, false).find((signal) => signal.project === "alpha" && signal.subsystem === "gitea");
expect(item?.href).toBe("/connectors/gitea-alpha");
expect(item?.detail).toBe("Readiness failed");
expect(item?.observedAt).toBe("2026-10-01T10:00:00Z");
});
it("exposes audit navigation only to an authorized auditor", () => {
expect(actionInbox(snapshot, false, false).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/operations?section=alerts");
expect(actionInbox(snapshot, false, true).find((signal) => signal.subsystem === "Authorization")?.href).toBe("/audit?q=gitea.repository.get");
});
it("links drift and backup to their bounded resolution workspace", () => {
const signals = actionInbox(snapshot, false, true);
expect(signals.find((signal) => signal.subsystem === "Catalog")?.href).toBe("/operations?section=checks");
expect(signals.find((signal) => signal.subsystem === "Recovery")?.href).toBe("/operations?section=recovery");
expect(alertDestination("BACKUP_STALE", false)).toBe("/operations?section=recovery");
});
it("keeps unknown and stale state explicit without claiming a diagnosis", () => {
const signals = actionInbox({ ...snapshot, drift: { ...snapshot.drift, known: false } }, true, false);
expect(signals[0]?.subsystem).toBe("Freshness");
expect(signals.find((signal) => signal.subsystem === "Catalog")?.status).toBe("unknown");
});
});
describe("mobile call buckets", () => {
it("aggregates each four-hour interval without dropping outcomes", () => {
const trend = Array.from({ length: 24 }, (_, hour) => ({ hour: `2026-10-01T${String(hour).padStart(2, "0")}:00:00Z`, success: 1, denied: 2, error: 3, rate_limited: 4 }));
const buckets = aggregateTrend(trend, 4);
expect(buckets).toHaveLength(6);
expect(buckets[0]).toEqual({ hour: "2026-10-01T00:00:00Z", endHour: "2026-10-01T03:00:00Z", success: 4, denied: 8, error: 12, rate_limited: 16 });
expect(buckets[5]?.success).toBe(4);
});
it("retains partial buckets and handles empty observations", () => {
expect(aggregateTrend([], 4)).toEqual([]);
expect(aggregateTrend([{ hour: "2026-10-01T00:00:00Z", success: 2, denied: 0, error: 1, rate_limited: 0 }], 4)[0]?.error).toBe(1);
});
});
+16
View File
@@ -0,0 +1,16 @@
import { afterEach, expect, it, vi } from "vitest";
import { readPreference, writePreference } from "../src/preferences";
afterEach(() => vi.unstubAllGlobals());
it("accepts only known presentation preferences", () => {
const values = new Map<string, string>([["hub-density", "arbitrary"]]);
vi.stubGlobal("localStorage", { getItem: (key: string) => values.get(key) ?? null, setItem: (key: string, value: string) => values.set(key, value) });
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("comfortable");
writePreference("hub-density", "compact");
expect(readPreference("hub-density", ["compact", "comfortable"], "comfortable")).toBe("compact");
});
it("continues with safe defaults when storage is denied", () => {
vi.stubGlobal("localStorage", { getItem: () => { throw new Error("storage denied"); }, setItem: () => { throw new Error("storage denied"); } });
expect(readPreference("hub-theme", ["dark", "light"], "dark")).toBe("dark");
expect(() => writePreference("hub-theme", "light")).not.toThrow();
});
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from "vitest";
import { NULL_INPUT_VALUE, parseSchemaArguments } from "../src/schema-input";
import type { ToolDefinition } from "../src/contracts";
const schema = (properties: NonNullable<ToolDefinition["inputSchema"]["properties"]>, required: string[] = []): ToolDefinition["inputSchema"] => ({
type: "object",
properties,
required,
});
describe("schema-driven tool arguments", () => {
it("parses strings, integers, decimals, booleans, enums, arrays and objects by declared type", () => {
const inputSchema = schema({
query: { type: "string" },
limit: { type: "integer", minimum: 1, maximum: 20 },
score: { type: "number" },
includeClosed: { type: "boolean" },
format: { type: "string", enum: ["compact", "full"] },
labels: { type: "array" },
filters: { type: "object" },
}, ["query", "limit", "includeClosed", "format"]);
expect(parseSchemaArguments({
query: "pull request",
limit: "4",
score: "3.5",
includeClosed: "false",
format: JSON.stringify("full"),
labels: "[\"bug\",\"urgent\"]",
filters: "{\"author\":\"jens\"}",
}, inputSchema)).toEqual({
arguments: {
query: "pull request",
limit: 4,
score: 3.5,
includeClosed: false,
format: "full",
labels: ["bug", "urgent"],
filters: { author: "jens" },
},
errors: {},
valid: true,
});
});
it("reports missing required values, malformed JSON and type mismatches clearly", () => {
const inputSchema = schema({
count: { type: "integer" },
enabled: { type: "boolean" },
tags: { type: "array" },
options: { type: "object" },
}, ["count", "enabled", "tags", "options"]);
const parsed = parseSchemaArguments({ count: "2.3", enabled: "sometimes", tags: "{oops", options: "[]" }, inputSchema);
expect(parsed.valid).toBe(false);
expect(parsed.arguments).toEqual({});
expect(parsed.errors).toEqual({
count: "Enter a whole number.",
enabled: "Choose yes or no.",
tags: "Enter valid JSON for this array.",
options: "Enter a JSON object.",
});
});
it("applies string and numeric bounds and rejects enum values outside the schema", () => {
const inputSchema = schema({
query: { type: "string", minLength: 2, maxLength: 5 },
limit: { type: "integer", minimum: 1, maximum: 8 },
mode: { type: "string", enum: ["safe", "full"] },
});
const parsed = parseSchemaArguments({ query: "x", limit: "9", mode: JSON.stringify("unsafe") }, inputSchema);
expect(parsed.valid).toBe(false);
expect(parsed.errors.query).toBe("Enter at least 2 characters.");
expect(parsed.errors.limit).toBe("Enter a number no greater than 8.");
expect(parsed.errors.mode).toBe("Choose a value listed by this tool's schema.");
});
it("omits empty optional fields and accepts explicit null only for nullable fields", () => {
const inputSchema = schema({ optional: { type: "string" }, nullable: { type: ["string", "null"] } });
expect(parseSchemaArguments({ optional: "", nullable: NULL_INPUT_VALUE }, inputSchema)).toEqual({
arguments: { nullable: null },
errors: {},
valid: true,
});
expect(parseSchemaArguments({ nullable: NULL_INPUT_VALUE }, schema({ nullable: { type: "string" } })).errors.nullable).toBe("This field does not accept null.");
});
});
+61
View File
@@ -0,0 +1,61 @@
import { describe, expect, it } from "vitest";
import { MAX_TOOL_RUNS, recordToolRun, readToolHistory, type ToolRunMetadata } from "../src/tool-history";
function createStorage(seed: string | null = null) {
let value = seed;
return {
getItem: () => value,
setItem: (_key: string, next: string) => { value = next; },
value: () => value,
};
}
const run = (overrides: Partial<ToolRunMetadata> = {}): ToolRunMetadata => ({
tool: "gitea.pullrequest.status",
project: "ludarium",
environment: "prod",
ok: true,
traceId: "trace-123",
at: "2026-09-30T10:00:00.000Z",
...overrides,
});
describe("bounded recent tool history", () => {
it("stores only safe invocation metadata and never serializes arguments or results", () => {
const storage = createStorage();
recordToolRun({ ...run(), arguments: { token: "fixture-do-not-store" }, result: "private output" } as ToolRunMetadata, storage);
expect(JSON.parse(storage.value() ?? "null")).toEqual([run()]);
expect(storage.value()).not.toContain("fixture-do-not-store");
expect(storage.value()).not.toContain("private output");
});
it("bounds history to the newest twenty valid entries", () => {
const storage = createStorage();
for (let index = 0; index < MAX_TOOL_RUNS + 4; index += 1) {
recordToolRun(run({ traceId: `trace-${index}`, at: `2026-09-30T10:${String(index).padStart(2, "0")}:00.000Z` }), storage);
}
const history = readToolHistory(storage);
expect(history).toHaveLength(MAX_TOOL_RUNS);
expect(history[0]?.traceId).toBe(`trace-${MAX_TOOL_RUNS + 3}`);
expect(history.at(-1)?.traceId).toBe("trace-4");
});
it("drops malformed and oversized stored entries without exposing extra fields", () => {
const storage = createStorage(JSON.stringify([
{ ...run(), arguments: { apiKey: "secret" }, content: "result" },
{ ...run(), traceId: "trace\nsecret" },
{ ...run(), at: "yesterday" },
null,
]));
expect(readToolHistory(storage)).toEqual([run()]);
});
it("treats corrupt or unavailable browser storage as an empty history", () => {
expect(readToolHistory(createStorage("not-json"))).toEqual([]);
expect(() => readToolHistory({ getItem: () => { throw new Error("blocked"); }, setItem: () => undefined })).not.toThrow();
expect(() => recordToolRun(run(), { getItem: () => null, setItem: () => { throw new Error("blocked"); } })).not.toThrow();
});
});
+1 -1
View File
@@ -6,7 +6,7 @@ export default defineConfig({
test: {
environment: "node",
include: ["tests/**/*.test.ts"],
coverage: { reporter: ["text", "json-summary"] },
coverage: { include: ["src/**/*.{ts,tsx}"], reporter: ["text", "json-summary"] },
},
});