Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020

This commit is contained in:
Public Source Publisher committed 2026-10-01 01:36:21 +02:00
commit cb5be898ea
663 files changed
+82674

No files matched your search

+41
View File
@@ -0,0 +1,41 @@
import type { CatalogPreview, ClientActionResult, ClientOnboardingResult, DashboardSnapshot, ManagementApiClient, ManagementRole, OnboardingClientKind, OperationsCheck, OperationsSummary, PageQuery, PolicySimulation, ProjectDetailInfo, ResourceKind, ResourcePage, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "./contracts";
export class ApiError extends Error {
readonly status: number;
constructor(status: number) { super(status === 401 ? "Your management session has expired." : "The control API is unavailable."); this.name = "ApiError"; this.status = status; }
}
async function postJson<T>(path: string, body: unknown, csrfToken: string | null): Promise<T> { if (!csrfToken) throw new ApiError(403); const response = await fetch(path, { method: "POST", credentials: "same-origin", headers: { Accept: "application/json", "Content-Type": "application/json", "X-CSRF-Token": csrfToken }, body: JSON.stringify(body) }); if (!response.ok) throw new ApiError(response.status); return (await response.json()) as T; }
async function getJson<T>(path: string): Promise<T> {
const response = await fetch(path, { credentials: "same-origin", headers: { Accept: "application/json" } });
if (!response.ok) throw new ApiError(response.status);
return (await response.json()) as T;
}
export async function logoutSession(): Promise<void> {
const session = await getJson<{ csrf_token?: string }>("/auth/session");
const csrfToken = session.csrf_token;
if (!csrfToken) throw new ApiError(403);
const response = await fetch("/auth/logout", {
method: "POST",
credentials: "same-origin",
headers: { Accept: "application/json", "X-CSRF-Token": csrfToken },
});
if (!response.ok) throw new ApiError(response.status);
}
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()}`); },
projectDetail: (slug) => getJson<ProjectDetailInfo>(`/api/projects/${encodeURIComponent(slug)}`),
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)}`),
invokeTool: (input) => postJson<ToolInvocationResult>("/api/tools/invoke", input, csrfToken),
operationsSummary: () => getJson<OperationsSummary>("/api/operations/summary"),
runOperationsCheck: () => postJson<OperationsCheck>("/api/operations/check", {}, csrfToken),
previewCatalog: (input) => postJson<CatalogPreview>("/api/catalog/preview", input, csrfToken),
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`),
}; }
export function safeApiMessage(error: unknown): string { return error instanceof ApiError ? error.message : "The control API returned no usable data."; }
+188
View File
@@ -0,0 +1,188 @@
import { useEffect, useMemo, useRef, useState } from "react";
import type { 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 { Brand } from "./components/Brand";
import { CommandMenu } from "./components/CommandMenu";
import { ErrorPanel } from "./components/ErrorPanel";
import { Icon } from "./components/Icon";
import { LoadingPanel } from "./components/LoadingPanel";
import { SessionMenu } from "./components/SessionMenu";
import { navigation } from "./navigation";
import { AuthGateway } from "./pages/AuthGateway";
import { Dashboard } from "./pages/Dashboard";
import { ForbiddenPanel } from "./pages/ForbiddenPanel";
import { Operations } from "./pages/Operations";
import { ProjectDetail } from "./pages/ProjectDetail";
import { ResourcePage } from "./pages/ResourcePage";
import { SettingsPage } from "./pages/SettingsPage";
export function App({ api: apiProp }: { api?: ManagementApiClient }) {
const [api] = useState<ManagementApiClient>(() => apiProp ?? createApiClient());
const [path, navigate] = usePath();
const [session, setSession] = useState<SessionStatus>("checking");
const [snapshot, setSnapshot] = useState<DashboardSnapshot | null>(null);
const [loadState, setLoadState] = useState<"idle" | "loading" | "error">("loading");
const [error, setError] = useState("The control API is unavailable.");
const [roles, setRoles] = useState<ManagementRole[]>([]);
const [subject, setSubject] = useState<string | null>(null);
const [navOpen, setNavOpen] = useState(false);
const [commandOpen, setCommandOpen] = useState(false);
const navTriggerRef = useRef<HTMLButtonElement>(null);
const title = useMemo(() => routeTitle(basePath(path)), [path]);
const clearIdentity = () => {
setRoles([]);
setSnapshot(null);
setSubject(null);
};
const closeNavigation = (restoreFocus = true) => {
setNavOpen(false);
if (restoreFocus) window.setTimeout(() => navTriggerRef.current?.focus(), 0);
};
const load = async () => {
setSession("checking");
setLoadState("loading");
let current;
try {
current = await api.session();
} catch (caught) {
if (caught instanceof ApiError && caught.status === 401) {
clearIdentity();
setSession("unauthenticated");
setLoadState("idle");
return;
}
clearIdentity();
setError(safeApiMessage(caught));
setLoadState("error");
return;
}
if (!current.authenticated) {
clearIdentity();
setSession("unauthenticated");
setLoadState("idle");
return;
}
setSession("authenticated");
setRoles(current.session?.roles ?? []);
setSubject(current.session?.subject ?? null);
try {
setSnapshot(await api.dashboard());
setLoadState("idle");
} catch (caught) {
if (caught instanceof ApiError && caught.status === 401) {
clearIdentity();
setSession("expired");
setError(caught.message);
} else {
setError(safeApiMessage(caught));
}
setLoadState("error");
}
};
useEffect(() => { void load(); }, []);
useEffect(() => { setNavOpen(false); }, [path]);
useEffect(() => {
if (!navOpen) return;
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === "Escape") closeNavigation();
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [navOpen]);
useEffect(() => {
if (session !== "authenticated") return;
const onKeyDown = (event: KeyboardEvent) => {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
setCommandOpen((current) => {
if (!current && document.querySelector('[role="dialog"]')) return current;
return !current;
});
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [session]);
const kind = resourceKind(path);
const detail = basePath(path).startsWith("/projects/");
const canReadAudit = can(roles, "audit.read");
const active = (href: string) => basePath(path) === href || (href !== "/" && basePath(path).startsWith(`${href}/`));
const authenticated = session === "authenticated";
const content = session === "checking" || loadState === "loading"
? <LoadingPanel />
: session === "unauthenticated" && loadState === "idle"
? <AuthGateway />
: 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 />;
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">
<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>
</div>
<nav aria-label="Primary navigation">
{navigation.map((group) => {
const items = group.items.filter((item) => !item.auditOnly || canReadAudit);
return items.length ? <section className="nav-group" aria-label={group.label} key={group.label}>
<p>{group.label}</p>
{items.map((item) => <a
aria-current={active(item.href) ? "page" : undefined}
className={active(item.href) ? "nav-link active" : "nav-link"}
href={item.href}
key={item.href}
onClick={(event) => { event.preventDefault(); navigate(item.href); }}
><Icon name={item.icon} size={17} /><span>{item.label}</span></a>)}
</section> : null;
})}
</nav>
<div className="sidebar-foot">
<span><span className="secure-dot" aria-hidden="true" />Policy boundary active</span>
<small>Same-origin · server-managed</small>
</div>
</aside>
</> : 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>
<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">
<Icon name="search" size={16} /><span>Quick jump</span><kbd>Ctrl K</kbd>
</button>
<SessionMenu subject={subject} roles={roles} />
</div>
</header> : null}
{content}
</main>
</div>
{authenticated ? <CommandMenu open={commandOpen} canReadAudit={canReadAudit} navigate={navigate} onClose={() => setCommandOpen(false)} /> : null}
</RbacProvider>
);
}
+5
View File
@@ -0,0 +1,5 @@
export const workspaceName = "ITWorx MCP Hub" as const;
export function bootstrapMessage(): string {
return `${workspaceName} web workspace is ready for WP-120.`;
}
+14
View File
@@ -0,0 +1,14 @@
export function Brand({ compact = false }: { compact?: boolean }) {
return (
<span className={compact ? "brand preauth-brand" : "brand"}>
<span className="brand-mark" aria-hidden="true">
<svg viewBox="0 0 24 24" width="18" height="18" fill="none">
<path d="M5.8 9.5 12 5.9l6.2 3.6v7L12 20.1l-6.2-3.6v-7Z" stroke="currentColor" strokeWidth="1.5" />
<circle cx="12" cy="13" r="2.7" fill="currentColor" />
<path d="M12 6v4.2M6 9.7l3.7 2.1M18 9.7l-3.7 2.1" stroke="currentColor" strokeWidth="1.4" />
</svg>
</span>
<span><strong>ITWorx</strong><small>MCP Hub</small></span>
</span>
);
}
+4
View File
@@ -0,0 +1,4 @@
import { routeTitle } from "../contracts";
import { basePath } from "../routing";
export function Breadcrumbs({ path, navigate }: { path: string; navigate: (next: string) => void }) { return <nav className="breadcrumbs" aria-label="Breadcrumb"><a href="/" onClick={(event) => { event.preventDefault(); navigate("/"); }}>Overview</a><span aria-hidden="true">/</span><strong>{routeTitle(basePath(path))}</strong></nav>; }
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { availableNavigation } from "../navigation";
import { Icon } from "./Icon";
import { Modal } from "./Modal";
export function CommandMenu({
open,
canReadAudit,
navigate,
onClose,
}: {
open: boolean;
canReadAudit: boolean;
navigate: (next: string) => void;
onClose: () => void;
}) {
const [query, setQuery] = useState("");
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]);
useEffect(() => {
if (!open) return;
setQuery("");
const focusTimer = window.setTimeout(() => inputRef.current?.focus(), 0);
return () => window.clearTimeout(focusTimer);
}, [open]);
const select = (href: string) => {
onClose();
navigate(href);
};
return (
<Modal open={open} title="Quick jump" onClose={onClose} className="command-modal">
<label className="command-search">
<span className="sr-only">Find a workspace</span>
<Icon name="search" size={17} />
<input
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
placeholder="Find projects, catalog, policies…"
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>
<p className="command-hint">Navigate without leaving the keyboard. Access-controlled destinations remain hidden.</p>
</Modal>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { Fragment, useState } from "react";
import type { ResourceKind, ResourcePage, ResourceRecord } from "../contracts";
import { Modal } from "./Modal";
import { StatusPill } from "./StatusPill";
export type Density = "comfortable" | "compact";
export type ColumnKey = "name" | "status" | "project" | "summary" | "risk" | "decision" | "actions";
const ALL_COLUMNS: Record<ResourceKind, ColumnKey[]> = {
projects: ["name", "status", "project", "summary"],
servers: ["name", "status", "project", "summary"],
tools: ["name", "status", "project", "summary", "risk", "actions"],
connectors: ["name", "status", "project", "summary"],
clients: ["name", "status", "project", "summary", "actions"],
policies: ["name", "status", "project", "summary"],
audit: ["name", "status", "project", "summary", "decision"],
};
export function columnsFor(kind: ResourceKind): ColumnKey[] { return ALL_COLUMNS[kind]; }
export function DataTable({
data, kind, density, visibleColumns, onClientAction, onConfig, onTest,
}: {
data: ResourcePage;
kind: ResourceKind;
density: Density;
visibleColumns: ColumnKey[];
onClientAction: (id: string, action: "disable" | "revoke") => void;
onConfig: (id: string) => void;
onTest: (row: ResourceRecord) => void;
}) {
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 === "tools" && show("actions")) return <div className="inline-actions"><button className="button button-small button-secondary" onClick={() => onTest(row)}>Readiness</button></div>;
return null;
};
return (
<Fragment>
<div className={`table-wrap desktop-data-table table-kind-${kind} density-${density}`} role="region" tabIndex={0} aria-label={`${kind} records table`}>
<table>
<caption className="sr-only">{kind} records</caption>
<thead>
<tr>
{show("name") && <th>Name</th>}
{show("status") && <th>Status</th>}
{show("project") && <th>Project</th>}
{show("summary") && <th>Summary</th>}
{kind === "tools" && show("risk") && <th>Risk</th>}
{kind === "audit" && show("decision") && <th>Decision / trace</th>}
{(kind === "clients" || kind === "tools") && show("actions") && <th>Actions</th>}
</tr>
</thead>
<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("status") && <td><StatusPill value={row.status} /></td>}
{show("project") && <td>{row.project ?? "—"}</td>}
{show("summary") && <td>{row.summary}</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>}
</tr>
))}
</tbody>
</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>)}
</div>
<Modal
open={confirmingAction !== null}
title={`${confirmingAction?.action === "disable" ? "Disable" : "Revoke"} ${confirmingAction?.name}?`}
onClose={() => setConfirmingAction(null)}
>
<p>This action is logged and takes effect immediately.</p>
<div className="state-actions">
<button
className="button"
data-testid="confirm-action"
onClick={() => {
if (!confirmingAction) return;
onClientAction(confirmingAction.id, confirmingAction.action);
setConfirmingAction(null);
}}
>
Confirm
</button>
<button className="button button-secondary" onClick={() => setConfirmingAction(null)}>Cancel</button>
</div>
</Modal>
</Fragment>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Icon } from "./Icon";
export function ErrorPanel({ message, expired, retry }: { message: string; expired?: boolean; retry: () => void }) {
return <section className="panel state-panel error-state" role="alert">
<div className="state-icon" aria-hidden="true">!</div>
<p className="eyebrow">Recoverable interruption</p>
<h2>{expired ? "Session expired" : "Management data unavailable"}</h2>
<p>{message}</p>
<div className="state-actions"><button className="button" onClick={retry}><Icon name="refresh" size={14} />{expired ? "Check session again" : "Retry connection"}</button>{expired ? <a className="button button-secondary" href="/auth/login">Re-authenticate</a> : null}</div>
<small className="state-footnote">No cached management data is shown while the control-plane response is untrusted.</small>
</section>;
}
+49
View File
@@ -0,0 +1,49 @@
import type { ReactNode } from "react";
export type IconName =
| "overview"
| "projects"
| "servers"
| "catalog"
| "connectors"
| "clients"
| "policies"
| "audit"
| "operations"
| "settings"
| "search"
| "refresh"
| "arrow";
const paths: Record<IconName, ReactNode> = {
overview: <><path d="M4 13h6V4H4v9Zm0 7h6v-4H4v4Zm10 0h6v-9h-6v9Zm0-16v4h6V4h-6Z" /></>,
projects: <><path d="M3.5 7.5h6l1.8 2H20.5v8.8a1.7 1.7 0 0 1-1.7 1.7H5.2a1.7 1.7 0 0 1-1.7-1.7V7.5Z" /><path d="M5.5 7.5V5.7A1.7 1.7 0 0 1 7.2 4h4.2l1.7 2h4.7a1.7 1.7 0 0 1 1.7 1.7v1.8" /></>,
servers: <><rect x="4" y="4" width="16" height="6" rx="1.5" /><rect x="4" y="14" width="16" height="6" rx="1.5" /><path d="M8 7h.01M8 17h.01M12 7h5M12 17h5" /></>,
catalog: <><path d="M4 5.5A1.5 1.5 0 0 1 5.5 4H10v16H5.5A1.5 1.5 0 0 1 4 18.5v-13ZM14 4h4.5A1.5 1.5 0 0 1 20 5.5v13a1.5 1.5 0 0 1-1.5 1.5H14V4Z" /><path d="M7 8h1M16 8h1" /></>,
connectors: <><circle cx="6" cy="12" r="2.5" /><circle cx="18" cy="6" r="2.5" /><circle cx="18" cy="18" r="2.5" /><path d="m8.2 10.9 7.6-3.8M8.2 13.1l7.6 3.8" /></>,
clients: <><circle cx="9" cy="8" r="3" /><path d="M3.8 19c.5-3.4 2.2-5 5.2-5s4.7 1.6 5.2 5M16 10h4M18 8v4" /></>,
policies: <><path d="M12 3.5 19 6v5.2c0 4.4-2.3 7.4-7 9.3-4.7-1.9-7-4.9-7-9.3V6l7-2.5Z" /><path d="m8.5 12 2.2 2.2 4.8-5" /></>,
audit: <><path d="M6 3.5h9L19 7v13.5H6V3.5Z" /><path d="M15 3.5V7h4M9 11h6M9 15h6" /><circle cx="6" cy="18" r="2.5" /></>,
operations: <><path d="M4 17.5h3l2.2-5 3.1 7 2.5-11 2 6H20" /></>,
settings: <><circle cx="12" cy="12" r="3" /><path d="M19.4 15a1.7 1.7 0 0 0 .3 1.9l.1.1-2.8 2.8-.1-.1a1.7 1.7 0 0 0-1.9-.3 1.7 1.7 0 0 0-1 1.6v.2h-4v-.2a1.7 1.7 0 0 0-1-1.6 1.7 1.7 0 0 0-1.9.3l-.1.1L4.2 17l.1-.1a1.7 1.7 0 0 0 .3-1.9A1.7 1.7 0 0 0 3 14H2.8v-4H3a1.7 1.7 0 0 0 1.6-1 1.7 1.7 0 0 0-.3-1.9L4.2 7 7 4.2l.1.1a1.7 1.7 0 0 0 1.9.3 1.7 1.7 0 0 0 1-1.6v-.2h4V3a1.7 1.7 0 0 0 1 1.6 1.7 1.7 0 0 0 1.9-.3l.1-.1L19.8 7l-.1.1a1.7 1.7 0 0 0-.3 1.9 1.7 1.7 0 0 0 1.6 1h.2v4H21a1.7 1.7 0 0 0-1.6 1Z" /></>,
search: <><circle cx="10.5" cy="10.5" r="6" /><path d="m15 15 5 5" /></>,
refresh: <><path d="M20 6v5h-5M4 18v-5h5" /><path d="M18.2 9A7 7 0 0 0 6.1 6.2L4 11M5.8 15A7 7 0 0 0 17.9 17.8L20 13" /></>,
arrow: <><path d="M5 12h14M14 7l5 5-5 5" /></>,
};
export function Icon({ name, size = 18 }: { name: IconName; size?: number }) {
return (
<svg
aria-hidden="true"
className="icon"
fill="none"
height={size}
viewBox="0 0 24 24"
width={size}
>
<g stroke="currentColor" strokeLinecap="round" strokeLinejoin="round" strokeWidth="1.6">
{paths[name]}
</g>
</svg>
);
}
+7
View File
@@ -0,0 +1,7 @@
export function LoadingPanel() {
return <section className="panel state-panel loading-state" aria-live="polite" aria-busy="true">
<div className="state-orbit" aria-hidden="true"><span /><span /><span /></div>
<div><p className="eyebrow">Synchronizing control plane</p><h2>Loading operational data</h2><p>Checking the authenticated session and refreshing bounded management views.</p></div>
<div className="loading-skeleton" aria-hidden="true"><span /><span /><span /></div>
</section>;
}
+135
View File
@@ -0,0 +1,135 @@
import type { Health } from "../contracts";
type TopologyProps = {
hub?: Health;
gateway?: Health;
connectors?: Array<{ name: string; state: Health }>;
compact?: boolean;
};
const labels: Record<Health, string> = {
healthy: "healthy",
degraded: "degraded",
unavailable: "unavailable",
unknown: "unknown",
};
function shortConnectorName(name: string): string {
const normalized = name.toLowerCase();
if (normalized.includes("gitea")) return "Gitea";
if (normalized.includes("unraid")) return "Unraid";
if (normalized.includes("knowledge")) return "Knowledge";
if (normalized.includes("mobility")) return "Mobility";
return name.length > 13 ? `${name.slice(0, 12)}…` : name;
}
export function McpTopology({ hub = "healthy", gateway = "healthy", connectors = [], compact = false }: TopologyProps) {
const targets = connectors.length
? connectors.slice(0, 3).map((connector) => ({ label: shortConnectorName(connector.name), state: connector.state }))
: [
{ label: "Gitea", state: "healthy" as const },
{ label: "Knowledge", state: "healthy" as const },
{ label: "Unraid", state: "healthy" as const },
];
const hiddenTargets = Math.max(0, connectors.length - targets.length);
const description = `Client requests pass through a ${labels[hub]} policy boundary and a ${labels[gateway]} MCP gateway to ${connectors.length || 3} scoped connector${(connectors.length || 3) === 1 ? "" : "s"}.`;
return (
<figure className={compact ? "topology topology-compact" : "topology"} aria-label="Governed MCP request path">
<div className="topology-head">
<span><span className={`signal-dot signal-${hub}`} aria-hidden="true" />Governed request path</span>
<small>{connectors.length ? `${connectors.length} live routes` : "Deny by default"}</small>
</div>
<div className="topology-canvas" aria-hidden="true">
<svg viewBox="0 0 620 244" preserveAspectRatio="xMidYMid meet">
<defs>
<linearGradient id="flow-gradient" x1="0" x2="1">
<stop offset="0" stopColor="currentColor" stopOpacity="0.18" />
<stop offset="0.5" stopColor="currentColor" stopOpacity="0.9" />
<stop offset="1" stopColor="currentColor" stopOpacity="0.18" />
</linearGradient>
</defs>
<g className="topology-links">
<path d="M118 122H202" />
<path d="M318 122H390" />
<path d="M494 122H524V48H542" />
<path d="M494 122H542" />
<path d="M494 122H524V196H542" />
</g>
<g className="topology-flow">
<path d="M118 122H202" />
<path d="M318 122H390" />
<path d="M494 122H524V48H542" />
<path d="M494 122H542" />
<path d="M494 122H524V196H542" />
</g>
<g className="topology-node topology-node-client">
<rect x="20" y="92" width="98" height="60" rx="10" />
<circle cx="45" cy="115" r="8" />
<path d="M34 140c2-8 6-11 11-11s9 3 11 11" />
<text x="67" y="116">Client</text>
<text className="topology-sub" x="67" y="135">Codex / MCP</text>
</g>
<g className={`topology-node topology-node-${hub}`}>
<rect x="202" y="84" width="116" height="76" rx="10" />
<path d="M228 105.5 240 110v9c0 8-4 13-12 17-8-4-12-9-12-17v-9l12-4.5Z" />
<path d="m222 120 4 4 8-9" />
<text x="250" y="112">Policy gate</text>
<text className="topology-sub" x="250" y="132">Scope + identity</text>
<circle className="topology-state" cx="300" cy="99" r="4" />
</g>
<g className={`topology-node topology-node-${gateway}`}>
<rect x="390" y="84" width="104" height="76" rx="10" />
<circle cx="416" cy="122" r="13" />
<circle cx="416" cy="122" r="4" />
<text x="438" y="116">Gateway</text>
<text className="topology-sub" x="438" y="136">ContextForge</text>
<circle className="topology-state" cx="476" cy="99" r="4" />
</g>
{targets.map((target, index) => {
const y = [28, 102, 176][index] ?? 102;
return (
<g className={`topology-target topology-node-${target.state}`} key={`${target.label}-${index}`}>
<rect x="542" y={y} width="70" height="40" rx="8" />
<circle className="topology-state" cx="555" cy={y + 20} r="4" />
<text x="565" y={y + 24}>{target.label}</text>
</g>
);
})}
</svg>
</div>
<div className="topology-mobile-flow" aria-hidden="true">
<div className="topology-mobile-node">
<span className="topology-mobile-mark">C</span>
<span><strong>Client</strong><small>Codex / MCP</small></span>
</div>
<span className="topology-mobile-link" />
<div className={`topology-mobile-node topology-node-${hub}`}>
<span className="topology-mobile-mark">P</span>
<span><strong>Policy gate</strong><small>Scope + identity</small></span>
<i className="topology-state" />
</div>
<span className="topology-mobile-link" />
<div className={`topology-mobile-node topology-node-${gateway}`}>
<span className="topology-mobile-mark">G</span>
<span><strong>Gateway</strong><small>ContextForge</small></span>
<i className="topology-state" />
</div>
<span className="topology-mobile-link" />
<div className="topology-mobile-targets">
{targets.map((target, index) => <span className={`topology-node-${target.state}`} key={`${target.label}-mobile-${index}`}><i className="topology-state" />{target.label}</span>)}
{hiddenTargets > 0 ? <span>+{hiddenTargets} routes</span> : null}
</div>
</div>
<figcaption>
<span>{description}</span>
{hiddenTargets > 0 ? <small>+{hiddenTargets} additional scoped route{hiddenTargets === 1 ? "" : "s"}</small> : null}
</figcaption>
</figure>
);
}
+4
View File
@@ -0,0 +1,4 @@
import type { Health } from "../contracts";
import { StatusPill } from "./StatusPill";
export function MetricCard({ label, value, detail, status }: { label: string; value: string; detail: string; status?: Health }) { return <article className="metric-card"><div className="metric-heading"><span>{label}</span>{status && <StatusPill value={status} />}</div><strong>{value}</strong><p>{detail}</p></article>; }
+77
View File
@@ -0,0 +1,77 @@
import { useEffect, useId, useRef, type ReactNode } from "react";
export function Modal({ open, title, onClose, children, className = "" }: { open: boolean; title: string; onClose: () => void; children: ReactNode; className?: string }) {
const dialogRef = useRef<HTMLDivElement>(null);
const onCloseRef = useRef(onClose);
const titleId = useId();
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]);
useEffect(() => {
if (!open) return;
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();
}
};
document.addEventListener("keydown", onKeyDown);
return () => document.removeEventListener("keydown", onKeyDown);
}, [open]);
if (!open) return null;
return (
<div className="modal-overlay" onClick={onClose}>
<div
className={`modal ${className}`.trim()}
role="dialog"
aria-modal="true"
aria-labelledby={titleId}
tabIndex={-1}
ref={dialogRef}
onClick={(event) => event.stopPropagation()}
>
<div className="modal-heading">
<h2 id={titleId}>{title}</h2>
<button className="modal-close" onClick={onClose} aria-label="Close dialog">×</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>
);
}
+21
View File
@@ -0,0 +1,21 @@
import { useState } from "react";
import { logoutSession, safeApiMessage } from "../api";
import type { ManagementRole } from "../contracts";
export function SessionMenu({ subject, roles }: { subject: string | null; roles: ManagementRole[] }) {
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const initial = subject?.trim().charAt(0).toUpperCase() || "•";
const signOut = async () => {
setBusy(true);
setError("");
try {
await logoutSession();
window.location.assign("/");
} catch (caught) {
setError(safeApiMessage(caught));
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>;
}
+4
View File
@@ -0,0 +1,4 @@
import type { Health } from "../contracts";
import { healthLabel } from "../format";
export function StatusPill({ value }: { value: Health }) { return <span className={`status status-${value}`} aria-label={`Status: ${healthLabel(value)}`}><span aria-hidden="true" />{healthLabel(value)}</span>; }
+73
View File
@@ -0,0 +1,73 @@
export type Health = "healthy" | "degraded" | "unavailable" | "unknown";
export type SessionStatus = "checking" | "authenticated" | "unauthenticated" | "expired";
export type ManagementRole = "viewer" | "auditor" | "operator" | "admin";
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 DashboardSnapshot {
generatedAt: string; staleAfterSeconds: number;
health: { hub: Health; gateway: Health; detail: string };
projects: ProjectStatus[]; connectors: ConnectorStatus[];
denials: { count: number; window: string; latestTool?: string };
drift: { count: number; projects: string[]; known: boolean; detail: string }; activeClients: number;
backup: { state: Health; lastSuccessfulAt: string | null; detail: string; restoreVerified?: boolean; artifactCount?: number };
version: { application: string; schema: string };
}
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 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 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; }
export interface PolicySimulation { decision: PolicyDecision; allowed: boolean; matchedPolicies: string[]; reasons: string[]; reasonCode: string; traceId: string; requiredScopes: string[]; steps: PolicyTraceStep[]; }
export interface ToolReadinessResult {
ready: boolean; tool: string; project: string; environment: string; note: string;
checks: {
catalog: { valid: boolean; readOnly: boolean; connectorType: string };
input: { valid: boolean; errors: string[] };
binding: { valid: boolean; detail: string };
policy: { allowed: boolean; decision: PolicyDecision; reasonCode: string; traceId: string };
connector: { reachable: boolean; status: string; detail: string };
};
}
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 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 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 ManagementApiClient extends ApiClient {
resource(kind: ResourceKind, query: PageQuery): Promise<ResourcePage>;
projectDetail(slug: string): Promise<ProjectDetailInfo>;
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>;
invokeTool(input: { project: string; environment: string; tool: string; arguments: Record<string, unknown> }): Promise<ToolInvocationResult>;
operationsSummary(): Promise<OperationsSummary>;
runOperationsCheck(): Promise<OperationsCheck>;
previewCatalog(input: { project: string }): Promise<CatalogPreview>;
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[] }>;
}
export function isSnapshotStale(snapshot: DashboardSnapshot, now = Date.now()): boolean {
const generated = Date.parse(snapshot.generatedAt);
return !Number.isFinite(generated) || now - generated > snapshot.staleAfterSeconds * 1000;
}
export function routeTitle(pathname: string): string {
const route = pathname.replace(/\/$/, "") || "/";
if (route === "/") return "Overview";
if (route.startsWith("/projects/")) return "Project 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";
}
+8
View File
@@ -0,0 +1,8 @@
export const BRUSSELS_TIME_ZONE = "Europe/Brussels";
export function formatBrusselsTime(value: string | null): string {
if (!value) return "Never recorded";
const date = new Date(value);
if (Number.isNaN(date.getTime())) return "Unknown time";
return new Intl.DateTimeFormat("en-GB", { timeZone: BRUSSELS_TIME_ZONE, dateStyle: "medium", timeStyle: "short" }).format(date);
}
export function healthLabel(value: string): string { return value.replace(/(^|-)([a-z])/g, (_, separator: string, letter: string) => `${separator}${letter.toUpperCase()}`); }
+19
View File
@@ -0,0 +1,19 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient } from "./contracts";
/** Real registered projects, fetched once per mount -- replaces every
* hardcoded "ludarium"/"prod" default that used to appear regardless of
* what was actually registered in the catalog. */
export function useProjectOptions(api: ManagementApiClient) {
const [projects, setProjects] = useState<{ id: string; name: string }[]>([]);
useEffect(() => { let active = true; void api.resource("projects", { filter: "", limit: 100 }).then((page) => { if (active) setProjects(page.rows.map((row) => ({ id: row.id, name: row.name }))); }).catch(() => { if (active) setProjects([]); }); return () => { active = false; }; }, [api]);
return projects;
}
/** The registered environments for whichever project is currently selected
* -- re-fetched every time `project` changes, empty while nothing is
* 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]);
return environments;
}
+12
View File
@@ -0,0 +1,12 @@
import { createRoot } from "react-dom/client";
import { createElement } from "react";
import { App } from "./app";
import "./styles.css";
const storedTheme = localStorage.getItem("hub-theme");
if (storedTheme === "light" || storedTheme === "dark") {
document.documentElement.setAttribute("data-theme", storedTheme);
}
const root = document.querySelector<HTMLDivElement>("#root");
if (root) createRoot(root).render(createElement(App));
+50
View File
@@ -0,0 +1,50 @@
import type { IconName } from "./components/Icon";
export type NavigationItem = {
href: string;
label: string;
description: string;
icon: IconName;
auditOnly?: boolean;
};
export type NavigationGroup = {
label: string;
items: NavigationItem[];
};
export const navigation: NavigationGroup[] = [
{
label: "Overview",
items: [{ href: "/", label: "Overview", description: "Live platform signal and exceptions", icon: "overview" }],
},
{
label: "Build",
items: [
{ href: "/projects", label: "Projects", description: "Project environments and bindings", icon: "projects" },
{ href: "/servers", label: "MCP Servers", description: "Governed virtual MCP endpoints", icon: "servers" },
{ href: "/tools", label: "Catalog", description: "Discover and validate available tools", icon: "catalog" },
{ href: "/connectors", label: "Connectors", description: "Source integrations and health", icon: "connectors" },
],
},
{
label: "Govern",
items: [
{ href: "/clients", label: "Clients", description: "Scoped machine identities and config", icon: "clients" },
{ href: "/policies", label: "Policies", description: "Explain effective authorization", icon: "policies" },
{ href: "/audit", label: "Audit", description: "Trace governed requests and decisions", icon: "audit", auditOnly: true },
],
},
{
label: "Operate",
items: [{ href: "/operations", label: "Operations", description: "Checks, alerts, drift and recovery", icon: "operations" }],
},
{
label: "Admin",
items: [{ href: "/settings", label: "Platform", description: "Session and platform preferences", icon: "settings" }],
},
];
export function availableNavigation(canReadAudit: boolean): NavigationItem[] {
return navigation.flatMap((group) => group.items).filter((item) => !item.auditOnly || canReadAudit);
}
+37
View File
@@ -0,0 +1,37 @@
import { Brand } from "../components/Brand";
import { Icon } from "../components/Icon";
import { McpTopology } from "../components/McpTopology";
const trustPoints = [
["Project scoped", "Only assigned capabilities are visible"],
["Policy checked", "Identity and environment are evaluated per call"],
["Fully audited", "Every governed request receives a trace ID"],
] as const;
export function AuthGateway() {
return (
<section className="auth-layout" aria-labelledby="auth-heading">
<div className="auth-copy">
<Brand compact />
<p className="eyebrow">Secure management access</p>
<h1 id="auth-heading">Inspect your governed MCP estate.</h1>
<p className="auth-lead">One operational view for project tools, connector health, policy decisions and audited client access.</p>
<div className="auth-actions">
<a className="button auth-button" href="/auth/login">Continue to sign in <Icon name="arrow" size={16} /></a>
<span><span className="secure-dot" aria-hidden="true" />Same-origin Authentik session</span>
</div>
<small className="auth-help">Use your approved ITWorx identity. Tokens remain in the server-managed session and are never exposed to this browser.</small>
<dl className="auth-trust-list">
{trustPoints.map(([title, detail]) => <div key={title}><dt>{title}</dt><dd>{detail}</dd></div>)}
</dl>
</div>
<div className="auth-visual" aria-label="How governed MCP access works">
<div className="auth-visual-intro"><p className="eyebrow">Live control path</p><span className="tag">Read-only v1</span></div>
<h2>Capability access without infrastructure exposure.</h2>
<p>Requests move through explicit identity, policy and project boundaries before reaching a purpose-built connector.</p>
<McpTopology compact />
<div className="auth-boundary"><strong>Infrastructure stays behind the boundary</strong><span>No Docker socket, broad filesystem, unrestricted SQL or reusable source credentials reach the client.</span></div>
</div>
</section>
);
}
+20
View File
@@ -0,0 +1,20 @@
import { useEffect, 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 }) {
const canApply = useCan("catalog.apply");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
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>;
}
+93
View File
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, OnboardingClientKind } from "../contracts";
import { safeApiMessage } from "../api";
import { Icon } from "../components/Icon";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
export function ClientOnboarding({ api }: { api: ManagementApiClient }) {
const canCreate = useCan("client.create");
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 [copyStatus, setCopyStatus] = useState("");
const [busy, setBusy] = useState(false);
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
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 create = async () => {
if (!ready) return;
setBusy(true);
setError("");
setCopyStatus("");
setResult(null);
try {
setResult(await api.createClient({ name: normalizedName, project, environment, kind }));
} catch (caught) {
setError(safeApiMessage(caught));
} finally {
setBusy(false);
}
};
const copyConfig = async () => {
if (!result) return;
try {
await navigator.clipboard.writeText(JSON.stringify({
name: `itworx-${result.client.project}-${result.client.environment}`,
endpoint: result.config.endpoint,
hubClientId: result.client.id,
authentik: {
issuer: result.provisioning.issuer,
tokenEndpoint: result.provisioning.tokenEndpoint,
oauthClientId: result.provisioning.oauthClientId,
username: result.provisioning.oauthUsername,
},
installScript: "scripts/install_codex_mcp.ps1",
credential: "operator-owned user-only secret file",
}, null, 2));
setCopyStatus("Configuration copied");
} catch {
setCopyStatus("Clipboard access is unavailable. Copy the non-secret fields manually.");
}
};
return <section className="panel workflow-panel onboarding-panel">
<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>
<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>
</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}
</section>;
}
+100
View File
@@ -0,0 +1,100 @@
import type { MouseEvent } from "react";
import type { DashboardSnapshot, Health } from "../contracts";
import { isSnapshotStale } from "../contracts";
import { formatBrusselsTime } from "../format";
import { Icon } from "../components/Icon";
import { McpTopology } from "../components/McpTopology";
import { MetricCard } from "../components/MetricCard";
import { StatusPill } from "../components/StatusPill";
import { useCan } from "../rbac-context";
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 }) {
const stale = isSnapshotStale(snapshot);
const exceptions = exceptionsFor(snapshot, stale, useCan("audit.read"));
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">
<div className={allCurrent ? "hero-signal hero-signal-healthy" : "hero-signal hero-signal-attention"}>
<span aria-hidden="true" />{allCurrent ? "All reported signals current" : `${exceptions.length} signal${exceptions.length === 1 ? "" : "s"} need review`}
</div>
<p className="eyebrow">Operational overview</p>
<h2>Governed MCP, with the current signal first.</h2>
<p className="hero-copy">See the complete path from authenticated client to policy, gateway and scoped connector—then act on exceptions without searching across systems.</p>
<div className="hero-actions">
<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>
<McpTopology
hub={stale ? "degraded" : snapshot.health.hub}
gateway={stale ? "degraded" : snapshot.health.gateway}
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"} />
</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>
{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>}
</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>}
</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="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}` : ""}`} />
<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} status={stale ? "degraded" : snapshot.backup.state} />
<MetricCard label="Release" value={snapshot.version.application} detail={`Schema ${snapshot.version.schema}`} />
</section>
</div>;
}
+1
View File
@@ -0,0 +1 @@
export function ForbiddenPanel() { return <section className="panel state-panel" role="alert"><div className="state-icon" aria-hidden="true">!</div><h2>Management route not permitted</h2><p>This path is not part of the authorized v1 management surface.</p><a className="button" href="/">Return to overview</a></section>; }
+81
View File
@@ -0,0 +1,81 @@
import { useEffect, useState } from "react";
import { createApiClient, safeApiMessage } from "../api";
import type { DashboardSnapshot, OperationsCheck, OperationsSummary } 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";
function SummaryLoading() {
return <div className="inline-loading" role="status"><span className="spinner spinner-small" aria-hidden="true" /><span>Loading operational summary…</span></div>;
}
export function Operations({ path, navigate, snapshot }: { path: string; navigate: (next: string) => void; snapshot: DashboardSnapshot | null }) {
const [api] = useState(() => createApiClient());
const canCheck = useCan("operations.check");
const [summary, setSummary] = useState<OperationsSummary | null>(null);
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 runCheck = async () => {
setBusy(true);
setError("");
try {
setCheck(await api.runOperationsCheck());
setSummary(await api.operationsSummary());
} 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>;
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]));
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>
<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>
<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>
</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>;
}
+23
View File
@@ -0,0 +1,23 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, PolicySimulation } from "../contracts";
import { safeApiMessage } from "../api";
import { StatusPill } from "../components/StatusPill";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
export function PolicySimulator({ api }: { api: ManagementApiClient }) {
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [tools, setTools] = useState<{ id: string; name: string }[]>([]);
const [tool, setTool] = useState("");
const [result, setResult] = useState<PolicySimulation | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
useEffect(() => { if (!project && projects[0]) setProject(projects[0].id); }, [projects, project]);
useEffect(() => { setEnvironment((current) => environments.includes(current) ? current : environments[0] ?? ""); }, [environments]);
useEffect(() => { let active = true; void api.resource("tools", { filter: "", limit: 100 }).then((page) => { if (!active) return; const options = page.rows.map((row) => ({ id: row.canonicalName ?? row.id, name: row.name })); setTools(options); setTool((current) => options.some((item) => item.id === current) ? current : options[0]?.id ?? ""); }).catch(() => { if (active) setTools([]); }); return () => { active = false; }; }, [api]);
const ready = project !== "" && environment !== "" && tool !== "";
const run = async () => { if (!ready) return; setBusy(true); setError(""); try { setResult(await api.simulatePolicy({ project, environment, tool })); } catch (caught) { setError(safeApiMessage(caught)); } finally { setBusy(false); } };
return <section className="panel workflow-panel"><div className="panel-heading"><div><p className="eyebrow">Decision visibility</p><h3>Policy simulator</h3></div><span className="tag">Known entities only</span></div><div className="form-grid"><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><label>Tool<select value={tool} onChange={(event) => setTool(event.target.value)} disabled={tools.length === 0}>{tools.length === 0 ? <option value="">No registered tools</option> : tools.map((item) => <option key={item.id} value={item.id}>{item.name}</option>)}</select></label></div><button className="button" onClick={() => void run()} disabled={busy || !ready}>{busy ? "Evaluating…" : "Explain decision"}</button>{error && <p className="error-text" role="alert">{error}</p>}{result && <div className="decision-card" role="status"><div><StatusPill value={result.allowed ? "healthy" : "degraded"}/><strong>{result.decision}</strong></div><p>{result.reasons.join(" ")}</p><small>Matched: {result.matchedPolicies.join(", ") || "none"} · Trace {result.traceId}</small></div>}{result && result.steps.length > 0 && <div className="policy-trace"><p className="eyebrow">Decision logic trace</p><ol>{result.steps.map((step, index) => <li key={`${step.phase}-${index}`} className={step.matched ? "trace-step trace-step-matched" : "trace-step trace-step-skipped"}><div className="trace-step-head"><span className="trace-step-icon" aria-hidden="true">{step.matched ? "✓" : "–"}</span><strong>{step.phase}</strong>{step.rule && <span className="tag trace-rule-chip">{step.rule}</span>}</div><p>{step.reason}</p></li>)}</ol></div>}</section>;
}
+35
View File
@@ -0,0 +1,35 @@
import { useEffect, useState } from "react";
import type { ManagementApiClient, ProjectDetailInfo, ResourceKind, ResourceRecord } from "../contracts";
import { ApiError, safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import { ErrorPanel } from "../components/ErrorPanel";
import { LoadingPanel } from "../components/LoadingPanel";
import { MetricCard } from "../components/MetricCard";
import { basePath } from "../routing";
import { useCan } from "../rbac-context";
const projectTabs = ["Summary", "Environments", "MCP Servers", "Capabilities", "Connectors", "Knowledge Sources", "Clients", "Audit"] as const;
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></>;
}
export function ProjectDetail({ path, navigate, api }: { path: string; navigate: (next: string) => void; api: ManagementApiClient }) {
const slug = basePath(path).split("/").pop() ?? "project";
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 [detail, setDetail] = useState<ProjectDetailInfo | null>(null);
const [state, setState] = useState<"loading" | "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]);
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>;
}
+143
View File
@@ -0,0 +1,143 @@
import { useEffect, useMemo, useState } from "react";
import type { ManagementApiClient, ResourceKind, ResourcePage as ResourcePageRecord, ResourceRecord } from "../contracts";
import { routeTitle } from "../contracts";
import { safeApiMessage } from "../api";
import { Breadcrumbs } from "../components/Breadcrumbs";
import type { Density } from "../components/DataTable";
import { columnsFor, DataTable } from "../components/DataTable";
import { ErrorPanel } from "../components/ErrorPanel";
import { Icon } from "../components/Icon";
import { LoadingPanel } from "../components/LoadingPanel";
import { basePath } from "../routing";
import { useCan } from "../rbac-context";
import { CatalogWorkflow } from "./CatalogWorkflow";
import { ClientOnboarding } from "./ClientOnboarding";
import { PolicySimulator } from "./PolicySimulator";
import { ToolReadinessModal } from "./ToolReadinessModal";
const descriptions: Record<ResourceKind, string> = {
projects: "Inspect health, environments and bounded capabilities across registered projects.",
servers: "Review project-scoped virtual MCP endpoints published through the trusted gateway.",
tools: "Find governed tools, verify their route and run safe readiness checks before invocation.",
connectors: "Monitor purpose-built source integrations without exposing their underlying credentials.",
clients: "Manage project-bound machine identities and generate secret-free client configuration.",
policies: "Inspect effective policy records and explain decisions against known entities.",
audit: "Trace governed requests by project, client, tool, decision and non-secret trace identifier.",
};
type Feedback = { tone: "info" | "danger"; message: string } | null;
export function ResourcePage({ path, kind, api, navigate }: { path: string; kind: ResourceKind; api: ManagementApiClient; navigate: (next: string) => void }) {
const canDisableClients = useCan("client.disable");
const canTestTools = useCan("tool.test");
const params = new URLSearchParams(path.split("?")[1] ?? "");
const urlFilter = params.get("q") ?? "";
const [filterInput, setFilterInput] = useState(urlFilter);
const [data, setData] = useState<ResourcePageRecord | null>(null);
const [state, setState] = useState<"loading" | "ready" | "error">("loading");
const [error, setError] = useState("");
const [feedback, setFeedback] = useState<Feedback>(null);
const [cursorStack, setCursorStack] = useState<(string | undefined)[]>([undefined]);
const [density, setDensity] = useState<Density>("comfortable");
const [testingTool, setTestingTool] = useState<ResourceRecord | null>(null);
const [refreshKey, setRefreshKey] = useState(0);
const cursor = cursorStack[cursorStack.length - 1];
const hasCurrentData = data?.kind === kind;
const isRefreshing = state === "loading" && hasCurrentData;
const visibleColumns = useMemo(() => {
const hideActions = (kind === "clients" && !canDisableClients) || (kind === "tools" && !canTestTools);
return hideActions ? columnsFor(kind).filter((column) => column !== "actions") : columnsFor(kind);
}, [kind, canDisableClients, canTestTools]);
useEffect(() => { setFilterInput(urlFilter); }, [urlFilter]);
useEffect(() => {
const normalizedFilter = filterInput.trim();
if (normalizedFilter === urlFilter) return;
const timer = window.setTimeout(() => {
navigate(normalizedFilter ? `/${kind}?q=${encodeURIComponent(normalizedFilter)}` : `/${kind}`);
}, 260);
return () => window.clearTimeout(timer);
}, [filterInput, kind, navigate, urlFilter]);
useEffect(() => { setCursorStack([undefined]); }, [kind, urlFilter]);
useEffect(() => {
let active = true;
setState("loading");
setError("");
void api.resource(kind, { filter: urlFilter, cursor, limit: 25 })
.then((value) => {
if (!active) return;
setData(value);
setState("ready");
})
.catch((caught) => {
if (!active) return;
setError(safeApiMessage(caught));
setState("error");
});
return () => { active = false; };
}, [api, cursor, kind, refreshKey, urlFilter]);
const nextPage = () => {
if (data?.page.nextCursor) setCursorStack((stack) => [...stack, data.page.nextCursor]);
};
const previousPage = () => setCursorStack((stack) => (stack.length > 1 ? stack.slice(0, -1) : stack));
const retry = () => setRefreshKey((value) => value + 1);
const action = async (id: string, verb: "disable" | "revoke") => {
setFeedback({ tone: "info", message: "Submitting server-authorized client action…" });
try {
const result = await api.clientAction(id, verb);
setFeedback({ tone: "info", message: result.message });
retry();
} catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
}
};
const config = async (id: string) => {
try {
const value = await api.generatedConfig(id);
setFeedback({ tone: "info", message: `Generated configuration for ${value.clientId}: ${value.endpoint} · scopes ${value.scopes.join(", ")}. Secret material is write-only and is not displayed.` });
} catch (caught) {
setFeedback({ tone: "danger", message: safeApiMessage(caught) });
}
};
if (!hasCurrentData && state === "error") return <ErrorPanel message={error} retry={retry} />;
if (!hasCurrentData || !data) return <LoadingPanel />;
return <div className="resource-stack">
<Breadcrumbs path={path} navigate={navigate} />
<section className="panel resource-page" aria-busy={isRefreshing}>
<div className="panel-heading resource-heading">
<div><p className="eyebrow">Management workspace</p><h2>{routeTitle(basePath(path))}</h2><p className="resource-description">{descriptions[kind]}</p></div>
<span className="record-count"><strong>{data.page.total}</strong><small>total · server paginated</small></span>
</div>
<div className="resource-toolbar">
<label className="filter-field">
<span>Filter records</span>
<span className="filter-input-wrap"><Icon name="search" size={16} /><input value={filterInput} onChange={(event) => setFilterInput(event.target.value)} placeholder={kind === "audit" ? "Project, client, tool or trace" : "Name, project or capability"} />{filterInput ? <button type="button" className="filter-clear" onClick={() => setFilterInput("")} aria-label="Clear filter">×</button> : null}</span>
</label>
<div className="table-controls" role="group" aria-label="Table density">
<span>Density</span>
<button className={density === "comfortable" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "comfortable"} onClick={() => setDensity("comfortable")}>Comfortable</button>
<button className={density === "compact" ? "button button-small active" : "button button-small button-secondary"} aria-pressed={density === "compact"} onClick={() => setDensity("compact")}>Compact</button>
</div>
</div>
<div className={isRefreshing ? "resource-results results-refreshing" : "resource-results"}>
{isRefreshing ? <span className="refresh-indicator" role="status"><span className="spinner spinner-small" aria-hidden="true" />Updating results…</span> : null}
{state === "error" ? <div className="notice notice-danger" role="alert"><span>{error}</span><button type="button" onClick={retry}>Retry</button></div> : null}
{data.rows.length === 0
? <div className="empty-line"><span aria-hidden="true">○</span><span><strong>No matching records</strong><small>{urlFilter ? "Clear or broaden the current filter." : "Nothing has been registered in this workspace yet."}</small></span></div>
: <DataTable data={data} kind={kind} density={density} visibleColumns={visibleColumns} onClientAction={action} onConfig={config} onTest={setTestingTool} />}
</div>
<div className="pagination">
<span>Showing {data.rows.length} of {data.page.total}</span>
<div><button className="button button-small button-secondary" aria-label="Previous page" onClick={previousPage} disabled={cursorStack.length <= 1} title={cursorStack.length <= 1 ? "No previous page" : "Load previous page"}>Previous</button><button className="button button-small button-secondary" aria-label="Next page" onClick={nextPage} disabled={!data.page.nextCursor} title={!data.page.nextCursor ? "No next page" : "Load next page"}>Next <Icon name="arrow" size={13} /></button></div>
</div>
{feedback ? <div className={`notice notice-${feedback.tone}`} role={feedback.tone === "danger" ? "alert" : "status"}>{feedback.message}</div> : null}
</section>
{kind === "tools" ? <CatalogWorkflow api={api} /> : null}
{kind === "policies" ? <PolicySimulator api={api} /> : null}
{kind === "clients" ? <><ClientOnboarding api={api} /><section className="panel workflow-panel client-safety"><div><p className="eyebrow">Client safety</p><h3>Write-only credentials</h3><p className="muted">Generated configuration includes endpoint, identity reference and scopes only. Client secrets are never returned by the API or rendered into the DOM.</p></div><span className="boundary-badge">Secret-free browser surface</span></section></> : null}
{testingTool ? <ToolReadinessModal tool={testingTool} api={api} onClose={() => setTestingTool(null)} /> : null}
</div>;
}
+15
View File
@@ -0,0 +1,15 @@
import { useState } from "react";
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"; }
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); };
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>;
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from "react";
import { safeApiMessage } from "../api";
import { Modal } from "../components/Modal";
import { StatusPill } from "../components/StatusPill";
import type { ManagementApiClient, ResourceRecord, ToolDefinition, ToolInvocationResult, ToolReadinessResult } from "../contracts";
import { useProjectEnvironments, useProjectOptions } from "../hooks";
import { useCan } from "../rbac-context";
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 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 {
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)),
);
} catch {
// Invocation succeeded even when browser storage is disabled or corrupt.
}
}
export function ToolReadinessModal({ tool, api, onClose }: { tool: ResourceRecord; api: ManagementApiClient; onClose: () => void }) {
const canInvoke = useCan("tool.invoke");
const projects = useProjectOptions(api);
const [project, setProject] = useState("");
const environments = useProjectEnvironments(api, project);
const [environment, setEnvironment] = useState("");
const [definition, setDefinition] = useState<ToolDefinition | null>(null);
const [argumentsValue, setArgumentsValue] = useState<Record<string, unknown>>({});
const [result, setResult] = useState<ToolReadinessResult | null>(null);
const [invocation, setInvocation] = useState<ToolInvocationResult | null>(null);
const [error, setError] = useState("");
const [busy, setBusy] = useState<"readiness" | "invoke" | "">("");
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]);
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.`); }
};
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);
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 copyResult = async () => { if (invocation) await navigator.clipboard.writeText(JSON.stringify(invocation.structuredContent ?? invocation.content ?? invocation.error, null, 2)); };
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>
<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>;
}
+14
View File
@@ -0,0 +1,14 @@
import { createContext, useContext, type ReactNode } from "react";
import type { ManagementAction, ManagementRole } from "./rbac";
import { can } from "./rbac";
const RolesContext = createContext<readonly ManagementRole[]>([]);
export function RbacProvider({ roles, children }: { roles: readonly ManagementRole[]; children: ReactNode }) {
return <RolesContext.Provider value={roles}>{children}</RolesContext.Provider>;
}
export function useCan(action: ManagementAction): boolean {
const roles = useContext(RolesContext);
return can(roles, action);
}
+44
View File
@@ -0,0 +1,44 @@
import type { ManagementRole } from "./contracts";
export type { ManagementRole };
export type ManagementAction =
| "dashboard.read"
| "resource.read"
| "audit.read"
| "policy.simulate"
| "catalog.preview"
| "tool.test"
| "tool.invoke"
| "operations.check"
| "client.read"
| "client.bootstrap"
| "client.disable"
| "client.revoke"
| "client.create"
| "catalog.apply";
/** Mirrors services/control_api/authz.py's ENDPOINT_PERMISSIONS exactly.
* This is a client-side hide-only affordance, never an authorization
* boundary -- services/control_api/authz.py's check_role() is what
* actually enforces every one of these actions server-side. */
const PERMISSIONS: Record<ManagementAction, readonly ManagementRole[]> = {
"dashboard.read": ["viewer", "auditor", "operator", "admin"],
"resource.read": ["viewer", "auditor", "operator", "admin"],
"audit.read": ["auditor", "operator", "admin"],
"policy.simulate": ["viewer", "auditor", "operator", "admin"],
"catalog.preview": ["viewer", "auditor", "operator", "admin"],
"tool.test": ["operator", "admin"],
"tool.invoke": ["operator", "admin"],
"operations.check": ["operator", "admin"],
"client.read": ["viewer", "auditor", "operator", "admin"],
"client.bootstrap": ["operator", "admin"],
"client.disable": ["operator", "admin"],
"client.revoke": ["operator", "admin"],
"client.create": ["admin"],
"catalog.apply": ["admin"],
};
export function can(roles: readonly ManagementRole[], action: ManagementAction): boolean {
const allowed = PERMISSIONS[action];
return roles.some((role) => allowed.includes(role));
}
+16
View File
@@ -0,0 +1,16 @@
import { useCallback, useEffect, useState } from "react";
import type { ResourceKind } from "./contracts";
const resourceRoutes: Record<string, ResourceKind> = { "/projects": "projects", "/servers": "servers", "/tools": "tools", "/connectors": "connectors", "/clients": "clients", "/policies": "policies", "/audit": "audit" };
export function usePath() {
const [path, setPath] = useState(window.location.pathname + window.location.search);
useEffect(() => { const onPopState = () => setPath(window.location.pathname + window.location.search); window.addEventListener("popstate", onPopState); return () => window.removeEventListener("popstate", onPopState); }, []);
const navigate = useCallback((next: string) => {
window.history.pushState({}, "", next);
setPath(next);
}, []);
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; }
+862
View File
@@ -0,0 +1,862 @@
@import "@fontsource/inter/400.css";
@import "@fontsource/inter/500.css";
@import "@fontsource/inter/600.css";
@import "@fontsource/inter/700.css";
@import "@fontsource/jetbrains-mono/400.css";
@import "@fontsource/jetbrains-mono/500.css";
/* Tokens -- derived from stitch_itworx_mcp_control_plane's
itworx_mcp_hub_design_system/DESIGN.md ("Cyber-Neutral" dark-first
observability palette). Token *names* are unchanged from the prior
iteration so no component markup needed to change, only values. */
:root {
--surface-0: #0f131b;
--surface-1: #181c24;
--surface-2: #1c2028;
--surface-2-hover: #262a32;
--line: #3b494c;
--line-soft: #262a32;
--paper: #dfe2ee;
--paper-bright: #f3f5fa;
--ink-dim: #bac9cc;
--ink-faint: #849396;
--accent: #00b0fe;
--accent-bright: #00e5ff;
--accent-dim: #00404a;
--status-success: #34d399;
--status-success-dim: #113328;
--status-danger: #ffb4ab;
--status-danger-dim: #4a1410;
--status-info: #8dcdff;
--status-info-dim: #1a3245;
--status-unknown: #849396;
--status-unknown-dim: #262a32;
--space-1: 4px; --space-2: 8px; --space-3: 12px; --space-4: 16px;
--space-5: 20px; --space-6: 28px; --space-7: 36px; --space-8: 48px;
--radius-sm: 4px; --radius-md: 6px; --radius-lg: 8px; --radius-pill: 9999px;
--shadow-1: 0 1px 3px -1px #0008;
--shadow-2: 0 4px 16px -6px #0009;
--motion-fast: 120ms; --motion-base: 200ms;
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, "SFMono-Regular", monospace;
color: var(--paper);
background: var(--surface-0);
font-family: var(--font-sans);
font-synthesis: none;
-webkit-font-smoothing: antialiased;
}
:root[data-theme="light"] {
--surface-0: #f8fafc; --surface-1: #ffffff; --surface-2: #f1f5f9;
--surface-2-hover: #e2e8f0; --line: #dbe3e8; --line-soft: #e8eef1;
--paper: #0f172a; --paper-bright: #020617;
/* #5b6b80, not the Tailwind slate-400 #94a3b8 this token used at first:
slate-400 on this surface measures ~2.45:1, well under WCAG AA's 4.5:1
for normal text -- and --ink-faint is real text (table headers,
breadcrumbs, filter hints), not decoration. #5b6b80 measures ~5.2:1. */
--ink-dim: #475569; --ink-faint: #5b6b80;
--accent: #006875; --accent-bright: #00838f; --accent-dim: #d6f1f5;
}
@media (prefers-color-scheme: light) {
:root:not([data-theme="dark"]) {
--surface-0: #f8fafc; --surface-1: #ffffff; --surface-2: #f1f5f9;
--surface-2-hover: #e2e8f0; --line: #dbe3e8; --line-soft: #e8eef1;
--paper: #0f172a; --paper-bright: #020617;
--ink-dim: #475569; --ink-faint: #5b6b80;
--accent: #006875; --accent-bright: #00838f; --accent-dim: #d6f1f5;
}
}
@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; }
a { color: inherit; }
button { font-family: var(--font-sans); }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* 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); }
/* Hero */
.hero { display: flex; justify-content: space-between; gap: 24px; padding: 28px 30px; border: 1px solid var(--line); border-radius: 4px; background: linear-gradient(150deg, var(--surface-2), var(--surface-1)); margin-bottom: 20px; position: relative; overflow: hidden; }
.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; }
.exception-item:hover strong, .exception-item:focus-visible strong { color: var(--accent-bright); }
.exception-item strong, .exception-item small { display: block; }
.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; }
td small, .stack-item small { color: var(--ink-faint); margin-top: 3px; font-family: var(--font-mono); font-size: 11px; }
.stack-list { display: grid; gap: 4px; }
.stack-item { display: flex; justify-content: space-between; align-items: center; gap: 14px; padding: 13px 0; border-bottom: 1px solid var(--line-soft); }
.stack-item strong, .stack-item small { font-size: 12.5px; }
/* Signal lamps */
.status { display: inline-flex; align-items: center; gap: 7px; font-size: 11px; color: var(--ink-dim); white-space: nowrap; font-weight: 600; background: var(--surface-2); border: 1px solid var(--line-soft); border-radius: var(--radius-pill); padding: 4px 10px 4px 8px; }
.status > span { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); box-shadow: inset 0 1px 1px #0006; position: relative; }
.status > span::after { content: ""; position: absolute; top: 1px; left: 1px; width: 2.5px; height: 2.5px; border-radius: 50%; background: #ffffff55; }
.status-healthy > span { background: var(--status-success); box-shadow: 0 0 6px 1px #34d39980, inset 0 1px 1px #ffffff40; }
.status-degraded > span { background: var(--accent); box-shadow: 0 0 6px 1px #00b0fe80, inset 0 1px 1px #ffffff40; }
.status-unavailable > span { background: var(--status-danger); box-shadow: 0 0 6px 1px #ffb4ab80, inset 0 1px 1px #ffffff40; }
.status-unknown > span { background: var(--status-unknown); box-shadow: inset 0 1px 1px #ffffff20; }
.notice { border-radius: 3px; padding: 12px 15px; color: var(--paper); font-size: 12.5px; margin-bottom: 16px; border-left: 3px solid; background: var(--surface-2); }
.notice-warning { border-left-color: var(--accent); }
.notice-danger { border-left-color: var(--status-danger); margin-top: 16px; }
.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); } }
.state-actions { display: flex; justify-content: center; gap: 10px; }
.button { display: inline-block; border: 1px solid transparent; border-radius: 3px; padding: 10px 16px; color: var(--surface-0); background: linear-gradient(160deg, var(--accent-bright), var(--accent)); text-decoration: none; font-size: 12px; font-weight: 700; letter-spacing: 0.03em; text-transform: uppercase; cursor: pointer; transition: filter 120ms ease, transform 120ms ease; }
.button:hover, .button:focus-visible { filter: brightness(1.08); }
.button:active { transform: translateY(1px); }
.button-secondary { color: var(--paper); background: var(--surface-2); border-color: var(--line); text-transform: none; letter-spacing: 0; font-weight: 600; }
.button-secondary:hover, .button-secondary:focus-visible { filter: none; border-color: var(--ink-faint); }
.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
query below, so .nav-trigger/.nav-close/.nav-backdrop rules exist even
before the query narrows other layout rules. */
.nav-trigger { display: none; flex-direction: column; justify-content: center; gap: 4px; width: 36px; height: 36px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-1); cursor: pointer; flex-shrink: 0; }
.nav-trigger span { display: block; width: 16px; height: 2px; margin: 0 auto; background: var(--paper); border-radius: 1px; }
.nav-close { display: none; border: 0; background: transparent; color: var(--ink-dim); font-size: 22px; line-height: 1; cursor: pointer; padding: var(--space-1); }
.nav-close:hover, .nav-close:focus-visible { color: var(--paper-bright); }
.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; }
.session-identity small { color: var(--ink-faint); font-family: var(--font-mono); font-size: 10px; text-transform: uppercase; }
.session-error { color: var(--status-danger); font-size: 11px; }
.breadcrumbs a { color: var(--accent); text-decoration: none; }
.breadcrumbs a:hover { text-decoration: underline; }
.resource-stack { display: grid; gap: 18px; }
.resource-stack .resource-page { max-width: none; margin: 0; text-align: left; padding: 23px; }
.resource-stack .resource-page h2 { font-size: 22px; }
.resource-stack .resource-page p { max-width: none; margin-left: 0; }
.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; }
.long-name { max-width: 250px; white-space: normal; overflow-wrap: anywhere; }
.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; }
.readiness-form { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.readiness-arguments { grid-column: 1 / -1; }
.readiness-arguments textarea { min-height: 110px; resize: vertical; font-family: var(--font-mono); line-height: 1.5; }
.readiness-result { margin-top: 17px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--accent-dim); border-radius: 3px; background: var(--surface-2); }
.readiness-summary { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.readiness-summary span { color: var(--ink-faint); font-family: var(--font-mono); font-size: 11px; }
.readiness-checks { display: grid; gap: 8px; margin: 16px 0; }
.readiness-checks > div { display: grid; grid-template-columns: 90px 1fr; gap: 12px; align-items: start; padding-top: 8px; border-top: 1px solid var(--line); }
.readiness-checks dt { color: var(--ink-faint); font-size: 11px; text-transform: uppercase; letter-spacing: 0.04em; }
.readiness-checks dd { display: flex; align-items: center; gap: 8px; margin: 0; color: var(--ink-dim); font-size: 12px; }
.readiness-result small { color: var(--ink-faint); font-family: var(--font-mono); }
.tool-console-actions { display: flex; gap: 10px; margin-top: 16px; }
.tool-result { margin-top: 17px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid var(--status-success); border-radius: 3px; background: var(--surface-2); }
.tool-result pre { max-height: 320px; overflow: auto; padding: 12px; border: 1px solid var(--line); background: var(--surface-0); color: var(--ink-dim); font: 11px/1.5 var(--font-mono); white-space: pre-wrap; overflow-wrap: anywhere; }
.tool-result small { color: var(--ink-faint); font-family: var(--font-mono); }
.connector-checks { display: grid; gap: 8px; margin-top: 18px; }
.connector-checks > div { display: grid; grid-template-columns: auto minmax(110px, .35fr) 1fr; align-items: center; gap: 10px; padding: 9px 0; border-top: 1px solid var(--line); }
.connector-checks span { color: var(--ink-dim); font-size: 12px; overflow-wrap: anywhere; }
.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); }
.onboarding-result dl { display: grid; gap: 8px; }
.onboarding-result dl > div { display: grid; grid-template-columns: 100px 1fr; gap: 12px; }
.onboarding-result dt { color: var(--ink-faint); font: 10px/1.5 var(--font-mono); text-transform: uppercase; }
.onboarding-result dd { margin: 0; color: var(--ink-dim); overflow-wrap: anywhere; }
.onboarding-result li { margin: 7px 0; color: var(--ink-dim); }
.decision-card, .diff-box { margin-top: 17px; padding: 16px; border: 1px solid var(--line); border-radius: 3px; background: var(--surface-2); border-left: 3px solid var(--accent-dim); }
.decision-card > div { display: flex; gap: 9px; align-items: center; }
.decision-card strong { color: var(--paper-bright); text-transform: uppercase; font-size: 12.5px; letter-spacing: 0.03em; font-family: var(--font-mono); }
.decision-card p { margin: 11px 0; color: var(--ink-dim); }
.decision-card small { color: var(--ink-faint); font-family: var(--font-mono); }
.diff-box { display: grid; gap: 8px; }
.policy-trace { margin-top: 17px; }
.policy-trace ol { list-style: none; margin: 0; padding: 0; display: grid; gap: 10px; }
.trace-step { position: relative; padding: 12px 14px 12px 42px; border: 1px solid var(--line); border-radius: var(--radius-sm); background: var(--surface-1); }
.trace-step-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.trace-step-head strong { color: var(--paper-bright); font-size: 13px; font-weight: 600; }
.trace-step-icon { position: absolute; left: 12px; top: 12px; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%; font-size: 12px; font-weight: 700; line-height: 1; }
.trace-step-matched { border-left: 2px solid var(--status-success); }
.trace-step-matched .trace-step-icon { background: var(--status-success-dim); color: var(--status-success); }
.trace-step-skipped { border-left: 2px solid var(--line); }
.trace-step-skipped .trace-step-icon { background: var(--surface-2); color: var(--ink-faint); }
.trace-rule-chip { margin-left: auto; }
.trace-step p { margin: 6px 0 0; color: var(--ink-dim); font-size: 12px; font-family: var(--font-mono); }
.diff-add { color: var(--status-success); font-family: var(--font-mono); font-size: 12px; }
.diff-error, .error-text { color: var(--status-danger); font-size: 12px; font-family: var(--font-mono); }
.tabs { display: flex; gap: 3px; overflow-x: auto; border-bottom: 1px solid var(--line); margin: 23px 0; padding-bottom: 0; }
.tab { color: var(--ink-dim); text-decoration: none; padding: 10px 12px; white-space: nowrap; font-size: 11.5px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.04em; border-bottom: 2px solid transparent; transition: color 120ms ease, border-color 120ms ease; }
.tab:hover, .tab:focus-visible { color: var(--paper); }
.tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.detail-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 12px; text-align: left; margin-bottom: 19px; }
.detail-grid .metric-card { min-height: 114px; }
.detail-grid .metric-card strong { font-size: 19px; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.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); }
.modal-heading { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--space-4); }
.modal-heading h2 { margin: 0; font-size: 18px; color: var(--paper-bright); }
.modal-close { background: transparent; border: 0; color: var(--ink-dim); font-size: 20px; cursor: pointer; line-height: 1; padding: var(--space-1); }
.modal-close:hover, .modal-close:focus-visible { color: var(--paper-bright); }
@media (prefers-reduced-motion: no-preference) { .modal-overlay { animation: modal-fade var(--motion-fast) ease; } .modal { animation: modal-in var(--motion-base) ease; } }
@keyframes modal-fade { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-in { from { opacity: 0; transform: translateY(8px) scale(0.98); } to { opacity: 1; transform: none; } }
/* Table density */
.density-compact td, .density-compact th { padding: 6px 9px; }
.density-comfortable td, .density-comfortable th { padding: 13px 11px; }
/* Sol Ultra product-quality layer
--------------------------------
These rules build on the accepted Cyber-Neutral token set above. They
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),
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;
}
body::before {
content: "";
position: fixed;
inset: 0;
pointer-events: none;
background: linear-gradient(90deg, var(--surface-0) 0%, transparent 16%, transparent 84%, var(--surface-0) 100%);
opacity: .48;
}
#root { position: relative; min-height: 100vh; }
.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 .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; }
.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 .icon { color: var(--accent-bright); }
.sidebar-foot { 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; }
.main-content { 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-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; }
.command-trigger {
display: inline-flex;
align-items: center;
gap: 8px;
min-height: 36px;
padding: 7px 8px 7px 11px;
border: 1px solid var(--line);
border-radius: var(--radius-md);
color: var(--ink-dim);
background: var(--surface-1);
cursor: pointer;
font-size: 11.5px;
transition: border-color var(--motion-fast) ease, color var(--motion-fast) ease, background-color var(--motion-fast) ease;
}
.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 > .tag { padding-inline: 8px; }
/* Public gateway */
.preauth-shell { display: block; min-height: 100vh; }
.preauth-shell .main-content { width: 100%; min-height: 100vh; padding: clamp(18px, 3vw, 42px); }
.auth-layout {
display: grid;
grid-template-columns: minmax(340px, .82fr) minmax(520px, 1.18fr);
width: min(100%, 1320px);
min-height: calc(100vh - clamp(36px, 6vw, 84px));
margin: 0 auto;
border: 1px solid var(--line);
border-radius: 10px;
overflow: hidden;
background: color-mix(in srgb, var(--surface-1) 96%, transparent);
box-shadow: 0 28px 80px -48px #000;
}
.auth-copy, .auth-visual { min-width: 0; padding: clamp(34px, 5vw, 74px); }
.auth-copy { display: flex; flex-direction: column; justify-content: center; }
.auth-copy .preauth-brand { margin: 0 0 clamp(50px, 8vh, 88px); }
.auth-copy h1 { max-width: 13ch; margin: 0; color: var(--paper-bright); font-size: clamp(38px, 5vw, 66px); line-height: .99; letter-spacing: -.05em; }
.auth-lead { max-width: 53ch; margin: 23px 0 0; color: var(--ink-dim); font-size: 15px; line-height: 1.7; }
.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-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; }
.auth-trust-list dd { margin: 5px 0 0; color: var(--ink-faint); font-size: 9.5px; line-height: 1.45; }
.auth-visual {
display: flex;
flex-direction: column;
justify-content: center;
border-left: 1px solid var(--line);
background:
radial-gradient(520px 320px at 64% 34%, color-mix(in srgb, var(--accent-dim) 38%, transparent), transparent 65%),
color-mix(in srgb, var(--surface-2) 66%, var(--surface-1));
}
.auth-visual-intro { display: flex; justify-content: space-between; align-items: center; gap: 16px; }
.auth-visual-intro .eyebrow { margin: 0; }
.auth-visual h2 { max-width: 16ch; margin: 18px 0 12px; color: var(--paper-bright); font-size: clamp(25px, 3vw, 40px); line-height: 1.08; letter-spacing: -.035em; }
.auth-visual > p { max-width: 59ch; margin: 0; color: var(--ink-dim); font-size: 13px; }
.auth-boundary { display: grid; grid-template-columns: minmax(150px, .55fr) 1fr; gap: 20px; margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--line); }
.auth-boundary strong { color: var(--paper); font-size: 11.5px; }
.auth-boundary span { color: var(--ink-faint); font-size: 10.5px; line-height: 1.55; }
/* MCP topology illustration */
.topology { min-width: 0; margin: 0; padding: 18px; border: 1px solid var(--line); border-radius: var(--radius-lg); background: color-mix(in srgb, var(--surface-0) 88%, transparent); box-shadow: inset 0 1px 0 color-mix(in srgb, var(--paper) 5%, transparent); }
.auth-visual .topology { margin-top: 32px; }
.topology-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-bottom: 11px; border-bottom: 1px solid var(--line-soft); }
.topology-head > span { display: inline-flex; align-items: center; gap: 8px; color: var(--paper); font-size: 10.5px; font-weight: 650; text-transform: uppercase; letter-spacing: .06em; }
.topology-head small { color: var(--ink-faint); font: 9.5px/1.4 var(--font-mono); }
.signal-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--status-unknown); box-shadow: 0 0 0 3px var(--status-unknown-dim); }
.signal-healthy { background: var(--status-success); box-shadow: 0 0 0 3px var(--status-success-dim); }
.signal-degraded { background: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
.signal-unavailable { background: var(--status-danger); box-shadow: 0 0 0 3px var(--status-danger-dim); }
.topology-canvas { min-width: 0; }
.topology-canvas svg { display: block; width: 100%; height: auto; color: var(--accent); overflow: visible; }
.topology-mobile-flow { display: none; }
.topology-links path { fill: none; stroke: var(--line); stroke-width: 2; }
.topology-flow path { fill: none; stroke: var(--accent); stroke-width: 1.7; stroke-dasharray: 4 14; opacity: .9; animation: topology-flow 2.8s linear infinite; }
.topology-flow path:nth-child(2) { animation-delay: -.5s; }
.topology-flow path:nth-child(n+3) { animation-delay: -1s; }
@keyframes topology-flow { to { stroke-dashoffset: -72; } }
.topology-node rect, .topology-target rect { fill: var(--surface-1); stroke: var(--line); stroke-width: 1.2; transition: fill var(--motion-base) ease, stroke var(--motion-base) ease; }
.topology-node:hover rect, .topology-target:hover rect { fill: var(--surface-2-hover); stroke: var(--ink-faint); }
.topology-node > circle:not(.topology-state), .topology-node > path { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.topology-node-client > circle { fill: color-mix(in srgb, var(--accent-dim) 68%, transparent) !important; }
.topology-node text, .topology-target text { fill: var(--paper); font: 600 10.5px var(--font-sans); }
.topology-node .topology-sub { fill: var(--ink-faint); font: 8.5px var(--font-mono); }
.topology-state { fill: var(--status-unknown); background: var(--status-unknown); }
.topology-node-healthy .topology-state { fill: var(--status-success); background: var(--status-success); }
.topology-node-degraded .topology-state { fill: var(--accent); background: var(--accent); }
.topology-node-unavailable .topology-state { fill: var(--status-danger); background: var(--status-danger); }
.topology figcaption { display: flex; justify-content: space-between; gap: 14px; padding-top: 10px; border-top: 1px solid var(--line-soft); color: var(--ink-faint); font-size: 9.5px; line-height: 1.5; }
.topology figcaption > span { max-width: 72ch; }
.topology figcaption small { flex: 0 0 auto; color: var(--accent); font-family: var(--font-mono); }
/* Dashboard */
.dashboard { display: grid; gap: 18px; }
.dashboard-hero { display: grid; grid-template-columns: minmax(320px, .75fr) minmax(420px, 1.25fr); gap: clamp(26px, 4vw, 58px); padding: clamp(26px, 3vw, 42px); margin: 0; border-radius: var(--radius-lg); }
.dashboard-hero::after { content: ""; position: absolute; width: 280px; height: 280px; right: 9%; top: -60%; border: 1px solid color-mix(in srgb, var(--accent) 14%, transparent); border-radius: 50%; pointer-events: none; }
.hero-content { align-self: center; min-width: 0; }
.hero-signal { display: inline-flex; align-items: center; gap: 8px; margin-bottom: 31px; color: var(--ink-dim); font: 9.5px/1.5 var(--font-mono); text-transform: uppercase; letter-spacing: .07em; }
.hero-signal > span { width: 7px; height: 7px; border-radius: 50%; }
.hero-signal-healthy > span { background: var(--status-success); box-shadow: 0 0 0 4px var(--status-success-dim); }
.hero-signal-attention > span { background: var(--accent); box-shadow: 0 0 0 4px var(--accent-dim); }
.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; }
.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: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: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-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); }
.refresh-button { display: inline-flex; gap: 7px; align-items: center; }
.empty-line > span:last-child { display: grid; }
.empty-line strong { color: var(--paper); font-size: 12px; }
.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; }
.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); }
.connector-score > strong { color: var(--paper-bright); font-size: 31px; line-height: 1; }
.connector-score > span { display: grid; color: var(--paper); font-size: 11px; font-weight: 600; }
.connector-score small { margin-top: 2px; color: var(--ink-faint); font-size: 9.5px; font-weight: 400; }
.secondary-grid { grid-template-columns: repeat(5, minmax(0, 1fr)); margin: 0; }
.secondary-grid .metric-card { min-height: 122px; }
.secondary-grid .metric-card strong { font-size: clamp(18px, 1.6vw, 24px); overflow-wrap: anywhere; }
/* Command menu */
.command-modal { max-width: 620px; padding: 0; overflow: hidden; }
.command-modal .modal-heading { padding: 19px 21px 13px; margin: 0; border-bottom: 1px solid var(--line); }
.command-modal .modal-heading h2 { font-size: 13px; text-transform: uppercase; letter-spacing: .06em; font-family: var(--font-mono); }
.command-modal .modal-body { padding: 14px; }
.command-search { display: flex; align-items: center; gap: 10px; min-height: 48px; padding: 0 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-0); color: var(--ink-faint); }
.command-search:focus-within { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
.command-search input { min-width: 0; flex: 1; border: 0; outline: 0; background: transparent; color: var(--paper); font-size: 13px; }
.command-results { display: grid; gap: 3px; max-height: min(52vh, 430px); overflow-y: auto; margin-top: 10px; }
.command-result { display: grid; grid-template-columns: auto 1fr auto; align-items: center; gap: 12px; width: 100%; padding: 11px; border: 1px solid transparent; border-radius: var(--radius-md); color: var(--ink-dim); text-align: left; background: transparent; cursor: pointer; }
.command-result:hover, .command-result:focus-visible { border-color: var(--line); color: var(--paper); background: var(--surface-2); }
.command-result-icon { display: grid; place-items: center; width: 34px; height: 34px; border: 1px solid var(--line); border-radius: var(--radius-md); color: var(--accent); background: var(--surface-1); }
.command-result > span:nth-child(2) { display: grid; }
.command-result strong { color: var(--paper); font-size: 12px; }
.command-result small { margin-top: 2px; color: var(--ink-faint); font-size: 10.5px; }
.command-result > .icon { color: var(--ink-faint); }
.command-empty { display: grid; gap: 4px; padding: 28px; text-align: center; color: var(--ink-faint); }
.command-empty strong { color: var(--paper); }
.command-hint { margin: 12px 2px 0 !important; color: var(--ink-faint) !important; font: 9.5px/1.5 var(--font-mono) !important; }
/* Resource workspaces */
.resource-stack, .resource-stack > *, .resource-results, .desktop-data-table { min-width: 0; }
.resource-heading { align-items: flex-start; padding-bottom: 19px; border-bottom: 1px solid var(--line-soft); }
.resource-description { max-width: 76ch !important; margin: 8px 0 0 !important; color: var(--ink-dim) !important; font-size: 12px !important; }
.record-count { display: grid; min-width: 82px; padding: 9px 12px; border: 1px solid var(--line); border-radius: var(--radius-md); text-align: right; background: var(--surface-2); }
.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-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 > 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 table { min-width: 820px; }
.desktop-data-table table { table-layout: fixed; }
.desktop-data-table th:nth-child(1) { width: 24%; }
.desktop-data-table th:nth-child(2) { width: 12%; }
.desktop-data-table th:nth-child(3) { width: 15%; }
.desktop-data-table td:nth-child(1), .desktop-data-table td:nth-child(3), .desktop-data-table td:nth-child(4) { white-space: normal; overflow-wrap: anywhere; }
.table-kind-tools th:nth-child(4) { width: 32%; }
.table-kind-tools th:nth-child(5) { width: 7%; }
.table-kind-tools th:nth-child(6) { width: 10%; }
.table-kind-clients table { min-width: 940px; }
.table-kind-clients th:nth-child(1) { width: 20%; }
.table-kind-clients th:nth-child(2) { width: 10%; }
.table-kind-clients th:nth-child(3) { width: 15%; }
.table-kind-clients th:nth-child(4) { width: 30%; }
.table-kind-clients th:nth-child(5) { width: 25%; }
.table-kind-audit th:nth-child(4) { width: 29%; }
.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; }
.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; }
.client-safety h3, .approvals-panel h3 { margin-bottom: 8px; }
.boundary-badge { flex: 0 0 auto; padding: 8px 10px; border: 1px solid color-mix(in srgb, var(--accent) 30%, var(--line)); border-radius: var(--radius-md); color: var(--accent); background: color-mix(in srgb, var(--accent-dim) 55%, transparent); font: 9.5px/1.4 var(--font-mono); text-transform: uppercase; letter-spacing: .05em; }
/* Onboarding */
.onboarding-steps { position: relative; display: grid; grid-template-columns: repeat(3, 1fr); gap: 0; margin: 25px 0 21px; padding: 0; list-style: none; }
.onboarding-steps::before { content: ""; position: absolute; top: 15px; left: 16.66%; right: 16.66%; height: 1px; background: var(--line); }
.onboarding-steps li { position: relative; z-index: 1; display: flex; align-items: center; justify-content: center; gap: 9px; color: var(--ink-faint); }
.onboarding-steps li > span { display: grid; place-items: center; width: 30px; height: 30px; border: 1px solid var(--line); border-radius: 50%; background: var(--surface-1); font: 10px var(--font-mono); }
.onboarding-steps li.active > span { border-color: var(--accent); color: var(--surface-0); background: var(--accent); }
.onboarding-steps li > div { display: grid; }
.onboarding-steps strong { color: var(--paper); font-size: 10.5px; }
.onboarding-steps small { font-size: 9.5px; }
.onboarding-form { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.onboarding-form label > small { color: var(--ink-faint); font-size: 9.5px; font-weight: 400; line-height: 1.4; text-transform: none; letter-spacing: 0; }
.form-hint { margin: -10px 0 14px !important; color: var(--ink-faint) !important; font-size: 10.5px !important; }
.external-provisioning-note { display: flex; align-items: flex-start; gap: 9px; max-width: none !important; margin: 18px 0 0 !important; padding: 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-2); }
.external-provisioning-note > span:first-child { display: grid; place-items: center; flex: 0 0 auto; width: 20px; height: 20px; border: 1px solid var(--accent-dim); border-radius: 50%; color: var(--accent); font: 700 10px var(--font-mono); }
.external-provisioning-note > span:last-child { color: var(--ink-dim); font-size: 10.5px; }
.external-provisioning-note strong { color: var(--paper); }
.copy-status { margin-top: 10px !important; color: var(--status-success) !important; font: 10px/1.5 var(--font-mono) !important; }
/* Operations and charts */
.operations-overview .panel-heading { align-items: flex-start; }
.operations-overview .panel-heading > .button { display: inline-flex; align-items: center; gap: 7px; white-space: nowrap; }
.inline-loading { display: flex; align-items: center; justify-content: center; gap: 10px; min-height: 110px; color: var(--ink-faint); font-size: 11px; }
.spinner-small { width: 14px; height: 14px; margin: 0; border-width: 2px; }
.live-check-summary { margin-top: 20px; padding: 16px; border: 1px solid var(--line); border-left: 3px solid; border-radius: var(--radius-md); background: var(--surface-2); }
.live-check-healthy { border-left-color: var(--status-success); }
.live-check-attention { border-left-color: var(--status-danger); }
.live-check-summary > div:first-child { display: flex; align-items: center; gap: 10px; }
.live-check-summary > div:first-child small { margin-left: auto; color: var(--ink-faint); font: 9.5px var(--font-mono); }
.operations-grid { display: grid; grid-template-columns: minmax(320px, .72fr) minmax(520px, 1.28fr); gap: 18px; }
.generated-time { color: var(--ink-faint); font: 9.5px/1.4 var(--font-mono); }
.chart-legend { display: flex; flex-wrap: wrap; gap: 13px; margin-top: -3px; color: var(--ink-faint); font-size: 9.5px; }
.chart-legend > span { display: inline-flex; align-items: center; gap: 5px; }
.chart-legend i { display: block; width: 7px; height: 7px; border-radius: 2px; }
.legend-success, .trend-success { background: var(--status-success); }
.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-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); }
.trend-stack i { min-height: 0; }
.trend-empty { display: block; min-height: 4px; background: var(--line); }
.approvals-panel p { margin: 0 !important; }
/* Loading and error states */
.state-panel { width: min(100%, 700px); }
.loading-state { display: grid; grid-template-columns: auto 1fr; align-items: center; gap: 24px; text-align: left; }
.loading-state > div:nth-child(2) p { margin: 0; }
.state-orbit { position: relative; width: 76px; height: 76px; border: 1px solid var(--line); border-radius: 50%; }
.state-orbit::before, .state-orbit::after { content: ""; position: absolute; inset: 12px; border: 1px solid var(--line-soft); border-radius: 50%; }
.state-orbit::after { inset: 27px; border: 0; background: var(--accent); box-shadow: 0 0 18px color-mix(in srgb, var(--accent) 60%, transparent); }
.state-orbit span { position: absolute; top: 35px; left: 35px; width: 6px; height: 6px; border-radius: 50%; background: var(--status-success); transform-origin: 3px 3px; animation: orbit 2.4s linear infinite; }
.state-orbit span:nth-child(1) { transform: rotate(0deg) translateX(34px); }
.state-orbit span:nth-child(2) { animation-delay: -.8s; transform: rotate(120deg) translateX(34px); }
.state-orbit span:nth-child(3) { animation-delay: -1.6s; transform: rotate(240deg) translateX(34px); }
@keyframes orbit { to { rotate: 360deg; } }
.loading-skeleton { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.loading-skeleton span { height: 5px; border-radius: var(--radius-pill); background: linear-gradient(90deg, var(--line-soft), var(--line), var(--line-soft)); background-size: 200% 100%; animation: skeleton 1.6s ease infinite; }
.loading-skeleton span:nth-child(2) { animation-delay: -.35s; }
.loading-skeleton span:nth-child(3) { animation-delay: -.7s; }
@keyframes skeleton { to { background-position: -200% 0; } }
.state-actions .button, .button { display: inline-flex; align-items: center; justify-content: center; gap: 7px; }
.state-footnote { display: block; margin-top: 20px; color: var(--ink-faint); font: 9.5px/1.5 var(--font-mono); }
/* Responsive composition */
@media (max-width: 1220px) {
.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)); }
.auth-layout { grid-template-columns: minmax(340px, .9fr) minmax(450px, 1.1fr); }
.auth-copy, .auth-visual { padding: clamp(30px, 4vw, 52px); }
}
@media (max-width: 980px) {
.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; }
.sidebar { width: 220px; }
.operations-grid { grid-template-columns: 1fr; }
.secondary-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.command-trigger span, .command-trigger kbd { display: none; }
}
@media (max-width: 840px) {
.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; }
.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; }
.resource-toolbar { display: grid; grid-template-columns: 1fr; align-items: stretch; }
.filter-field { max-width: none; }
.table-controls { flex-wrap: wrap; }
.client-safety, .approvals-panel { align-items: flex-start; }
.auth-trust-list { grid-template-columns: 1fr; }
.auth-boundary { grid-template-columns: 1fr; gap: 8px; }
}
@media (max-width: 640px) {
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; }
.topbar-actions { gap: 6px; }
.topbar-title .eyebrow { display: none; }
.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; }
.auth-copy .preauth-brand { margin-bottom: 48px; }
.auth-copy h1 { font-size: clamp(36px, 12vw, 50px); }
.auth-lead { font-size: 13px; }
.auth-actions { align-items: flex-start; flex-direction: column; }
.auth-button { width: 100%; }
.auth-visual h2 { max-width: 20ch; font-size: 27px; }
.auth-visual .topology { margin-top: 24px; }
.topology { padding: 12px; }
.topology-head small { display: none; }
.topology-canvas { display: none; }
.topology-mobile-flow { display: grid; justify-items: stretch; width: min(100%, 250px); margin: 22px auto 18px; }
.topology-mobile-node { position: relative; display: grid; grid-template-columns: 34px 1fr auto; align-items: center; gap: 10px; min-height: 56px; padding: 9px 12px; border: 1px solid var(--line); border-radius: var(--radius-md); background: var(--surface-1); }
.topology-mobile-node > span:nth-child(2) { display: grid; gap: 2px; }
.topology-mobile-node strong { color: var(--paper); font-size: 11px; }
.topology-mobile-node small { color: var(--ink-faint); font: 8.5px/1.4 var(--font-mono); }
.topology-mobile-mark { display: grid; place-items: center; width: 32px; height: 32px; border: 1px solid var(--accent); border-radius: 50%; color: var(--accent); font: 600 11px var(--font-mono); background: var(--accent-dim); }
.topology-mobile-node > .topology-state { width: 7px; height: 7px; border-radius: 50%; }
.topology-mobile-link { width: 1px; height: 21px; margin: 0 auto; border-left: 1px dashed var(--accent); }
.topology-mobile-targets { display: flex; flex-wrap: wrap; justify-content: center; gap: 6px; }
.topology-mobile-targets > span { display: inline-flex; align-items: center; gap: 6px; min-height: 28px; padding: 5px 8px; border: 1px solid var(--line); border-radius: 999px; color: var(--paper); font-size: 9px; background: var(--surface-1); }
.topology-mobile-targets .topology-state { width: 6px; height: 6px; border-radius: 50%; }
.topology figcaption { display: block; }
.topology figcaption small { display: block; margin-top: 6px; }
.dashboard { gap: 14px; }
.dashboard-hero { padding: 22px 18px; gap: 24px; }
.dashboard-hero h2 { font-size: 31px; }
.hero-signal { margin-bottom: 22px; }
.hero-actions { display: grid; grid-template-columns: 1fr; }
.hero-meta { display: grid; gap: 3px; }
.hero-meta strong { margin: 0; }
.metric-grid, .secondary-grid { grid-template-columns: 1fr 1fr; gap: 10px; }
.metric-card { min-height: 116px; padding: 15px; }
.metric-card strong { font-size: 20px; }
.exception-item { align-items: flex-start; }
.exception-action > .icon { display: none; }
.resource-stack { gap: 13px; }
.resource-stack .resource-page, .panel { padding: 16px; }
.resource-heading { gap: 12px; }
.record-count { min-width: 70px; }
.table-controls > span { flex-basis: 100%; }
.pagination { align-items: flex-start; flex-direction: column; }
.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; }
.live-check-summary > div:first-child { align-items: flex-start; flex-wrap: wrap; }
.live-check-summary > div:first-child small { width: 100%; margin: 0; }
.trend-chart { height: 205px; }
.chart-legend { gap: 9px; }
.loading-state { grid-template-columns: 1fr; justify-items: center; text-align: center; }
.loading-skeleton { grid-template-columns: 1fr; width: 100%; }
.command-modal { align-self: start; margin-top: 8vh; }
}
@media (max-width: 420px) {
.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; }
.auth-visual-intro .tag { display: none; }
}
@media (prefers-reduced-motion: reduce) {
.topology-flow path, .state-orbit span, .loading-skeleton span { animation: none; }
.metric-card:hover { transform: none; }
}