Initial curated ITWorx_MCP_Hub source from 368fba691ea8d23403b4d56b53b5ec670d7c5020
This commit is contained in:
commit
cb5be898ea
663 files changed
+82674
No files matched your search
@@ -0,0 +1,27 @@
|
||||
.git
|
||||
.luna
|
||||
.venv
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
.pytest_cache
|
||||
node_modules
|
||||
apps/*/node_modules
|
||||
apps/*/dist
|
||||
apps/*/coverage
|
||||
__pycache__
|
||||
*.pyc
|
||||
.env
|
||||
.env.*
|
||||
secrets
|
||||
backups
|
||||
logs
|
||||
evidence
|
||||
docs
|
||||
tests
|
||||
implementation
|
||||
.claude
|
||||
.github
|
||||
*.md
|
||||
PACK_MANIFEST.json
|
||||
BUILD_STATE.json
|
||||
CURRENT_STATE.md
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
# secrets and local configuration
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
secrets/*
|
||||
!secrets/README.md
|
||||
*.pem
|
||||
*.key
|
||||
*.p12
|
||||
*.pfx
|
||||
.claude/settings.local.json
|
||||
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
dist/
|
||||
coverage/
|
||||
playwright-report/
|
||||
test-results/
|
||||
|
||||
# IDE / OS
|
||||
.vscode/
|
||||
.idea/
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# build and runtime
|
||||
artifacts/
|
||||
backups/
|
||||
logs/
|
||||
.tmp/
|
||||
|
||||
# WP-230D local E2E harness runtime state (throwaway TLS material + SQLite db)
|
||||
.wp230e2e/
|
||||
.luna/context.md
|
||||
|
||||
# subagent-driven-development scratch workspace (ledgers, briefs, reports)
|
||||
.superpowers/
|
||||
|
||||
# evidence is committed except large raw files
|
||||
evidence/**/*.log.gz
|
||||
evidence/**/*.tar
|
||||
evidence/**/*.tar.gz
|
||||
@@ -0,0 +1,7 @@
|
||||
[extend]
|
||||
useDefault = true
|
||||
[[allowlists]]
|
||||
description = "Boundary tests call demo token function with synthetic KEY constant, no credential"
|
||||
paths = ['tests/test_demo_token\.py$']
|
||||
regexTarget = "line"
|
||||
regexes = ['mint_demo_token\(KEY, ttl_hours=(MIN|MAX)_TTL_HOURS']
|
||||
@@ -0,0 +1,5 @@
|
||||
# License
|
||||
|
||||
Copyright © 2026 ITWorx / Jens.
|
||||
|
||||
This design pack and the resulting project are private and proprietary unless the owner explicitly chooses another license. Third-party dependencies retain their own licenses and notices.
|
||||
@@ -0,0 +1,3 @@
|
||||
# Public source export
|
||||
|
||||
This source snapshot was generated from the private canonical repository at revision `368fba691ea8d23403b4d56b53b5ec670d7c5020`. It contains no private Git history or operational evidence. Changes are published from the canonical repository.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,62 @@
|
||||
# ITWorx MCP Hub
|
||||
|
||||
A self-hosted governance platform for connecting AI clients to approved tools and knowledge. The Hub scopes capabilities by project, environment and client identity, while retaining policy decisions and audit attribution.
|
||||
|
||||
## What it provides
|
||||
|
||||
- A management UI for projects, readiness, policies, connector health and audit history.
|
||||
- An MCP dataplane and router with authenticated, bounded tool discovery and invocation.
|
||||
- Target-specific integrations for knowledge, Gitea, ProjectBrain, AppOps and infrastructure.
|
||||
- OIDC client onboarding and separately scoped service-to-service credentials.
|
||||
- Explicit deny-by-default authorization inside sensitive connectors.
|
||||
- PostgreSQL persistence, migrations, workers and recovery tooling.
|
||||
|
||||
The gateway, API and normal connectors never receive a Docker socket. Only the dedicated read broker may access it, through its purpose-built read-only interface. There is no general shell, unrestricted SQL or arbitrary filesystem tool.
|
||||
|
||||
## Architecture
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
Client[MCP client] --> Router[Authenticated dataplane]
|
||||
UI[Management UI] --> API[Control API]
|
||||
API --> DB[(PostgreSQL)]
|
||||
Router --> Policy[Scoped connector authorization]
|
||||
Policy --> Tools[Approved tools and knowledge]
|
||||
```
|
||||
|
||||
`apps/web` contains the React management UI; `services` contains runtime services; `connectors` supplies integrations; `packages` holds shared policy/contracts. Compose definitions, image build contexts and database migrations live in `infrastructure`.
|
||||
|
||||
## Deployment
|
||||
|
||||
Requirements: Docker with Compose, Python 3.13, Node.js 24 and pnpm 10.33. The production stack is intentionally configuration-driven and has no universal one-command public demo.
|
||||
|
||||
1. Copy `infrastructure/compose/.env.example` to an untracked `.env` in that directory.
|
||||
2. Replace every `REPLACE_*` input: dedicated host paths, bind addresses, OIDC issuer/client settings, project/resource bindings and immutable owned image references.
|
||||
3. Build the owned images from the supplied contexts and use immutable image digests. Provision the named secret files outside Git according to [Secret storage](infrastructure/secrets/README.md).
|
||||
4. Review [Compose requirements](infrastructure/compose/COMPOSE_REQUIREMENTS.md), configure TLS routing and run migrations before starting the full stack.
|
||||
5. Validate authentication, connector reachability and denied operations before onboarding clients.
|
||||
|
||||
```bash
|
||||
cd infrastructure/compose
|
||||
docker compose --env-file .env config --quiet
|
||||
# Start only after required inputs, secret files and image references are reviewed.
|
||||
docker compose --env-file .env up -d
|
||||
```
|
||||
|
||||
The public snapshot omits the owner's catalog project, workspace and resource bindings. Create your own scoped bindings before catalog reconciliation; no production project access is preconfigured.
|
||||
|
||||
External OIDC, registries and connector services are operator-supplied dependencies. Do not substitute real production credentials into examples. Optional connectors can remain unconfigured. The development override is an engineering environment, not an internet-ready deployment.
|
||||
|
||||
## Client onboarding and security
|
||||
|
||||
See [Client onboarding](docs/04-mcp/CLIENT_ONBOARDING.md) and [Security invariants](docs/02-security/SECURITY_INVARIANTS.md). A visible tool is not authorization to use it: project, environment, resource identity and caller privileges are checked independently. Report issues privately according to [SECURITY.md](SECURITY.md).
|
||||
|
||||
## Development
|
||||
|
||||
Python dependencies are locked by `uv.lock`; Node workspaces are locked by `pnpm-lock.yaml`. Tests cover authorization, contracts and runtime behavior. Canonical `python scripts/verify.py` additionally checks private design/evidence metadata; that complete archival gate belongs to the private repository. For public source development, install locked dependencies and run the relevant unit/integration tests and `apps/web` build. Integration/browser tests require their documented external services.
|
||||
|
||||
## Public source and license
|
||||
|
||||
`ITWorx_MCP_Hub-Public` is automatically exported from canonical private `main`. It contains reviewed source, tests and reusable deployment definitions, with example domains and paths substituted for operational topology. Private Git history, live inventories, evidence, internal Actions and production-specific delivery overrides are excluded.
|
||||
|
||||
The project remains proprietary under [LICENSE.md](LICENSE.md). Public visibility grants no additional reuse rights; third-party dependencies retain their licenses.
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
# Security Policy
|
||||
|
||||
## Supported code
|
||||
|
||||
Security fixes target the current `main` release line. Historical design-pack, work-packet and experiment branches are retained for traceability and are not separate supported release channels.
|
||||
|
||||
## Reporting vulnerabilities
|
||||
|
||||
Report suspected vulnerabilities privately to the repository owner. Do not publish access tokens, authorization headers, private infrastructure details, production configuration, audit data containing identifiers, signing material or exploit-sensitive evidence in a public issue.
|
||||
|
||||
A useful report includes the affected commit/component, minimal reproduction conditions using synthetic data where possible, expected and observed behaviour, impact, and whether the issue affects authentication, authorization, project/environment isolation, connector boundaries, secret redaction, Docker broker isolation, MCP framing/session handling or deployment controls.
|
||||
|
||||
## Control-plane boundary
|
||||
|
||||
The canonical MCP Hub is a privileged operational control plane. Preserve least-privilege connectors, explicit read/write separation, repository and workspace allowlists, optimistic locking, validation gates, attributable changes and fail-closed behaviour. Public examples or SDK material must never include operational credentials, private topology or production connector configuration.
|
||||
|
||||
## Security invariants
|
||||
|
||||
The normative security requirements in `docs/02-security/SECURITY_INVARIANTS.md` are release requirements. Contributions must preserve deny-by-default authorization, bounded target-specific connectors, secret non-disclosure, auditability, and the prohibition on unrestricted shell/filesystem/SQL/Docker capabilities through MCP.
|
||||
|
||||
Never commit live `.env` files, tokens, private keys, production secrets or unredacted sensitive runtime evidence.
|
||||
|
||||
## Disclosure
|
||||
|
||||
Coordinate remediation and disclosure with the repository owner before publishing technical details that would materially increase exploitation risk.
|
||||
@@ -0,0 +1,4 @@
|
||||
# Applications
|
||||
|
||||
Reserved for independently deployable Hub applications. WP-000 intentionally
|
||||
adds no runtime application.
|
||||
@@ -0,0 +1,15 @@
|
||||
import tsParser from "@typescript-eslint/parser";
|
||||
|
||||
export default [
|
||||
{
|
||||
ignores: ["dist/**", "coverage/**"],
|
||||
},
|
||||
{
|
||||
files: ["**/*.{ts,tsx}"],
|
||||
languageOptions: { parser: tsParser },
|
||||
rules: {
|
||||
"no-console": "error",
|
||||
"no-unused-vars": ["error", { args: "none" }],
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,16 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="theme-color" content="#0f131b" />
|
||||
<meta name="color-scheme" content="dark light" />
|
||||
<meta name="description" content="ITWorx MCP Hub governance and operations control plane." />
|
||||
<title>ITWorx MCP Hub</title>
|
||||
<link rel="icon" href="/favicon.svg" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"name": "@itworx-mcp-hub/web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"description": "Authenticated management shell and operational dashboard for ITWorx MCP Hub.",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"lint": "eslint .",
|
||||
"test": "vitest run --coverage",
|
||||
"test:e2e": "playwright test",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/inter": "5.3.0",
|
||||
"@fontsource/jetbrains-mono": "5.3.0",
|
||||
"react": "19.1.1",
|
||||
"react-dom": "19.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "4.11.0",
|
||||
"@playwright/test": "^1.62.1",
|
||||
"@types/node": "^26.1.2",
|
||||
"@types/react": "19.1.10",
|
||||
"@types/react-dom": "19.1.7",
|
||||
"@typescript-eslint/parser": "8.39.0",
|
||||
"@vitejs/plugin-react": "4.7.0",
|
||||
"@vitest/coverage-v8": "3.2.4",
|
||||
"eslint": "9.35.0",
|
||||
"eslint-plugin-react-hooks": "5.2.0",
|
||||
"typescript": "5.9.2",
|
||||
"vite": "7.1.3",
|
||||
"vitest": "3.2.4"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import path from "node:path";
|
||||
import { defineConfig } from "@playwright/test";
|
||||
|
||||
const WEB_DIR = process.cwd();
|
||||
const REPO_ROOT = path.resolve(WEB_DIR, "..", "..");
|
||||
const PYTHON = process.env.WP230E2E_PYTHON ?? path.join(REPO_ROOT, ".venv", "Scripts", "python.exe");
|
||||
const DIST = path.join(WEB_DIR, "dist");
|
||||
|
||||
const WEB_PORT = 8780;
|
||||
const CONTROL_PORT = 8781;
|
||||
const OIDC_PORT = 8782;
|
||||
|
||||
export default defineConfig({
|
||||
testDir: "./tests/e2e",
|
||||
testMatch: /.*\.spec\.ts/,
|
||||
fullyParallel: false,
|
||||
workers: 1,
|
||||
retries: 0,
|
||||
reporter: [["list"], ["html", { open: "never", outputFolder: "playwright-report" }]],
|
||||
use: {
|
||||
baseURL: `https://127.0.0.1:${WEB_PORT}`,
|
||||
ignoreHTTPSErrors: true,
|
||||
trace: "retain-on-failure",
|
||||
screenshot: "only-on-failure",
|
||||
},
|
||||
// Playwright starts array entries in order and waits for each `url` to
|
||||
// answer before starting the next -- required here because control-api
|
||||
// reads the CA file the oidc process writes at its own startup.
|
||||
webServer: [
|
||||
{
|
||||
command: `"${PYTHON}" -m tests.e2e.harness oidc --port ${OIDC_PORT}`,
|
||||
url: `https://127.0.0.1:${OIDC_PORT}/.well-known/openid-configuration`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: false,
|
||||
timeout: 90_000,
|
||||
cwd: REPO_ROOT,
|
||||
},
|
||||
{
|
||||
command: `"${PYTHON}" -m tests.e2e.harness control-api --port ${CONTROL_PORT} --oidc-port ${OIDC_PORT} --web-port ${WEB_PORT}`,
|
||||
url: `http://127.0.0.1:${CONTROL_PORT}/ready`,
|
||||
reuseExistingServer: false,
|
||||
timeout: 90_000,
|
||||
cwd: REPO_ROOT,
|
||||
},
|
||||
{
|
||||
command: `"${PYTHON}" -m tests.e2e.harness web --port ${WEB_PORT} --control-port ${CONTROL_PORT} --dist "${DIST}"`,
|
||||
url: `https://127.0.0.1:${WEB_PORT}/ready`,
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: false,
|
||||
timeout: 90_000,
|
||||
cwd: REPO_ROOT,
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="14" fill="#1b1815"/><circle cx="32" cy="32" r="20" fill="none" stroke="#e8a33d" stroke-width="4"/><circle cx="32" cy="32" r="8.5" fill="#e8a33d"/></svg>
|
||||
|
After Width: | Height: | Size: 246 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.2 MiB |
@@ -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."; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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.`;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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()}`); }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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));
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>; }
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { ApiError, createApiClient, logoutSession } from "../src/api";
|
||||
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("management API safety contracts", () => {
|
||||
it("uses same-origin server pagination and URL-addressable filters", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ kind: "tools", generatedAt: "2026-08-01T18:00:00Z", rows: [], page: { total: 0 } }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await createApiClient().resource("tools", { filter: "risk:R1", limit: 25 });
|
||||
expect(fetchMock).toHaveBeenCalledWith("/api/tools?q=risk%3AR1&limit=25", expect.objectContaining({ credentials: "same-origin", headers: { Accept: "application/json" } }));
|
||||
});
|
||||
it("maps protected client actions and never invents returned secret material", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const config = await createApiClient().generatedConfig("client-1");
|
||||
expect(config).toEqual({ clientId: "client-1", endpoint: "https://mcp.example.test/sse", scopes: ["tools:read"] });
|
||||
expect(JSON.stringify(config)).not.toContain("secret");
|
||||
expect(fetchMock.mock.calls[0]?.[1]).toEqual(expect.objectContaining({ credentials: "same-origin" }));
|
||||
});
|
||||
it("fails closed without CSRF and sends the session-bound token on writes", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-fixture" }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ decision: "deny", matchedPolicies: [], reasons: [], traceId: "trace" }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const api = createApiClient();
|
||||
await expect(api.simulatePolicy({ project: "p", environment: "prod", tool: "t" })).rejects.toBeInstanceOf(ApiError);
|
||||
await api.session();
|
||||
await api.simulatePolicy({ project: "p", environment: "prod", tool: "t" });
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-fixture" }) }));
|
||||
});
|
||||
it("logs out through the same-origin CSRF-protected BFF route", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-logout" }) })
|
||||
.mockResolvedValueOnce({ ok: true, status: 204 });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
await logoutSession();
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("/auth/logout");
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: "POST", headers: expect.objectContaining({ "X-CSRF-Token": "csrf-logout" }) }));
|
||||
});
|
||||
it("keeps resource listing separate from persistent client management routes", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-1" }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ status: "revoked" }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const api = createApiClient();
|
||||
await api.session();
|
||||
expect(await api.clientAction("client-1", "revoke")).toEqual({ accepted: true, message: "Client revoke completed." });
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/client-management/client-1/revoke");
|
||||
});
|
||||
it("posts bounded tool readiness input to the dedicated endpoint", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-ready" }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ ready: false, checks: {} }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const api = createApiClient();
|
||||
await api.session();
|
||||
await api.testTool({ project: "mobilityops", environment: "prod", tool: "mobilityops.vehicle.get", arguments: { vehicleRef: "V-42" } });
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/tools/test");
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ method: "POST", body: JSON.stringify({ project: "mobilityops", environment: "prod", tool: "mobilityops.vehicle.get", arguments: { vehicleRef: "V-42" } }) }));
|
||||
});
|
||||
it("invokes tools and operations checks only through CSRF-protected routes", async () => {
|
||||
const fetchMock = vi.fn()
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ authenticated: true, csrf_token: "csrf-live" }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ ok: true, traceId: "trace-1" }) })
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ healthy: true, connectors: {} }) });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const api = createApiClient();
|
||||
await api.session();
|
||||
const payload = { project: "mobilityops", environment: "prod", tool: "mobilityops.operations.summary", arguments: {} };
|
||||
await api.invokeTool(payload);
|
||||
await api.runOperationsCheck();
|
||||
expect(fetchMock.mock.calls[1]?.[0]).toBe("/api/tools/invoke");
|
||||
expect(fetchMock.mock.calls[2]?.[0]).toBe("/api/operations/check");
|
||||
expect(fetchMock.mock.calls[1]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-live" }) }));
|
||||
expect(fetchMock.mock.calls[2]?.[1]).toEqual(expect.objectContaining({ headers: expect.objectContaining({ "X-CSRF-Token": "csrf-live" }) }));
|
||||
});
|
||||
it("converts non-success responses to typed safe errors", async () => {
|
||||
vi.stubGlobal("fetch", vi.fn().mockResolvedValue({ ok: false, status: 403 }));
|
||||
await expect(createApiClient().dashboard()).rejects.toBeInstanceOf(ApiError);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bootstrapMessage, workspaceName } from "../src/bootstrap";
|
||||
|
||||
describe("web workspace bootstrap", () => {
|
||||
it("exposes a deterministic placeholder contract", () => {
|
||||
expect(workspaceName).toBe("ITWorx MCP Hub");
|
||||
expect(bootstrapMessage()).toContain("WP-120");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,9 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { isSnapshotStale, routeTitle } from "../src/contracts";
|
||||
import { formatBrusselsTime, healthLabel } from "../src/format";
|
||||
|
||||
describe("management shell contracts", () => {
|
||||
it("maps required routes to stable titles", () => { expect(routeTitle("/")).toBe("Overview"); expect(routeTitle("/projects/ludarium")).toBe("Project detail"); expect(routeTitle("/audit")).toBe("Audit"); expect(routeTitle("/missing")).toBe("Not found"); });
|
||||
it("marks invalid and old snapshots stale instead of implying health", () => { const snapshot = { generatedAt: "2026-08-01T10:00:00Z", staleAfterSeconds: 30 } as Parameters<typeof isSnapshotStale>[0]; expect(isSnapshotStale(snapshot, Date.parse("2026-08-01T10:00:31Z"))).toBe(true); expect(isSnapshotStale({ ...snapshot, generatedAt: "not-a-date" })).toBe(true); });
|
||||
it("formats operator times in Brussels and labels state textually", () => { expect(formatBrusselsTime("2026-08-01T10:00:00Z")).toContain("12:00"); expect(formatBrusselsTime(null)).toBe("Never recorded"); expect(healthLabel("unavailable")).toBe("Unavailable"); });
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, loginAs, test, type Persona } from "./fixtures";
|
||||
|
||||
const routes: { path: string; persona: Persona }[] = [
|
||||
{ path: "/", persona: "hub-viewer" },
|
||||
{ path: "/projects", persona: "hub-viewer" },
|
||||
{ path: "/servers", persona: "hub-viewer" },
|
||||
{ path: "/tools", persona: "hub-viewer" },
|
||||
{ path: "/connectors", persona: "hub-viewer" },
|
||||
{ path: "/clients", persona: "hub-viewer" },
|
||||
{ path: "/policies", persona: "hub-viewer" },
|
||||
{ path: "/operations", persona: "hub-viewer" },
|
||||
{ path: "/audit", persona: "hub-auditor" },
|
||||
{ path: "/settings", persona: "hub-viewer" },
|
||||
];
|
||||
|
||||
test.describe("accessibility and responsive baseline", () => {
|
||||
test("the unauthenticated gateway has landmarks, a skip link, and no serious axe findings", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "Skip to main content" })).toBeVisible();
|
||||
await expect(page.getByRole("main")).toHaveCount(1);
|
||||
const result = await new AxeBuilder({ page }).analyze();
|
||||
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? ""))).toEqual([]);
|
||||
});
|
||||
|
||||
for (const viewport of [{ width: 390, height: 844 }, { width: 1440, height: 900 }]) {
|
||||
test(`authorized routes have no serious axe findings or page overflow at ${viewport.width}px`, async ({ page }) => {
|
||||
test.setTimeout(90_000);
|
||||
await page.setViewportSize(viewport);
|
||||
for (const persona of ["hub-viewer", "hub-auditor"] as const) {
|
||||
const personaRoutes = routes.filter((route) => route.persona === persona);
|
||||
if (!personaRoutes.length) continue;
|
||||
await loginAs(page, persona);
|
||||
for (const route of personaRoutes) {
|
||||
await page.goto(route.path);
|
||||
await expect(page.getByRole("main")).toBeVisible();
|
||||
const overflow = await page.evaluate(() => document.documentElement.scrollWidth > window.innerWidth + 1);
|
||||
expect(overflow, `${route.path} must not cause page-level horizontal overflow`).toBe(false);
|
||||
const result = await new AxeBuilder({ page }).analyze();
|
||||
expect(result.violations.filter((violation) => ["serious", "critical"].includes(violation.impact ?? "")), route.path).toEqual([]);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
test("mobile drawer and modal preserve keyboard focus", async ({ page }) => {
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/");
|
||||
const trigger = page.getByRole("button", { name: "Open navigation" });
|
||||
await trigger.focus();
|
||||
await trigger.press("Enter");
|
||||
await page.getByRole("button", { name: "Close navigation" }).press("Escape");
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
test.describe("authentication", () => {
|
||||
test("an unauthenticated visitor sees the sign-in panel, not management data", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
|
||||
await expect(page.getByRole("link", { name: "Continue to sign in" })).toHaveAttribute("href", "/auth/login");
|
||||
// No management chrome or data leaks before authentication.
|
||||
await expect(page.getByRole("navigation", { name: "Primary navigation" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Open platform settings" })).toHaveCount(0);
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a real PKCE/BFF login lands on an authenticated dashboard with real data", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await expect(page).toHaveURL(/\/$/);
|
||||
await expect(page.getByText("Authenticated", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
|
||||
// Real backend data, not a placeholder: Ludarium is the seeded catalog project.
|
||||
await expect(page.getByText("Ludarium", { exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("an authenticated user can sign out and stale management data disappears", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/settings");
|
||||
await page.getByRole("button", { name: "Sign out" }).click();
|
||||
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("an invalidated session cookie safely falls back to the sign-in panel, not stale data", async ({
|
||||
page,
|
||||
context,
|
||||
}) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
|
||||
|
||||
const cookies = await context.cookies();
|
||||
const sessionCookie = cookies.find((cookie) => cookie.name === "hub_session");
|
||||
expect(sessionCookie).toBeDefined();
|
||||
await context.addCookies([{ ...sessionCookie!, value: "tampered-session-value" }]);
|
||||
|
||||
await page.reload();
|
||||
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
|
||||
// The stale dashboard from before tampering must not still be showing.
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("a mutating request with a missing CSRF token is safely rejected, not silently accepted", async ({
|
||||
page,
|
||||
}) => {
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.goto("/policies");
|
||||
await page.route("**/api/policies/simulate", async (route) => {
|
||||
const headers = { ...route.request().headers() };
|
||||
delete headers["x-csrf-token"];
|
||||
await route.continue({ headers });
|
||||
});
|
||||
await page.getByLabel("Project").selectOption({ index: 0 });
|
||||
await page.getByRole("button", { name: "Explain decision" }).click();
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
// No decision card was rendered from an unauthenticated-CSRF response.
|
||||
await expect(page.getByRole("status").filter({ hasText: "Matched:" })).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { test as base, expect, type Page } from "@playwright/test";
|
||||
|
||||
/** Matches services/control_api/auth/models.py's ManagementRole group names,
|
||||
* and tests/e2e/harness.py's ROLE_GROUPS -- these are the only group names
|
||||
* the real per-endpoint RBAC matrix (services/control_api/authz.py) maps to
|
||||
* a role at all; anything else is denied by default. */
|
||||
export type Persona = "hub-viewer" | "hub-auditor" | "hub-operator" | "hub-admin";
|
||||
|
||||
/** Drives the real PKCE/BFF login flow through a real browser navigation:
|
||||
* GET /auth/login (real 307 to the fake OIDC provider's real HTTPS
|
||||
* /authorize), rewriting only the *outbound request URL* to add a
|
||||
* `persona` hint the fake provider reads (see fake_provider.py) -- no
|
||||
* production auth code is touched or bypassed, only the request this test
|
||||
* harness itself sends to a fake dependency. */
|
||||
export async function loginAs(page: Page, persona: Persona): Promise<void> {
|
||||
// Rewriting the *navigation request itself* to /authorize (page.route
|
||||
// continue({url})) does not reliably apply to a same-navigation redirect
|
||||
// hop in Chromium -- verified by hand: the outbound request to
|
||||
// /authorize still carried no persona param, and the fake provider fell
|
||||
// back to its default group. Instead, intercept the /auth/login response
|
||||
// (a 307), fetch it with redirects disabled, and rewrite the outgoing
|
||||
// Location header before letting the browser follow it.
|
||||
await page.route("**/auth/login", async (route) => {
|
||||
const response = await route.fetch({ maxRedirects: 0 });
|
||||
const headers = response.headers();
|
||||
if (!headers.location) throw new Error("expected /auth/login to redirect with a Location header");
|
||||
const location = new URL(headers.location);
|
||||
location.searchParams.set("persona", persona);
|
||||
await route.fulfill({ status: response.status(), headers: { ...headers, location: location.toString() } });
|
||||
});
|
||||
await page.goto("/auth/login");
|
||||
await page.waitForLoadState("networkidle");
|
||||
await page.unroute("**/auth/login");
|
||||
}
|
||||
|
||||
export const test = base;
|
||||
export { expect };
|
||||
@@ -0,0 +1,56 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
// The persistent sidebar becomes an off-canvas drawer below 840px (see
|
||||
// styles.css's compact-navigation media block) -- these tests only make
|
||||
// sense at that width, so every test in this file pins a mobile viewport.
|
||||
test.use({ viewport: { width: 390, height: 844 } });
|
||||
|
||||
test.describe("mobile navigation drawer", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/");
|
||||
});
|
||||
|
||||
test("opens via the hamburger trigger and exposes its state to assistive tech", async ({ page }) => {
|
||||
const trigger = page.getByRole("button", { name: "Open navigation" });
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "false");
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
|
||||
|
||||
await trigger.click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
|
||||
await expect(trigger).toHaveAttribute("aria-expanded", "true");
|
||||
});
|
||||
|
||||
test("closes via its own close button", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
|
||||
await page.getByRole("button", { name: "Close navigation" }).click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("closes on Escape", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("closes on backdrop click", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
|
||||
// The backdrop spans the full viewport (position:fixed; inset:0), but
|
||||
// the open sidebar itself sits on top of its left ~240px and intercepts
|
||||
// pointer events there -- click well to the right of the drawer's own
|
||||
// width so the hit lands on the backdrop, not the drawer.
|
||||
await page.locator(".nav-backdrop").click({ position: { x: 350, y: 400 } });
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("closes automatically after navigating to another page", async ({ page }) => {
|
||||
await page.getByRole("button", { name: "Open navigation" }).click();
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(1);
|
||||
await page.getByRole("link", { name: "Catalog", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/tools$/);
|
||||
await expect(page.locator(".sidebar.sidebar-open")).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,88 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
test.describe("role-based authorization, driven end to end from the browser", () => {
|
||||
test("a viewer never sees the client-create form; an admin does", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/clients");
|
||||
await expect(page.getByRole("button", { name: "Create pending client" })).toHaveCount(0);
|
||||
await expect(page.getByText("Client creation requires the admin role.")).toBeVisible();
|
||||
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.goto("/clients");
|
||||
await expect(page.getByRole("button", { name: "Create pending client" })).toBeVisible();
|
||||
});
|
||||
|
||||
test("client.create is still server-enforced even if the hidden form were bypassed", async ({ page, request }) => {
|
||||
// Proves the security property independent of the UI: the hidden
|
||||
// button is a usability affordance, not the authorization boundary.
|
||||
await loginAs(page, "hub-viewer");
|
||||
const cookies = await page.context().cookies();
|
||||
const sessionCookie = cookies.find((cookie) => cookie.name.toLowerCase().includes("session"));
|
||||
if (!sessionCookie) throw new Error("expected a session cookie to be set after login");
|
||||
const sessionResponse = await request.get("/auth/session", { headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}` } });
|
||||
const { csrf_token: csrfToken } = await sessionResponse.json();
|
||||
const response = await request.post("/api/client-management", {
|
||||
headers: { Cookie: `${sessionCookie.name}=${sessionCookie.value}`, "X-CSRF-Token": csrfToken },
|
||||
data: { name: "e2e-bypass-attempt", project: "ludarium", environment: "prod", kind: "native-2026" },
|
||||
});
|
||||
expect(response.status()).toBe(403);
|
||||
});
|
||||
|
||||
test("an auditor can read the audit log; a viewer never sees the Audit nav link at all", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0);
|
||||
|
||||
await loginAs(page, "hub-auditor");
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("link", { name: "Audit" })).toBeVisible();
|
||||
await page.goto("/audit");
|
||||
await expect(page.getByRole("alert")).toHaveCount(0);
|
||||
await expect(page.getByText(/total · server paginated/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("a viewer cannot select the audit section of a project workspace", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/projects/ludarium?tab=Audit");
|
||||
await expect(page.getByRole("link", { name: "Audit" })).toHaveCount(0);
|
||||
await expect(page.getByRole("link", { name: "Summary" })).toHaveAttribute("aria-current", "page");
|
||||
});
|
||||
|
||||
test("an operator sees state-appropriate client actions; a viewer does not", async ({ page }) => {
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.goto("/clients");
|
||||
await page.getByLabel("Name").fill("e2e-operator-target");
|
||||
await page.getByRole("button", { name: "Create pending client" }).click();
|
||||
await expect(page.getByText(/Client created: e2e-operator-target/)).toBeVisible();
|
||||
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/clients");
|
||||
const viewerRow = page.getByRole("row", { name: /e2e-operator-target/ });
|
||||
await expect(viewerRow).toBeVisible();
|
||||
await expect(viewerRow.getByRole("button", { name: "Disable" })).toHaveCount(0);
|
||||
|
||||
await loginAs(page, "hub-operator");
|
||||
await page.goto("/clients");
|
||||
const operatorRow = page.getByRole("row", { name: /e2e-operator-target/ });
|
||||
await expect(operatorRow.getByRole("button", { name: "Disable" })).toBeDisabled();
|
||||
await operatorRow.getByRole("button", { name: "Revoke" }).click();
|
||||
// The confirmation modal's title is dynamic ("Revoke e2e-operator-target?"),
|
||||
// which makes an exact accessible-name match on the confirm button awkward
|
||||
// to pin reliably against the row-scoped locator above. This is the one
|
||||
// deliberate exception to this file's role/text-based query convention,
|
||||
// justified by the dynamic title: DataTable.tsx's modal confirm button
|
||||
// carries data-testid="confirm-action" specifically for this case.
|
||||
await page.getByTestId("confirm-action").click();
|
||||
await expect(page.getByText(/completed\./)).toBeVisible();
|
||||
});
|
||||
|
||||
test("tool readiness is hidden below operator and visible to an operator", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/tools");
|
||||
await expect(page.getByRole("button", { name: "Readiness" })).toHaveCount(0);
|
||||
|
||||
await loginAs(page, "hub-operator");
|
||||
await page.goto("/tools");
|
||||
await expect(page.getByRole("button", { name: "Readiness" }).first()).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
// Playwright's default browser color-scheme emulation is "light", which
|
||||
// would make styles.css's `@media (prefers-color-scheme: light)` block
|
||||
// apply even with no `data-theme` attribute set, collapsing the "dark"
|
||||
// and "light" reads below into the same value. Pin the emulated OS
|
||||
// preference to dark so the initial (no localStorage) state is
|
||||
// deterministic and the toggle's own explicit `data-theme` write -- not
|
||||
// an OS-preference coincidence -- is what the test is proving out.
|
||||
test.use({ colorScheme: "dark" });
|
||||
|
||||
test.describe("theme toggle", () => {
|
||||
test("switching to light theme persists across reload and changes the rendered background", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/settings");
|
||||
const darkBackground = await page.evaluate(() => getComputedStyle(document.documentElement).backgroundColor);
|
||||
await page.getByRole("button", { name: "Light" }).click();
|
||||
const lightBackground = await page.evaluate(() => getComputedStyle(document.documentElement).backgroundColor);
|
||||
expect(lightBackground).not.toBe(darkBackground);
|
||||
await page.reload();
|
||||
const afterReload = await page.evaluate(() => getComputedStyle(document.documentElement).backgroundColor);
|
||||
expect(afterReload).toBe(lightBackground);
|
||||
await page.getByRole("button", { name: "Dark" }).click();
|
||||
});
|
||||
|
||||
test("appearance toggle exposes which theme is selected, not just the applied state", async ({ page }) => {
|
||||
// Regression test alongside the density toggle fix: SettingsPage had no
|
||||
// state tracking the current theme at all, so Dark/Light always
|
||||
// rendered as the same solid pill regardless of which was active.
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/settings");
|
||||
const dark = page.getByRole("button", { name: "Dark" });
|
||||
const light = page.getByRole("button", { name: "Light" });
|
||||
await expect(dark).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(light).toHaveAttribute("aria-pressed", "false");
|
||||
await light.click();
|
||||
await expect(dark).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(light).toHaveAttribute("aria-pressed", "true");
|
||||
await dark.click();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,106 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
const captureReview = Boolean(process.env.SOL_VISUAL_REVIEW);
|
||||
|
||||
async function expectNoPageOverflow(page: import("@playwright/test").Page) {
|
||||
const dimensions = await page.evaluate(() => ({ documentWidth: document.documentElement.scrollWidth, viewportWidth: window.innerWidth }));
|
||||
expect(dimensions.documentWidth, `document width at ${dimensions.viewportWidth}px`).toBeLessThanOrEqual(dimensions.viewportWidth + 1);
|
||||
}
|
||||
|
||||
test.describe("product-quality interaction and composition", () => {
|
||||
test("the public gateway uses the available canvas and stays usable from desktop to mobile", async ({ page }, testInfo) => {
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Inspect your governed MCP estate." })).toBeVisible();
|
||||
await expect(page.getByLabel("How governed MCP access works")).toBeVisible();
|
||||
const desktopLayout = await page.locator(".auth-layout").boundingBox();
|
||||
expect(desktopLayout?.width ?? 0).toBeGreaterThan(1100);
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("public-desktop.png"), fullPage: true });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await expect(page.getByRole("link", { name: /Continue to sign in/ })).toBeVisible();
|
||||
await expect(page.getByText("Project scoped", { exact: true })).toBeVisible();
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("public-mobile.png"), fullPage: true });
|
||||
});
|
||||
|
||||
test("quick jump is keyboard-first, access-aware and routes without a reload", async ({ page }) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("button", { name: "Open quick jump" })).toBeVisible();
|
||||
await page.keyboard.press("Control+K");
|
||||
|
||||
const dialog = page.getByRole("dialog", { name: "Quick jump" });
|
||||
await expect(dialog).toBeVisible();
|
||||
const search = dialog.getByLabel("Find a workspace");
|
||||
await expect(search).toBeFocused();
|
||||
await expect(dialog.getByRole("button", { name: /Audit/ })).toHaveCount(0);
|
||||
|
||||
await search.fill("catalog");
|
||||
await dialog.getByRole("button", { name: /Catalog/ }).click();
|
||||
await expect(page).toHaveURL(/\/tools$/);
|
||||
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
|
||||
});
|
||||
|
||||
test("resource filtering is debounced, normalized and URL-addressable", async ({ page }) => {
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.goto("/tools");
|
||||
const filter = page.getByLabel("Filter records");
|
||||
await filter.fill(" Fleet Ops ");
|
||||
await expect(page).toHaveURL(/\/tools\?q=Fleet%20Ops$/);
|
||||
await expect(page.getByText("Updating results…")).toHaveCount(0);
|
||||
|
||||
await filter.fill(" ");
|
||||
await expect(page).toHaveURL(/\/tools$/);
|
||||
});
|
||||
|
||||
test("the authenticated control plane retains hierarchy and has no overflow at key viewports", async ({ page }, testInfo) => {
|
||||
await loginAs(page, "hub-viewer");
|
||||
for (const viewport of [{ width: 1440, height: 900 }, { width: 1280, height: 800 }, { width: 768, height: 1024 }, { width: 390, height: 844 }]) {
|
||||
await page.setViewportSize(viewport);
|
||||
await page.goto("/");
|
||||
await expect(page.getByRole("heading", { name: "Governed MCP, with the current signal first." })).toBeVisible();
|
||||
await expect(page.getByLabel("Governed MCP request path")).toBeVisible();
|
||||
const topbar = await page.locator(".topbar").boundingBox();
|
||||
expect(topbar?.y ?? Number.POSITIVE_INFINITY, `topbar position at ${viewport.width}px`).toBeLessThan(100);
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({
|
||||
path: testInfo.outputPath(`dashboard-${viewport.width}.png`),
|
||||
fullPage: false,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test("catalog, operations and onboarding remain workable beyond the dashboard", async ({ page }, testInfo) => {
|
||||
const consoleErrors: string[] = [];
|
||||
page.on("console", (message) => { if (message.type() === "error") consoleErrors.push(message.text()); });
|
||||
await loginAs(page, "hub-admin");
|
||||
await page.setViewportSize({ width: 1440, height: 900 });
|
||||
|
||||
await page.goto("/tools");
|
||||
await expect(page.getByRole("heading", { name: "Catalog", level: 2 })).toBeVisible();
|
||||
await expect(page.getByLabel("Filter records")).toBeVisible();
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-desktop.png"), fullPage: false });
|
||||
|
||||
await page.goto("/operations");
|
||||
await expect(page.getByRole("heading", { name: "Governed-call trend" })).toBeVisible();
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("operations-desktop.png"), fullPage: false });
|
||||
|
||||
await page.goto("/clients");
|
||||
const onboarding = page.locator(".onboarding-panel");
|
||||
await expect(onboarding).toBeVisible();
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await onboarding.screenshot({ path: testInfo.outputPath("client-onboarding.png") });
|
||||
|
||||
await page.setViewportSize({ width: 390, height: 844 });
|
||||
await page.goto("/tools");
|
||||
await expect(page.getByLabel("Filter records")).toBeVisible();
|
||||
await expectNoPageOverflow(page);
|
||||
if (captureReview) await page.screenshot({ path: testInfo.outputPath("catalog-mobile.png"), fullPage: false });
|
||||
|
||||
expect(consoleErrors).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,230 @@
|
||||
import { expect, loginAs, test } from "./fixtures";
|
||||
|
||||
test.describe("management workflows, driven end to end from the browser", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await loginAs(page, "hub-admin");
|
||||
});
|
||||
|
||||
test("tool catalog pagination is real: Next/Previous move through disjoint, server-issued pages", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/tools");
|
||||
const firstRowId = () => page.locator("table tbody tr").first().locator("small").first().textContent();
|
||||
const firstPageFirstId = await firstRowId();
|
||||
await expect(page.getByRole("button", { name: "Previous page" })).toBeDisabled();
|
||||
|
||||
await page.getByRole("button", { name: "Next page" }).click();
|
||||
await expect(page.locator("table tbody tr").first().locator("small").first()).not.toHaveText(firstPageFirstId ?? "");
|
||||
const secondPageFirstId = await firstRowId();
|
||||
|
||||
await page.getByRole("button", { name: "Previous page" }).click();
|
||||
await expect(page.locator("table tbody tr").first().locator("small").first()).toHaveText(firstPageFirstId ?? "");
|
||||
|
||||
await page.getByRole("button", { name: "Next page" }).click();
|
||||
await expect(page.locator("table tbody tr").first().locator("small").first()).toHaveText(secondPageFirstId ?? "");
|
||||
});
|
||||
|
||||
test("project detail renders real registered environments and connector bindings, not a fixed 'prod'", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/projects/ludarium?tab=Environments");
|
||||
await expect(page.getByLabel("Project detail sections").getByRole("link", { name: "Environments" })).toHaveAttribute("aria-current", "page");
|
||||
await expect(page.getByRole("cell", { name: "prod", exact: true })).toBeVisible();
|
||||
// ludarium never registered a staging environment (docs/known-state, and
|
||||
// tests/test_wp230_ui_completion.py's equivalent backend assertion).
|
||||
await expect(page.getByRole("cell", { name: "staging" })).toHaveCount(0);
|
||||
|
||||
await page.getByLabel("Project detail sections").getByRole("link", { name: "Connectors" }).click();
|
||||
await expect(page.getByRole("cell", { name: "knowledge", exact: true })).toBeVisible();
|
||||
});
|
||||
|
||||
test("an unregistered project is a real 404, not a fabricated default", async ({ page }) => {
|
||||
await page.goto("/projects/does-not-exist");
|
||||
await expect(page.getByRole("alert")).toBeVisible();
|
||||
await expect(page.getByText(/is not registered/)).toBeVisible();
|
||||
});
|
||||
|
||||
test("policy simulation is wired to the real engine: a known-allow case explains its decision", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/policies");
|
||||
await page.getByLabel("Project").selectOption({ label: "Ludarium" });
|
||||
await page.getByLabel("Environment").selectOption("prod");
|
||||
await page.getByLabel("Tool").selectOption({ label: "Knowledge Document Search" });
|
||||
await page.getByRole("button", { name: "Explain decision" }).click();
|
||||
|
||||
const decisionCard = page.getByRole("status").filter({ hasText: "Matched:" });
|
||||
await expect(decisionCard).toBeVisible();
|
||||
await expect(decisionCard.getByText(/Trace [0-9a-f-]{8,}/)).toBeVisible();
|
||||
await expect(decisionCard.getByText("Matched: none")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("catalog preview surfaces a real validation result, driven by a real project selector", async ({ page }) => {
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Validate and preview" }).click();
|
||||
const outcome = page.getByText(/Valid preview|Validation blocked/);
|
||||
await expect(outcome).toBeVisible();
|
||||
const applyButton = page.getByRole("button", { name: "Apply approved catalog" });
|
||||
if (await outcome.filter({ hasText: "Valid preview" }).count()) {
|
||||
await expect(applyButton).toBeEnabled();
|
||||
await applyButton.click();
|
||||
await page.getByRole("button", { name: "Confirm apply" }).click();
|
||||
} else {
|
||||
await expect(applyButton).toBeDisabled();
|
||||
}
|
||||
});
|
||||
|
||||
test("connector state is communicated as visible text, not color alone", async ({ page }) => {
|
||||
await page.goto("/connectors");
|
||||
await expect(page.locator("table tbody tr").first()).toBeVisible();
|
||||
const statusCells = page.locator("table tbody tr td .status");
|
||||
const count = await statusCells.count();
|
||||
expect(count).toBeGreaterThan(0);
|
||||
for (let index = 0; index < count; index += 1) {
|
||||
const text = (await statusCells.nth(index).innerText()).trim();
|
||||
expect(["Healthy", "Degraded", "Unavailable", "Unknown"]).toContain(text);
|
||||
}
|
||||
});
|
||||
|
||||
test("table density toggle changes row padding without changing row count or data", async ({ page }) => {
|
||||
await page.goto("/tools");
|
||||
await expect(page.locator("table tbody tr").first()).toBeVisible();
|
||||
const rowCountBefore = await page.locator("table tbody tr").count();
|
||||
await page.getByRole("button", { name: "Compact" }).click();
|
||||
await expect(page.locator(".table-wrap")).toHaveClass(/density-compact/);
|
||||
expect(await page.locator("table tbody tr").count()).toBe(rowCountBefore);
|
||||
await page.getByRole("button", { name: "Comfortable" }).click();
|
||||
await expect(page.locator(".table-wrap")).toHaveClass(/density-comfortable/);
|
||||
});
|
||||
|
||||
test("density toggle exposes which option is selected, not just the applied state", async ({ page }) => {
|
||||
// Regression test for a real bug found during WP-235's visual polish
|
||||
// pass: the Stitch retokenization made .button's default look a solid
|
||||
// accent pill, which is also what the "active" class resolved to --
|
||||
// Comfortable and Compact rendered identically, so neither the sighted
|
||||
// UI nor assistive tech could tell which density was selected.
|
||||
await page.goto("/tools");
|
||||
const comfortable = page.getByRole("button", { name: "Comfortable" });
|
||||
const compact = page.getByRole("button", { name: "Compact" });
|
||||
await expect(comfortable).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(compact).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(compact).toHaveClass(/button-secondary/);
|
||||
await compact.click();
|
||||
await expect(comfortable).toHaveAttribute("aria-pressed", "false");
|
||||
await expect(compact).toHaveAttribute("aria-pressed", "true");
|
||||
await expect(comfortable).toHaveClass(/button-secondary/);
|
||||
});
|
||||
|
||||
test("policy simulation renders a step-by-step decision trace, not just the top-line result", async ({ page }) => {
|
||||
await page.goto("/policies");
|
||||
await page.getByLabel("Project").selectOption({ label: "Ludarium" });
|
||||
await page.getByLabel("Environment").selectOption("prod");
|
||||
await page.getByLabel("Tool").selectOption({ label: "Knowledge Document Search" });
|
||||
await page.getByRole("button", { name: "Explain decision" }).click();
|
||||
|
||||
await expect(page.getByText("Decision logic trace")).toBeVisible();
|
||||
const steps = page.locator(".trace-step");
|
||||
await expect(steps.first()).toBeVisible();
|
||||
expect(await steps.count()).toBeGreaterThan(0);
|
||||
// Every rendered step must carry a real phase name from the engine's
|
||||
// trace, not an empty placeholder row.
|
||||
const firstStepPhase = await steps.first().locator("strong").innerText();
|
||||
expect(firstStepPhase.trim().length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test("generated client configuration never renders secret material into the DOM", async ({ page }) => {
|
||||
await page.goto("/clients");
|
||||
await page.getByLabel("Name").fill("e2e-config-check");
|
||||
await page.getByRole("button", { name: "Create pending client" }).click();
|
||||
await expect(page.getByText(/Client created: e2e-config-check/)).toBeVisible();
|
||||
|
||||
await page.reload();
|
||||
const targetRow = page.getByRole("row", { name: /e2e-config-check/ });
|
||||
await expect(targetRow).toBeVisible();
|
||||
await targetRow.getByRole("button", { name: "Config" }).click();
|
||||
|
||||
await expect(page.getByText(/Generated configuration for/)).toBeVisible();
|
||||
const bodyText = await page.locator("body").innerText();
|
||||
expect(bodyText).not.toMatch(/"secret"|client_secret|bearer [a-z0-9]{20,}/i);
|
||||
expect(bodyText).toContain("Secret material is write-only");
|
||||
});
|
||||
|
||||
test("client onboarding does not claim that the Hub provisions an Authentik identity", async ({ page }) => {
|
||||
await page.goto("/clients");
|
||||
await expect(page.getByText(/External identity provisioning is a separate administrator task/)).toBeVisible();
|
||||
await expect(page.getByText(/Authentik hand-off reference/)).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("Operations page renders the same real drift/backup/denial data as the dashboard, not a fabricated summary", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
const dashboardDrift = await page.locator(".metric-card", { hasText: "Reconcile drift" }).locator("strong").innerText();
|
||||
await page.goto("/operations");
|
||||
await expect(page.getByRole("heading", { name: "Operations" })).toBeVisible();
|
||||
const operationsDrift = await page.locator(".metric-card", { hasText: "Reconcile drift" }).locator("strong").innerText();
|
||||
expect(operationsDrift).toBe(dashboardDrift);
|
||||
});
|
||||
|
||||
test("tool readiness validates the full registered path without invoking the tool", async ({ page }) => {
|
||||
await page.goto("/tools");
|
||||
await page.getByRole("button", { name: "Validate and preview" }).click();
|
||||
await expect(page.getByText("Valid preview")).toBeVisible();
|
||||
await page.getByRole("button", { name: "Apply approved catalog" }).click();
|
||||
await page.getByRole("button", { name: "Confirm apply" }).click();
|
||||
await expect(page.getByText(/Catalog reconciliation completed and verified/)).toBeVisible();
|
||||
|
||||
await page.getByLabel("Filter records").fill("Fleet Ops Operations Summary");
|
||||
const row = page.getByRole("row", { name: /Fleet Ops Operations Summary/ });
|
||||
await row.getByRole("button", { name: "Readiness" }).click();
|
||||
await page.getByRole("dialog").getByLabel("Project").selectOption({ label: "Fleet Ops" });
|
||||
await page.getByRole("dialog").getByLabel("Environment").selectOption("prod");
|
||||
await page.getByRole("button", { name: "Run readiness check" }).click();
|
||||
|
||||
await expect(page.getByRole("dialog").getByText("Ready", { exact: true })).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByText(/no MCP tool invocation was performed/i)).toBeVisible();
|
||||
await expect(page.getByRole("dialog").getByText(/healthy/i).first()).toBeVisible();
|
||||
});
|
||||
|
||||
test("closing a Modal returns keyboard focus to the element that opened it, not document.body", async ({ page }) => {
|
||||
// Regression test for a real bug found during WP-235 Task 12's manual
|
||||
// accessibility pass: Modal moved focus into the dialog on open but
|
||||
// never restored it on close, silently dropping keyboard users back to
|
||||
// document.body. Uses the tools "Readiness" button's Modal as an isolated,
|
||||
// side-effect-free trigger.
|
||||
await page.goto("/tools");
|
||||
const trigger = page.locator("table tbody tr").first().getByRole("button", { name: "Readiness" });
|
||||
await trigger.click();
|
||||
await expect(page.getByRole("dialog")).toBeVisible();
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(0);
|
||||
await expect(trigger).toBeFocused();
|
||||
});
|
||||
|
||||
test("an open Modal locks background scroll and traps keyboard focus", async ({ page }) => {
|
||||
await page.goto("/tools");
|
||||
await page.locator("table tbody tr").first().getByRole("button", { name: "Readiness" }).click();
|
||||
const dialog = page.getByRole("dialog");
|
||||
await expect(dialog).toBeVisible();
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("hidden");
|
||||
await page.keyboard.press("Control+K");
|
||||
await expect(page.getByRole("dialog")).toHaveCount(1);
|
||||
await expect(page.getByRole("dialog", { name: "Quick jump" })).toHaveCount(0);
|
||||
|
||||
const close = dialog.getByRole("button", { name: "Close dialog" });
|
||||
const readiness = dialog.getByRole("button", { name: "Run readiness check" });
|
||||
await expect(readiness).toBeEnabled();
|
||||
await close.focus();
|
||||
await page.keyboard.press("Shift+Tab");
|
||||
await expect(readiness).toBeFocused();
|
||||
await page.keyboard.press("Tab");
|
||||
await expect(close).toBeFocused();
|
||||
|
||||
await close.click();
|
||||
await expect.poll(() => page.evaluate(() => document.body.style.overflow)).toBe("");
|
||||
});
|
||||
|
||||
test("Operations page shows an honest placeholder for approvals, not a fabricated queue", async ({ page }) => {
|
||||
await page.goto("/operations");
|
||||
await expect(page.getByRole("heading", { name: "Approvals" })).toBeVisible();
|
||||
await expect(page.getByText(/Approval workflow is not yet part of the authorized v1 management surface/)).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { can } from "../src/rbac";
|
||||
|
||||
describe("can", () => {
|
||||
it("allows every role to read the dashboard", () => {
|
||||
expect(can(["viewer"], "dashboard.read")).toBe(true);
|
||||
expect(can(["auditor"], "dashboard.read")).toBe(true);
|
||||
expect(can(["operator"], "dashboard.read")).toBe(true);
|
||||
expect(can(["admin"], "dashboard.read")).toBe(true);
|
||||
});
|
||||
|
||||
it("denies audit.read to a viewer but allows it for auditor and above", () => {
|
||||
expect(can(["viewer"], "audit.read")).toBe(false);
|
||||
expect(can(["auditor"], "audit.read")).toBe(true);
|
||||
expect(can(["operator"], "audit.read")).toBe(true);
|
||||
expect(can(["admin"], "audit.read")).toBe(true);
|
||||
});
|
||||
|
||||
it("restricts client.disable and client.revoke to operator and admin", () => {
|
||||
expect(can(["viewer"], "client.disable")).toBe(false);
|
||||
expect(can(["auditor"], "client.disable")).toBe(false);
|
||||
expect(can(["operator"], "client.disable")).toBe(true);
|
||||
expect(can(["admin"], "client.disable")).toBe(true);
|
||||
expect(can(["operator"], "client.revoke")).toBe(true);
|
||||
expect(can(["viewer"], "client.revoke")).toBe(false);
|
||||
});
|
||||
|
||||
it("restricts live tool readiness checks to operator and admin", () => {
|
||||
expect(can(["viewer"], "tool.test")).toBe(false);
|
||||
expect(can(["auditor"], "tool.test")).toBe(false);
|
||||
expect(can(["operator"], "tool.test")).toBe(true);
|
||||
expect(can(["admin"], "tool.test")).toBe(true);
|
||||
});
|
||||
|
||||
it("restricts actual tool invocation and operational probes to operator and admin", () => {
|
||||
for (const action of ["tool.invoke", "operations.check"] as const) {
|
||||
expect(can(["viewer"], action)).toBe(false);
|
||||
expect(can(["auditor"], action)).toBe(false);
|
||||
expect(can(["operator"], action)).toBe(true);
|
||||
expect(can(["admin"], action)).toBe(true);
|
||||
}
|
||||
});
|
||||
|
||||
it("restricts client.create and catalog.apply to admin only", () => {
|
||||
expect(can(["operator"], "client.create")).toBe(false);
|
||||
expect(can(["admin"], "client.create")).toBe(true);
|
||||
expect(can(["operator"], "catalog.apply")).toBe(false);
|
||||
expect(can(["admin"], "catalog.apply")).toBe(true);
|
||||
});
|
||||
|
||||
it("allows policy.simulate and catalog.preview for every role, side-effect-free reads", () => {
|
||||
expect(can(["viewer"], "policy.simulate")).toBe(true);
|
||||
expect(can(["viewer"], "catalog.preview")).toBe(true);
|
||||
});
|
||||
|
||||
it("returns false for an empty role list, never throws", () => {
|
||||
expect(can([], "dashboard.read")).toBe(false);
|
||||
});
|
||||
|
||||
it("is permissive if the principal holds any one sufficient role among several", () => {
|
||||
expect(can(["viewer", "admin"], "client.create")).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"jsx": "react-jsx",
|
||||
"isolatedModules": true,
|
||||
"noUncheckedIndexedAccess": true
|
||||
},
|
||||
"include": ["src", "tests", "vite.config.ts", "playwright.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { defineConfig } from "vitest/config";
|
||||
import react from "@vitejs/plugin-react";
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
test: {
|
||||
environment: "node",
|
||||
include: ["tests/**/*.test.ts"],
|
||||
coverage: { reporter: ["text", "json-summary"] },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# Declarative catalog
|
||||
|
||||
This directory is the desired-state source for ITWorx MCP Hub. Catalog documents use **JSON-compatible YAML**: every `.yaml` file is valid YAML 1.2 and also valid JSON. This deliberate restriction lets the design pack validate the catalog using only Python's standard library before `uv`, PyYAML, or application dependencies exist.
|
||||
|
||||
## Reconciliation rules
|
||||
|
||||
1. Parse and validate every document against `contracts/schemas/`.
|
||||
2. Normalize slugs and endpoint aliases.
|
||||
3. Resolve template references without reading secrets.
|
||||
4. Calculate a deterministic desired-state hash.
|
||||
5. Produce a preview with create/update/disable/no-op actions.
|
||||
6. Reject destructive or privilege-expanding drift unless the active policy permits it.
|
||||
7. Apply through the ContextForge adapter and Hub repositories.
|
||||
8. Read back actual state and compare it with desired state.
|
||||
9. Record an append-only audit event and reconciliation evidence.
|
||||
|
||||
Catalog files never contain credentials. They contain references such as `docker-secret://gitea_mcp_token` or `authentik-client://itworx-mcp-hub`.
|
||||
|
||||
## Verification states
|
||||
|
||||
- `verified`: discovered and tested against the real target;
|
||||
- `unverified`: plausible desired state that must not be reported as live;
|
||||
- `external-input-required`: implementation is complete but a credential, URL, or target mapping is unavailable;
|
||||
- `fake-only`: deterministic fake is the current test binding.
|
||||
|
||||
The initial project files intentionally use discovery placeholders. `WP-145` replaces only facts it can prove and retains explicit placeholders for everything else.
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {"name": "AppOps Operator", "slug": "appops-operator"},
|
||||
"spec": {
|
||||
"description": "Bounded server inspection and typed AppOps operations governed by identity, binding and exact-target policy without shell or Docker socket access.",
|
||||
"allowedRiskClasses": ["R0", "R1", "R2", "R3"],
|
||||
"scopeTemplates": ["project:{project}:read", "project:{project}:write"],
|
||||
"toolAllowPatterns": ["server.*"],
|
||||
"toolDenyPatterns": ["server.*.admin", "server.shell.*", "server.secret.*", "server.docker.*"],
|
||||
"environmentConstraints": {"productionWrites": true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {
|
||||
"name": "Developer",
|
||||
"slug": "developer"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Read capabilities plus bounded non-production developer workflows. Write tools remain absent from v1.0.",
|
||||
"allowedRiskClasses": [
|
||||
"R0",
|
||||
"R1",
|
||||
"R2"
|
||||
],
|
||||
"scopeTemplates": [
|
||||
"hub:catalog:read",
|
||||
"project:{project}:read",
|
||||
"project:{project}:develop"
|
||||
],
|
||||
"toolAllowPatterns": [
|
||||
"project.status.get",
|
||||
"knowledge.*",
|
||||
"gitea.*.list",
|
||||
"gitea.repository.get",
|
||||
"gitea.code.search",
|
||||
"gitea.file.read",
|
||||
"deployment.project.status",
|
||||
"deployment.stack.inspect"
|
||||
],
|
||||
"toolDenyPatterns": [
|
||||
"*.delete",
|
||||
"*.execute",
|
||||
"*.restart",
|
||||
"*.redeploy",
|
||||
"*.rollback",
|
||||
"database.query.*"
|
||||
],
|
||||
"environmentConstraints": {
|
||||
"productionWrites": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {
|
||||
"name": "Gitea Controlled Writer",
|
||||
"slug": "gitea-controlled-writer"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Bounded production writes through safe change branches, atomic changesets, managed validation, reviewed pull requests, verified exact-head merge and merged-branch cleanup.",
|
||||
"allowedRiskClasses": ["R2", "R3"],
|
||||
"scopeTemplates": ["project:{project}:read", "project:{project}:write"],
|
||||
"toolAllowPatterns": [
|
||||
"gitea.repository.create",
|
||||
"gitea.branchprotection.ensure",
|
||||
"gitea.portfolio.governance.apply",
|
||||
"gitea.branch.create",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.update",
|
||||
"gitea.issue.comment.create",
|
||||
"gitea.file.create",
|
||||
"gitea.file.update",
|
||||
"gitea.file.delete",
|
||||
"gitea.pullrequest.create",
|
||||
"gitea.pullrequest.update",
|
||||
"gitea.pullrequest.close",
|
||||
"gitea.pullrequest.validation.ensure",
|
||||
"gitea.pullrequest.sync",
|
||||
"gitea.pullrequest.merge",
|
||||
"gitea.branch.delete",
|
||||
"gitea.changeset.apply",
|
||||
"gitea.validation.run",
|
||||
"gitea.workspace.run",
|
||||
"gitea.orchestration.start"
|
||||
],
|
||||
"toolDenyPatterns": [
|
||||
"gitea.repository.delete",
|
||||
"gitea.*.admin",
|
||||
"gitea.*.force*",
|
||||
"gitea.repository.*.update"
|
||||
],
|
||||
"environmentConstraints": {"productionWrites": true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {
|
||||
"name": "Infrastructure Reader",
|
||||
"slug": "infrastructure-reader"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Read-only Unraid and internal Hub observability with secret-safe output.",
|
||||
"allowedRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
],
|
||||
"scopeTemplates": [
|
||||
"project:{project}:read",
|
||||
"infra:unraid:read"
|
||||
],
|
||||
"toolAllowPatterns": [
|
||||
"project.status.get",
|
||||
"unraid.host.*",
|
||||
"unraid.storage.*",
|
||||
"unraid.container.list",
|
||||
"unraid.container.inspect",
|
||||
"unraid.container.health",
|
||||
"unraid.container.logs",
|
||||
"unraid.network.list",
|
||||
"observability.health.summary",
|
||||
"observability.logs.search"
|
||||
],
|
||||
"toolDenyPatterns": [
|
||||
"*.create",
|
||||
"*.update",
|
||||
"*.delete",
|
||||
"*.execute",
|
||||
"*.restart",
|
||||
"*.rotate",
|
||||
"*.renew"
|
||||
],
|
||||
"environmentConstraints": {
|
||||
"productionWrites": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {"name": "Knowledge Hub reviewed intake", "slug": "knowledge-intake"},
|
||||
"spec": {
|
||||
"description": "Read and compare Knowledge Hub content, then submit bounded document proposals for human review; no publication capability.",
|
||||
"allowedRiskClasses": ["R1", "R2"],
|
||||
"scopeTemplates": ["project:{project}:read", "project:{project}:write"],
|
||||
"toolAllowPatterns": ["knowledge.ingestion.search", "knowledge.ingestion.context", "knowledge.ingestion.extract", "knowledge.workspace.roots", "knowledge.workspace.children", "knowledge.workspace.page", "knowledge.ingestion.analyze", "knowledge.ingestion.text_proposal", "knowledge.ingestion.submit", "knowledge.ingestion.status"],
|
||||
"toolDenyPatterns": ["*.publish", "*.approve", "*.delete"],
|
||||
"environmentConstraints": {"productionWrites": true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {
|
||||
"name": "Fleet Ops Reader",
|
||||
"slug": "mobilityops-reader"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Read-only Fleet Ops operational summary, attention list, vehicle status and knowledge search.",
|
||||
"allowedRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
],
|
||||
"scopeTemplates": [
|
||||
"hub:catalog:read",
|
||||
"project:{project}:read"
|
||||
],
|
||||
"toolAllowPatterns": [
|
||||
"project.status.get",
|
||||
"mobilityops.*"
|
||||
],
|
||||
"toolDenyPatterns": [
|
||||
"*.create",
|
||||
"*.update",
|
||||
"*.delete",
|
||||
"*.execute",
|
||||
"*.restart",
|
||||
"*.redeploy",
|
||||
"*.rollback"
|
||||
],
|
||||
"environmentConstraints": {
|
||||
"productionWrites": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {"name": "OpenCode Governed Agent", "slug": "opencode-agent"},
|
||||
"spec": {
|
||||
"description": "Preview-gated asynchronous OpenCode jobs in explicitly registered workspaces and exact allowlisted models.",
|
||||
"allowedRiskClasses": ["R0", "R1", "R2", "R3"],
|
||||
"scopeTemplates": ["project:{project}:read", "project:{project}:write"],
|
||||
"toolAllowPatterns": ["opencode.*"],
|
||||
"toolDenyPatterns": ["opencode.*.shell*", "opencode.*.admin*", "opencode.*.provider*"],
|
||||
"environmentConstraints": {"productionWrites": true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {"name": "OpenCode Read-Only Analysis", "slug": "opencode-analysis"},
|
||||
"spec": {
|
||||
"description": "Optional asynchronous plan analysis with a deliberately read-only workspace mount.",
|
||||
"allowedRiskClasses": ["R0", "R1"],
|
||||
"scopeTemplates": ["project:{project}:read"],
|
||||
"toolAllowPatterns": ["opencode.analysis.*", "opencode.delivery.repositories.list", "opencode.delivery.jobs.list", "opencode.delivery.status", "opencode.delivery.result"],
|
||||
"toolDenyPatterns": ["opencode.job.*", "opencode.analysis.*.shell*", "opencode.analysis.*.admin*"],
|
||||
"environmentConstraints": {"productionWrites": false}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {
|
||||
"name": "Production Observer",
|
||||
"slug": "production-observer"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Sanitized production health, deployment state, metadata and bounded logs.",
|
||||
"allowedRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
],
|
||||
"scopeTemplates": [
|
||||
"project:{project}:read",
|
||||
"environment:prod:observe"
|
||||
],
|
||||
"toolAllowPatterns": [
|
||||
"project.status.get",
|
||||
"unraid.container.list",
|
||||
"unraid.container.inspect",
|
||||
"unraid.container.health",
|
||||
"unraid.container.logs",
|
||||
"unraid.project.summary",
|
||||
"deployment.project.status",
|
||||
"deployment.stack.inspect",
|
||||
"observability.health.summary",
|
||||
"observability.logs.search"
|
||||
],
|
||||
"toolDenyPatterns": [
|
||||
"*.execute",
|
||||
"*.restart",
|
||||
"*.redeploy",
|
||||
"*.rollback"
|
||||
],
|
||||
"environmentConstraints": {
|
||||
"productionWrites": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {
|
||||
"name": "Project Reader",
|
||||
"slug": "project-reader"
|
||||
},
|
||||
"spec": {
|
||||
"description": "Project knowledge, repository metadata and sanitized health/log visibility without writes.",
|
||||
"allowedRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
],
|
||||
"scopeTemplates": [
|
||||
"hub:catalog:read",
|
||||
"project:{project}:read"
|
||||
],
|
||||
"toolAllowPatterns": [
|
||||
"project.status.get",
|
||||
"knowledge.*",
|
||||
"gitea.capabilities.get",
|
||||
"gitea.connection.check",
|
||||
"gitea.selftest.run",
|
||||
"gitea.repository.get",
|
||||
"gitea.repository.context",
|
||||
"gitea.repository.health",
|
||||
"gitea.branchprotection.get",
|
||||
"gitea.branchprotection.preview",
|
||||
"gitea.branch.list",
|
||||
"gitea.commit.list",
|
||||
"gitea.commit.get",
|
||||
"gitea.ref.compare",
|
||||
"gitea.release.list",
|
||||
"gitea.issue.list",
|
||||
"gitea.issue.search",
|
||||
"gitea.pullrequest.list",
|
||||
"gitea.pullrequest.get",
|
||||
"gitea.pullrequest.feedback",
|
||||
"gitea.commitstatus.list",
|
||||
"gitea.code.search",
|
||||
"gitea.research.search",
|
||||
"gitea.research.fetch",
|
||||
"gitea.file.read",
|
||||
"gitea.tree.list",
|
||||
"gitea.changeset.preview",
|
||||
"gitea.validation.get",
|
||||
"gitea.validation.failure.get",
|
||||
"gitea.validation.list",
|
||||
"gitea.change.status",
|
||||
"gitea.change.review",
|
||||
"gitea.change.reconcile",
|
||||
"gitea.workspace.status",
|
||||
"gitea.portfolio.health",
|
||||
"gitea.portfolio.governance.preview",
|
||||
"gitea.portfolio.governance.verify",
|
||||
"gitea.orchestration.plan",
|
||||
"deployment.project.status",
|
||||
"deployment.stack.inspect"
|
||||
],
|
||||
"toolDenyPatterns": [
|
||||
"*.create",
|
||||
"*.update",
|
||||
"*.delete",
|
||||
"*.execute",
|
||||
"*.restart",
|
||||
"*.redeploy",
|
||||
"*.rollback"
|
||||
],
|
||||
"environmentConstraints": {
|
||||
"productionWrites": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "CapabilityPack",
|
||||
"metadata": {"name": "ProjectBrain Operator", "slug": "projectbrain-operator"},
|
||||
"spec": {
|
||||
"description": "Durable project context, review-safe memory, governed feature delivery and execution feedback through ProjectBrain.",
|
||||
"allowedRiskClasses": ["R0", "R1", "R2"],
|
||||
"scopeTemplates": ["project:{project}:read", "project:{project}:write"],
|
||||
"toolAllowPatterns": ["projectbrain.*"],
|
||||
"toolDenyPatterns": ["projectbrain.*.admin", "projectbrain.*.force*"],
|
||||
"environmentConstraints": {"productionWrites": true}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"apiVersion":"hub.example.com/v1alpha1","kind":"CapabilityPack","metadata":{"name":"Google Stitch Designer","slug":"stitch-designer"},"spec":{"description":"Curated Google Stitch project and screen design workflow without credential exposure or destructive tools.","allowedRiskClasses":["R1","R2"],"scopeTemplates":["project:{project}:read","project:{project}:write"],"toolAllowPatterns":["stitch.*"],"toolDenyPatterns":["stitch.*.delete","stitch.*.admin"],"environmentConstraints":{"productionWrites":true}}}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {"name": "AppOps governed connector", "slug": "appops-default"},
|
||||
"spec": {
|
||||
"connectorType": "appops",
|
||||
"image": "local-build://connector-appops",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": ["docker-secret://appops_hub_service_token"],
|
||||
"allowedNetworkTargets": ["config://APPOPS_ENDPOINT"],
|
||||
"configuration": {"readOnly": false, "writeMode": "approval-then-typed-execute", "requestTimeoutSeconds": 60, "maxResponseBytes": 2097152},
|
||||
"health": {"path": "/health/ready", "intervalSeconds": 30, "failureThreshold": 3},
|
||||
"defaultRiskClasses": ["R0", "R1", "R2", "R3"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Authentik Default",
|
||||
"slug": "authentik-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "authentik",
|
||||
"image": "local-build://connector-authentik",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"docker-secret://authentik_mcp_token"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"config://AUTHENTIK_BASE_URL"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": true,
|
||||
"includeApplications": true,
|
||||
"includeProviders": true,
|
||||
"includeOutpostHealth": true,
|
||||
"includeTokenValues": false
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Database Metadata Default",
|
||||
"slug": "database-metadata-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "database",
|
||||
"image": "local-build://connector-database",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"docker-secret://project_database_readonly_dsn"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"catalog-project-binding://database"
|
||||
],
|
||||
"configuration": {
|
||||
"mode": "metadata-only",
|
||||
"statementTimeoutMs": 3000,
|
||||
"maxRows": 100,
|
||||
"allowSchemas": [],
|
||||
"denySchemas": [
|
||||
"pg_catalog",
|
||||
"information_schema"
|
||||
],
|
||||
"allowAdHocQueries": false
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Deployment Readonly Default",
|
||||
"slug": "deployment-readonly-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "deployment",
|
||||
"image": "local-build://connector-deployment",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"service-identity://docker-read-broker"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"service://docker-read-broker"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": true,
|
||||
"discoverComposeMetadata": true,
|
||||
"allowedActions": [
|
||||
"status",
|
||||
"inspect"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Gitea Default",
|
||||
"slug": "gitea-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "gitea",
|
||||
"image": "local-build://connector-gitea",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"docker-secret://gitea_mcp_token",
|
||||
"docker-secret://gitea_mcp_write_token"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"config://GITEA_BASE_URL"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": false,
|
||||
"writeMode": "pr-first",
|
||||
"requestTimeoutSeconds": 15,
|
||||
"maxPageSize": 100,
|
||||
"exposePrivateRepoMetadata": "authorized-only"
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1",
|
||||
"R2"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {"name": "Knowledge Hub reviewed intake", "slug": "ingestion-default"},
|
||||
"spec": {
|
||||
"connectorType": "ingestion",
|
||||
"image": "local-build://connector-ingestion",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": ["docker-secret://knowledge_hub_ingestion_token"],
|
||||
"allowedNetworkTargets": ["config://KNOWLEDGE_HUB_ENDPOINT"],
|
||||
"configuration": {"maxFiles": 5, "maxFileBytes": 10485760, "maxTotalBytes": 26214400, "workspaceReadEnabled": false, "reviewRequired": true},
|
||||
"health": {"path": "/health/ready", "intervalSeconds": 30, "failureThreshold": 3},
|
||||
"defaultRiskClasses": ["R1", "R2"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Knowledge Default",
|
||||
"slug": "knowledge-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "knowledge",
|
||||
"image": "local-build://connector-knowledge",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [],
|
||||
"allowedNetworkTargets": [],
|
||||
"configuration": {
|
||||
"indexSecrets": false,
|
||||
"maxDocumentBytes": 1048576,
|
||||
"maxSearchResults": 20,
|
||||
"allowedUriSchemes": [
|
||||
"discovery",
|
||||
"itworx-docs",
|
||||
"itworx-git"
|
||||
]
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Fleet Ops read-only connector",
|
||||
"slug": "mobilityops-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "mobilityops",
|
||||
"image": "local-build://connector-mobilityops",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"docker-secret://mobilityops_service_token"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"config://MOBILITYOPS_ENDPOINT"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": true,
|
||||
"requestTimeoutSeconds": 15,
|
||||
"maxResponseBytes": 262144
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Nginx Proxy Manager Default",
|
||||
"slug": "nginx-proxy-manager-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "nginx-proxy-manager",
|
||||
"image": "local-build://connector-nginx-proxy-manager",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"docker-secret://npm_mcp_token"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"config://NPM_BASE_URL"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": true,
|
||||
"includeCertificates": true,
|
||||
"exposeAccessLists": false
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Observability Default",
|
||||
"slug": "observability-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "observability",
|
||||
"image": "local-build://connector-observability",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"docker-secret://observability_read_token"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"config://OBSERVABILITY_BASE_URL"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": true,
|
||||
"maxLookbackHours": 24,
|
||||
"maxLogEvents": 500,
|
||||
"redactionProfile": "strict"
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {"name": "OpenCode governed job broker", "slug": "opencode-default"},
|
||||
"spec": {
|
||||
"connectorType": "opencode",
|
||||
"image": "local-build://openchamber-mcp",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": ["docker-secret://connector_identity_signing_key"],
|
||||
"allowedNetworkTargets": ["service://opencode:4096"],
|
||||
"configuration": {"readOnly": false, "writeMode": "preview-token-first", "requestTimeoutSeconds": 30, "maxResponseBytes": 1048576},
|
||||
"health": {"path": "/ready", "intervalSeconds": 30, "failureThreshold": 3},
|
||||
"defaultRiskClasses": ["R0", "R1", "R2"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {"name": "OpenCode read-only analysis broker", "slug": "opencode-readonly-default"},
|
||||
"spec": {
|
||||
"connectorType": "opencode-readonly",
|
||||
"image": "local-build://openchamber-mcp",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": ["docker-secret://connector_identity_signing_key"],
|
||||
"allowedNetworkTargets": ["service://opencode-readonly:4096"],
|
||||
"configuration": {"readOnly": true, "agent": "plan", "workspaceMount": "read-only", "requestTimeoutSeconds": 30, "maxResponseBytes": 1048576},
|
||||
"health": {"path": "/ready", "intervalSeconds": 30, "failureThreshold": 3},
|
||||
"defaultRiskClasses": ["R0", "R1"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {"name": "ProjectBrain governed connector", "slug": "projectbrain-default"},
|
||||
"spec": {
|
||||
"connectorType": "projectbrain",
|
||||
"image": "local-build://connector-projectbrain",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": ["docker-secret://projectbrain_hub_service_token"],
|
||||
"allowedNetworkTargets": ["config://PROJECTBRAIN_ENDPOINT"],
|
||||
"configuration": {"readOnly": false, "writeMode": "review-and-pr-first", "requestTimeoutSeconds": 30, "maxResponseBytes": 8388608},
|
||||
"health": {"path": "/health/ready", "intervalSeconds": 30, "failureThreshold": 3},
|
||||
"defaultRiskClasses": ["R0", "R1", "R2"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"apiVersion":"hub.example.com/v1alpha1","kind":"ConnectorTemplate","metadata":{"name":"Google Stitch governed connector","slug":"stitch-default"},"spec":{"connectorType":"stitch","image":"local-build://connector-stitch","transport":"streamable-http","credentialReferences":["docker-secret://stitch_api_key"],"allowedNetworkTargets":["service://stitch.googleapis.com/mcp"],"configuration":{"readOnly":false,"writeMode":"bounded-design-writes","requestTimeoutSeconds":120,"maxResponseBytes":1048576},"health":{"path":"/health/ready","intervalSeconds":30,"failureThreshold":3},"defaultRiskClasses":["R1","R2"]}}
|
||||
@@ -0,0 +1,40 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "ConnectorTemplate",
|
||||
"metadata": {
|
||||
"name": "Unraid Default",
|
||||
"slug": "unraid-default"
|
||||
},
|
||||
"spec": {
|
||||
"connectorType": "unraid",
|
||||
"image": "local-build://connector-unraid",
|
||||
"transport": "streamable-http",
|
||||
"credentialReferences": [
|
||||
"service-identity://docker-read-broker"
|
||||
],
|
||||
"allowedNetworkTargets": [
|
||||
"service://docker-read-broker"
|
||||
],
|
||||
"configuration": {
|
||||
"readOnly": true,
|
||||
"allowedBrokerRoutes": [
|
||||
"/v1/host",
|
||||
"/v1/storage",
|
||||
"/v1/containers",
|
||||
"/v1/containers/{registered_id}",
|
||||
"/v1/containers/{registered_id}/logs",
|
||||
"/v1/networks"
|
||||
],
|
||||
"maxLogLines": 500
|
||||
},
|
||||
"health": {
|
||||
"path": "/health/ready",
|
||||
"intervalSeconds": 30,
|
||||
"failureThreshold": 3
|
||||
},
|
||||
"defaultRiskClasses": [
|
||||
"R0",
|
||||
"R1"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "010 Deny Generic Privileged Tools",
|
||||
"slug": "010-deny-generic-privileged-tools"
|
||||
},
|
||||
"spec": {
|
||||
"priority": 10,
|
||||
"description": "Irrevocably block generic shell, filesystem, Docker, SQL, secret and arbitrary HTTP capabilities.",
|
||||
"match": {
|
||||
"toolPatterns": [
|
||||
"shell.*",
|
||||
"filesystem.*",
|
||||
"docker.raw_*",
|
||||
"database.execute_*",
|
||||
"secrets.*",
|
||||
"http.request_*"
|
||||
]
|
||||
},
|
||||
"conditions": {},
|
||||
"decision": "deny_and_alert",
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "020 Project Scope Isolation",
|
||||
"slug": "020-project-scope-isolation"
|
||||
},
|
||||
"spec": {
|
||||
"priority": 20,
|
||||
"description": "Require the token project claim, endpoint project and requested resource project to be identical.",
|
||||
"match": {
|
||||
"resourceTypes": [
|
||||
"tool",
|
||||
"resource",
|
||||
"prompt"
|
||||
]
|
||||
},
|
||||
"conditions": {
|
||||
"allEqual": [
|
||||
"claims.project",
|
||||
"route.project",
|
||||
"request.project"
|
||||
],
|
||||
"requiredClaims": [
|
||||
"sub",
|
||||
"aud",
|
||||
"scope",
|
||||
"project"
|
||||
]
|
||||
},
|
||||
"decision": "allow_if_conditions",
|
||||
"terminal": false
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "030 Production Destructive Deny V1.1",
|
||||
"slug": "030-production-destructive-deny-v1-1"
|
||||
},
|
||||
"spec": {
|
||||
"priority": 30,
|
||||
"description": "Reject administrative and unknown operations in production; bounded project-scoped R2/R3 operations require separate explicit allows.",
|
||||
"match": {
|
||||
"environmentClassifications": [
|
||||
"production"
|
||||
],
|
||||
"riskClasses": [
|
||||
"R4",
|
||||
"RX"
|
||||
]
|
||||
},
|
||||
"conditions": {},
|
||||
"decision": "deny_and_alert",
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "035 Gitea Controlled Writes V1.1",
|
||||
"slug": "035-gitea-controlled-writes-v1-1"
|
||||
},
|
||||
"spec": {
|
||||
"priority": 35,
|
||||
"description": "Allow the bounded Gitea R2 operations for the registered or account-discovered gitea-all production project and an enabled bound client.",
|
||||
"match": {
|
||||
"project": "gitea-all",
|
||||
"environmentClassifications": ["production"],
|
||||
"connectors": ["gitea"],
|
||||
"riskClasses": ["R2"],
|
||||
"toolPatterns": [
|
||||
"gitea.repository.create",
|
||||
"gitea.branchprotection.ensure",
|
||||
"gitea.portfolio.governance.apply",
|
||||
"gitea.branch.create",
|
||||
"gitea.issue.create",
|
||||
"gitea.issue.update",
|
||||
"gitea.issue.comment.create",
|
||||
"gitea.file.create",
|
||||
"gitea.file.update",
|
||||
"gitea.file.delete",
|
||||
"gitea.pullrequest.create",
|
||||
"gitea.pullrequest.update",
|
||||
"gitea.pullrequest.close",
|
||||
"gitea.pullrequest.validation.ensure",
|
||||
"gitea.changeset.apply",
|
||||
"gitea.validation.run",
|
||||
"gitea.workspace.run",
|
||||
"gitea.orchestration.start"
|
||||
]
|
||||
},
|
||||
"conditions": {
|
||||
"requireEnabledClient": true,
|
||||
"requireBinding": true,
|
||||
"maxArgumentBytes": 1200000
|
||||
},
|
||||
"decision": "allow_if_conditions",
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
{"apiVersion":"hub.example.com/v1alpha1","kind":"Policy","metadata":{"name":"036 Gitea Loop Reviewed Lifecycle V1","slug":"036-gitea-loop-reviewed-lifecycle-v1"},"spec":{"priority":36,"description":"Allow SHA-fenced PR synchronization and exact-head verified merge for the bound ProjectBrain/Gitea delivery loop; connector safety gates remain mandatory.","match":{"project":"gitea-loop","environmentClassifications":["production"],"connectors":["gitea"],"riskClasses":["R3"],"toolPatterns":["gitea.pullrequest.sync","gitea.pullrequest.merge"]},"conditions":{"requireEnabledClient":true,"requireBinding":true,"maxArgumentBytes":65536},"decision":"allow_if_conditions","terminal":true}}
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "036 Gitea Reviewed Lifecycle V1",
|
||||
"slug": "036-gitea-reviewed-lifecycle-v1"
|
||||
},
|
||||
"spec": {
|
||||
"priority": 36,
|
||||
"description": "Allow SHA-fenced PR synchronization, verified exact-head merge and merged-branch cleanup only for the bound gitea-all production client; connector preconditions remain mandatory.",
|
||||
"match": {
|
||||
"project": "gitea-all",
|
||||
"environmentClassifications": ["production"],
|
||||
"connectors": ["gitea"],
|
||||
"riskClasses": ["R3"],
|
||||
"toolPatterns": [
|
||||
"gitea.pullrequest.sync",
|
||||
"gitea.pullrequest.merge",
|
||||
"gitea.branch.delete"
|
||||
]
|
||||
},
|
||||
"conditions": {
|
||||
"requireEnabledClient": true,
|
||||
"requireBinding": true,
|
||||
"maxArgumentBytes": 65536
|
||||
},
|
||||
"decision": "allow_if_conditions",
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {"name": "037 ProjectBrain Governed Operations", "slug": "037-projectbrain-governed-operations"},
|
||||
"spec": {
|
||||
"priority": 37,
|
||||
"description": "Allow ProjectBrain R2 mutations only for the bound gitea-all production client; ProjectBrain independently enforces review, approval, branch, SHA and validation gates.",
|
||||
"match": {
|
||||
"project": "gitea-all",
|
||||
"environmentClassifications": ["production"],
|
||||
"connectors": ["projectbrain"],
|
||||
"riskClasses": ["R2"],
|
||||
"toolPatterns": ["projectbrain.*"]
|
||||
},
|
||||
"conditions": {"requireEnabledClient": true, "requireBinding": true, "maxArgumentBytes": 1200000},
|
||||
"decision": "allow_if_conditions",
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"apiVersion": "hub.example.com/v1alpha1",
|
||||
"kind": "Policy",
|
||||
"metadata": {
|
||||
"name": "038 ProjectBrain Analysis Operations",
|
||||
"slug": "038-projectbrain-analysis-operations"
|
||||
},
|
||||
"spec": {
|
||||
"priority": 38,
|
||||
"description": "Allow only the bounded ProjectBrain analysis job mutations for clients that already hold an enabled inherited Gitea portfolio binding.",
|
||||
"match": {
|
||||
"project": "gitea-analysis",
|
||||
"environmentClassifications": ["production"],
|
||||
"connectors": ["projectbrain"],
|
||||
"riskClasses": ["R2"],
|
||||
"toolPatterns": [
|
||||
"projectbrain.analysis.start",
|
||||
"projectbrain.analysis.cancel"
|
||||
]
|
||||
},
|
||||
"conditions": {
|
||||
"requireEnabledClient": true,
|
||||
"requireBinding": true,
|
||||
"maxArgumentBytes": 65536
|
||||
},
|
||||
"decision": "allow_if_conditions",
|
||||
"terminal": true
|
||||
}
|
||||
}
|
||||
Loaded 100 of 663 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user