Contributing: Admin UI
This guide covers development of the admin UI for contributors. For end-user documentation on using the admin UI, see the Getting Started guide.
The admin UI is a React 19 and TypeScript frontend embedded in the server binary through rust_embed. Open http://localhost:8080 to use the graphical interface to the management API.
The UI is not a separate deployment. The management API server provides the compiled static assets from the Rust binary. One binary serves the API and UI on one port.
Tech Stack
- Framework: React 19.1, TypeScript 5.7
- Build tool: Vite 6
- Component library: IBM Carbon Design System (
@carbon/react) - Icons:
@carbon/icons-react - Routing:
react-router-domv6, hash-based (createHashRouter) - Styling: SCSS with Carbon theme tokens (
$g10light /$g100dark) - API client: Orval-generated from Rust server's OpenAPI spec (
openapi.json) - Package manager: Yarn (classic, not Berry)
Project Structure
ui/admin/
├── src/
│ ├── api/ # Orval-generated API client (DO NOT EDIT)
│ ├── assets/ # Static assets (images, fonts)
│ ├── components/ # Shared layout components
│ │ ├── Header.tsx # Top navigation bar
│ │ ├── SideNav.tsx # Left sidebar
│ │ ├── Content.tsx # Main content wrapper
│ │ └── ErrorBoundary.tsx # Error boundary wrapper
│ ├── constants/ # Shared constants
│ ├── hooks/api/ # Custom hooks wrapping API functions
│ ├── models/ # Orval-generated TypeScript types (DO NOT EDIT)
│ ├── Pages/ # Page components (capital P)
│ │ ├── Tools/
│ │ │ ├── Tools.tsx # Main component
│ │ │ ├── index.ts # Re-exports from router-exports.tsx
│ │ │ └── router-exports.tsx # Exports page element for lazy loading
│ │ ├── Resources/
│ │ └── ...
│ ├── navigation/ # Core navigation entries
│ │ └── core-nav-items.ts # Built-in navigation entries
│ ├── router/ # Route constants
│ │ └── links.models.ts # const enum Links
│ ├── utils/ # Utility functions
│ ├── custom-fetch.ts # Fetch wrapper with error handling
│ ├── router.tsx # Hash-based router setup
│ ├── App.tsx # Root component
│ └── index.scss # Global Carbon theme setup
├── public/ # Static files (copied to dist/)
├── openapi.json # Generated OpenAPI spec (committed, not gitignored)
├── orval.config.ts # Orval API client generator config
├── package.json
├── tsconfig.json
├── vite.config.ts
└── yarn.lockDevelopment Workflow
1. Install Dependencies
cd ui/admin
yarn install2. Run Dev Server
yarn run devThis starts Vite's dev server on http://localhost:5173 with hot module replacement (HMR). Changes to .tsx/.scss files reload instantly.
The dev server proxies API calls to http://localhost:8080 (configurable in vite.config.ts).
3. Build for Production
yarn run buildThis runs:
- Orval: Regenerates API client from committed
openapi.json - TypeScript: Type-checks all
.tsxfiles - Vite: Bundles and minifies to
dist/
Output:
dist/
├── index.html
├── assets/
│ ├── index-<hash>.js
│ └── index-<hash>.css
└── ...4. Embed in Server
The server binary embeds ui/admin/dist/ at compile time via rust_embed:
#[derive(RustEmbed)]
#[folder = "ui/admin/dist/"]
struct AdminUI;When you visit http://localhost:8080, the server serves files from the embedded bundle.
Important: Complete these steps after you change the UI:
yarn run buildto updatedist/cargo buildto re-embeddist/into the binary
For local dev, use WANAKU_UI_PATH to serve from filesystem:
export WANAKU_UI_PATH=/absolute/path/to/ui/admin/dist
cargo runNow you can iterate on the UI (yarn run build) without rebuilding the server.
Code Conventions
Carbon Components Only
Never use raw HTML elements for interactive UI. Use @carbon/react components:
Bad:
<button onClick={handleClick}>Submit</button>Good:
import { Button } from '@carbon/react';
<Button onClick={handleClick}>Submit</Button>Bad:
<table>
<tr><td>Name</td><td>Value</td></tr>
</table>Good:
import { DataTable, Table, TableHead, TableRow, TableHeader, TableBody, TableCell } from '@carbon/react';
<DataTable rows={rows} headers={headers}>
{({ rows, headers, getTableProps, getHeaderProps, getRowProps }) => (
<Table {...getTableProps()}>
<TableHead>
<TableRow>
{headers.map(header => (
<TableHeader {...getHeaderProps({ header })}>{header.header}</TableHeader>
))}
</TableRow>
</TableHead>
<TableBody>
{rows.map(row => (
<TableRow {...getRowProps({ row })}>
{row.cells.map(cell => <TableCell key={cell.id}>{cell.value}</TableCell>)}
</TableRow>
))}
</TableBody>
</Table>
)}
</DataTable>Page Structure: Three-File Pattern
Each page has exactly three files:
1. <PageName>.tsx — the main component
export const Tools = () => {
const [tools, setTools] = useState<Tool[]>([]);
// ... component logic
return <div>...</div>;
};2. index.ts — re-exports from router-exports.tsx
export * from './router-exports';3. router-exports.tsx — exports the page element for lazy loading
import { Tools } from './Tools';
export const ToolsElement = <Tools />;Why? The router uses lazy loading:
{
path: Links.Tools,
lazy: async () => import('./Pages/Tools'),
element: <Suspense fallback={<Loading />}><ToolsElement /></Suspense>
}This pattern keeps the router config clean and enables code-splitting.
Route Constants
Never hardcode URLs. Define them in src/router/links.models.ts:
export const enum Links {
Home = '/',
Tools = '/tools',
Resources = '/resources',
Prompts = '/prompts',
}Use in components:
import { Links } from '../router/links.models';
import { Link } from 'react-router-dom';
<Link to={Links.Tools}>View Tools</Link>API Hooks
Wrap Orval-generated API functions with custom hooks in src/hooks/api/:
Orval-generated (DO NOT EDIT):
// src/api/wanaku-router-api.ts
export const getTools = (): Promise<ToolsResponse> => {
return customFetch('/api/v1/tools');
};Custom hook:
// src/hooks/api/useTools.ts
import { useCallback } from 'react';
import { listTools } from '../../api/wanaku-router-api';
export const useTools = () => {
const fetchTools = useCallback(async () => {
const result = await listTools();
return result.data; // customFetch unwraps the server envelope automatically
}, []);
return { fetchTools };
};customFetch returns {status, data, headers} where data is the unwrapped payload (e.g., ToolEntry[]). The server's {"data": ..., "error": ...} envelope is stripped by customFetch.
Notifications
Use Carbon ToastNotification with auto-dismiss:
import { ToastNotification } from '@carbon/react';
const [showNotification, setShowNotification] = useState(false);
// Trigger notification
setShowNotification(true);
// In JSX
{showNotification && (
<ToastNotification
title="Success"
subtitle="Tool created successfully"
kind="success"
timeout={3000}
onClose={() => setShowNotification(false)}
/>
)}Never use alert() or console.log() for user feedback.
Error Handling
Wrap page content in ErrorBoundary:
import { ErrorBoundary } from '../../components/ErrorBoundary';
export const Tools = () => {
return (
<ErrorBoundary>
<div>
{/* page content */}
</div>
</ErrorBoundary>
);
};The error boundary catches React errors and shows a Carbon InlineNotification instead of crashing the app.
Empty States
Use the shared EmptyTableState component:
import { EmptyTableState } from '../../components/EmptyTableState';
{tools.length === 0 ? (
<EmptyTableState
title="No tools discovered yet"
subtitle="Register a forwarded MCP server to auto-discover tools"
/>
) : (
<DataTable rows={tools} headers={headers} />
)}Styling
The UI uses Carbon theme tokens, not hardcoded colors.
Bad:
.my-component {
background-color: #f4f4f4;
color: #161616;
}Good:
@use '@carbon/react/scss/theme';
.my-component {
background-color: theme.$layer-01;
color: theme.$text-primary;
}Theme tokens:
$layer-01,$layer-02,$layer-03— background layers$text-primary,$text-secondary— text colors$interactive-01,$interactive-02— buttons, links$border-subtle,$border-strong— borders
Router Configuration
The app uses hash-based routing (URLs start with #/) to avoid 404s when serving from the embedded bundle.
Why hash routing?
The server does not rewrite URLs. With browser routing, a refresh of /tools sends GET /tools to the server. It does not load / before client-side routing.
Hash routing keeps all requests to GET / (which serves index.html), and the router handles #/tools in JavaScript.
Router setup:
import { createHashRouter } from 'react-router-dom';
const router = createHashRouter([
{
path: Links.Home,
element: <App />,
children: [
{ path: Links.Tools, lazy: async () => import('./Pages/Tools') },
{ path: Links.Resources, lazy: async () => import('./Pages/Resources') },
]
}
]);API Client (Orval)
The UI uses Orval to generate a TypeScript client from the Rust server's OpenAPI spec. This keeps the API client in sync with the server.
OpenAPI Spec Generation
The OpenAPI spec is generated from the Rust server's utoipa annotations and committed as ui/admin/openapi.json. When the Rust API changes (new endpoints, new fields on types), regenerate the spec:
cargo run --example wanaku-openapi --no-default-features > ui/admin/openapi.jsonThe --no-default-features flag skips the UI build step in build.rs, avoiding a circular dependency (the UI build needs the spec, but the spec needs the server binary).
After regenerating the spec, regenerate the TypeScript client:
cd ui/admin
yarn run generate-apiThis runs the wanaku-openapi example from server/Cargo.toml with --no-default-features. It writes openapi.json and then runs Orval. Commit openapi.json and the regenerated src/api/ and src/models/ files.
Build Scripts
| Script | What it does |
|---|---|
yarn build | Orval (reads committed openapi.json) + TypeScript + Vite |
yarn dev | Orval + Vite dev server with HMR |
yarn generate-api | Regenerates openapi.json from Rust server + Orval |
yarn build and yarn dev do not run cargo — they read the committed openapi.json. Only yarn generate-api invokes the Rust toolchain.
Configuration
orval.config.ts:
export default {
wanaku: {
input: './openapi.json',
output: {
target: './src/api/wanaku-router-api.ts',
schemas: './src/models',
client: 'fetch',
mode: 'single',
override: {
mutator: {
path: './src/custom-fetch.ts',
name: 'customFetch',
},
},
},
},
};This overwrites src/api/wanaku-router-api.ts and src/models/. Never edit these files manually.
Response Handling
The Rust management API wraps all responses in {"data": ..., "error": ...}. The customFetch function in src/custom-fetch.ts unwraps this automatically, so API hooks and pages access response.data directly (not response.data.data).
The base URL is dynamic. The UI uses VITE_API_URL when it is set. Otherwise, it uses window.location.origin. This supports localhost and other backend addresses.
Authentication
The admin UI is protected by oauth2-proxy when auth is enabled. Users authenticate via oauth2-proxy's browser-based cookie flow — no client-side OIDC logic in the React app.
How it works:
- User visits
http://localhost:4181/admin/(oauth2-proxy management port) - oauth2-proxy checks for a valid session cookie
- If no cookie, oauth2-proxy redirects to Keycloak's login page
- User authenticates with Keycloak
- Keycloak redirects back to oauth2-proxy with an auth code
- oauth2-proxy exchanges the code for a token and sets a session cookie
- oauth2-proxy proxies the request to Praxis on port 8080
- The UI loads, session is established
Session expiry:
When the session expires, oauth2-proxy returns HTTP 401. The browser is redirected to the login page automatically.
No client-side tokens:
Unlike the previous embedded auth approach, the UI does NOT store tokens in sessionStorage or send Authorization: Bearer headers. oauth2-proxy handles all auth via cookies.
Code changes:
The UI no longer depends on oidc-client-ts. Auth redirect handling is removed from src/custom-fetch.ts.
Testing without auth:
Run Praxis standalone on port 8080 without oauth2-proxy. The UI connects directly and sends unauthenticated requests.
Adding a New Page
1. Create Page Files
mkdir src/Pages/MyNewPage
touch src/Pages/MyNewPage/MyNewPage.tsx
touch src/Pages/MyNewPage/index.ts
touch src/Pages/MyNewPage/router-exports.tsx2. Implement Component
MyNewPage.tsx:
import { Button } from '@carbon/react';
export const MyNewPage = () => {
return (
<div>
<h1>My New Page</h1>
<Button>Click me</Button>
</div>
);
};3. Add Router Exports
index.ts:
export * from './router-exports';router-exports.tsx:
import { MyNewPage } from './MyNewPage';
export const MyNewPageElement = <MyNewPage />;4. Register Route
src/router/links.models.ts:
export const enum Links {
MyNewPage = '/my-new-page',
// ...
}src/router.tsx:
{
path: Links.MyNewPage,
lazy: async () => import('./Pages/MyNewPage'),
}5. Add Nav Link
src/navigation/core-nav-items.ts:
export const CORE_NAV_ITEMS: NavItem[] = [
// Existing entries...
{ id: "my-new-page", label: "My New Page", route: Links.MyNewPage, source: "core", order: 100 },
];Rebuild and the page appears in the UI.
Related Docs
- Architecture — how the UI is embedded in the server
- Configuration —
WANAKU_UI_PATHfor local dev - Management API — API routes the UI consumes