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