Admin UI with FastAPI and gRPC-Web
Context
Prism Admin API (ADR-027) provides gRPC endpoints for administration. Need web-based UI for:
- Managing client configurations
- Monitoring active sessions
- Viewing backend health
- Namespace management
- Operational tasks
Requirements:
- Browser-accessible admin interface
- Communicate with gRPC backend
- Lightweight deployment
- Modern, responsive UI
- Production-grade security
Decision
Build Admin UI with FastAPI + gRPC-Web:
- FastAPI backend: Python service serving static files and gRPC-Web proxy
- gRPC-Web: Protocol translation from browser to gRPC backend
- Vanilla JavaScript: Simple, no-framework frontend
- CSS: Tailwind or modern CSS for styling
- Single container: All-in-one deployment
Rationale
Architecture
┌─────────────────────────────────────────────┐ │ Browser │ │ │ │ ┌────────────────────────────────────┐ │ │ │ Admin UI (HTML/CSS/JS) │ │ │ │ - Configuration manager │ │ │ │ - Session monitor │ │ │ │ - Health dashboard │ │ │ └────────────┬───────────────────────┘ │ │ │ HTTP + gRPC-Web │ └───────────────┼─────────────────────────────┘ │ ┌───────────────▼─────────────────────────────┐ │ FastAPI Service (:8000) │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ Static File Server │ │ │ │ GET / → index.html │ │ │ │ GET /static/* → CSS/JS │ │ │ └─────────────────────────────────────┘ │ │ │ │ ┌─────────────────────────────────────┐ │ │ │ gRPC-Web Proxy │ │ │ │ POST /prism.admin.v1.AdminService │ │ │ └──────────────┬──────────────────────┘ │ └─────────────────┼───────────────────────────┘ │ gRPC ┌─────────────────▼───────────────────────────┐ │ Prism Admin API (:8981) │ │ - prism.admin.v1.AdminService │ └─────────────────────────────────────────────┘
### Why FastAPI
**Pros:**
- Modern Python web framework
- Async support (perfect for gRPC proxy)
- Built-in OpenAPI/Swagger docs
- Easy static file serving
- Production-ready with Uvicorn
**Cons:**
- Python dependency (but we already use Python for tooling)
### Why gRPC-Web
**Browser limitation**: Browsers can't speak native gRPC (no HTTP/2 trailers support)
**gRPC-Web solution:**
- HTTP/1.1 or HTTP/2 compatible
- Protobuf encoding preserved
- Generated JavaScript clients
- Transparent proxy to gRPC backend
### Frontend Stack
**Vanilla JavaScript** (no framework):
- **Pros**: No build step, no dependencies, fast load, simple
- **Cons**: Manual DOM manipulation, no reactivity
**Modern CSS** (Tailwind or custom):
- **Pros**: Responsive, modern look, utility-first
- **Cons**: Larger CSS file (but can be minified)
**Generated gRPC-Web client:**
Generate JavaScript client from proto
protoc --js_out=import_style=commonjs,binary:./admin-ui/static/js
--grpc-web_out=import_style=commonjs,mode=grpcwebtext:./admin-ui/static/js
proto/prism/admin/v1/admin.proto
### FastAPI Implementation
admin-ui/main.py
from fastapi import FastAPI from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse import grpc from grpc_web import grpc_web_server
app = FastAPI(title="Prism Admin UI")
Serve static files
app.mount("/static", StaticFiles(directory="static"), name="static")
Serve index.html for root
@app.get("/") async def read_root(): return FileResponse("static/index.html")
gRPC-Web proxy
@app.post("/prism.admin.v1.AdminService/{method}") async def grpc_proxy(method: str, request: bytes): """Proxy gRPC-Web requests to gRPC backend""" channel = grpc.aio.insecure_channel("prism-proxy:8981") # Forward request to gRPC backend # Handle response and convert to gRPC-Web format pass
Health check
@app.get("/health") async def health(): return {"status": "healthy"}
### Frontend Structure
admin-ui/
├── main.py # FastAPI app
├── requirements.txt # Python deps
├── Dockerfile # Container image
└── static/
├── index.html # Main page
├── css/
│ └── styles.css # Tailwind or custom CSS
├── js/
│ ├── admin_grpc_web_pb.js # Generated gRPC-Web client
│ ├── config.js # Config management
│ ├── sessions.js # Session monitoring
│ └── health.js # Health dashboard
└── lib/
└── grpc-web.js # gRPC-Web runtime
HTML Template
<!-- admin-ui/static/index.html -->
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prism Admin</title>
<link rel="stylesheet" href="/static/css/styles.css">
</head>
<body class="bg-gray-100">
<div class="container mx-auto px-4 py-8">
<header class="mb-8">
<h1 class="text-3xl font-bold">Prism Admin</h1>
<nav class="mt-4">
<button data-page="configs" class="px-4 py-2 bg-blue-500 text-white rounded">Configs</button>
<button data-page="sessions" class="px-4 py-2 bg-blue-500 text-white rounded">Sessions</button>
<button data-page="health" class="px-4 py-2 bg-blue-500 text-white rounded">Health</button>
</nav>
</header>
<main id="content">
<!-- Dynamic content loaded here -->
</main>
</div>
<script type="module" src="/static/js/admin_grpc_web_pb.js"></script>
<script type="module" src="/static/js/config.js"></script>
<script type="module" src="/static/js/sessions.js"></script>
<script type="module" src="/static/js/health.js"></script>
</body>
</html>
JavaScript gRPC-Web Client
// admin-ui/static/js/config.js
import {AdminServiceClient} from './admin_grpc_web_pb.js';
import {ListConfigsRequest} from './admin_grpc_web_pb.js';
const client = new AdminServiceClient('http://localhost:8000', null, null);
async function loadConfigs() {
const request = new ListConfigsRequest();
client.listConfigs(request, {'x-admin-token': getAdminToken()}, (err, response) => {
if (err) {
console.error('Error loading configs:', err);
return;
}
const configs = response.getConfigsList();
renderConfigs(configs);
});
}
function renderConfigs(configs) {
const html = configs.map(config => `
<div class="bg-white p-4 rounded shadow mb-4">
<h3 class="font-bold">${config.getName()}</h3>
<p class="text-sm text-gray-600">Pattern: ${config.getPattern()}</p>
<p class="text-sm text-gray-600">Backend: ${config.getBackend().getType()}</p>
</div>
`).join('');
document.getElementById('content').innerHTML = html;
}
// Export functions
export {loadConfigs};
Deployment
# admin-ui/Dockerfile
FROM python:3.11-slim
WORKDIR /app
# Install dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application
COPY main.py .
COPY static/ static/
# Expose port
EXPOSE 8000
# Run FastAPI with Uvicorn
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# docker-compose.yml
services:
prism-proxy:
image: prism/proxy:latest
ports:
- "8980:8980" # Data plane
- "8981:8981" # Admin API
admin-ui:
image: prism/admin-ui:latest
ports:
- "8000:8000"
environment:
- PRISM_ADMIN_ENDPOINT=prism-proxy:8981
- ADMIN_TOKEN_SECRET=your-secret-key
depends_on:
- prism-proxy
Security
Authentication:
from fastapi import Header, HTTPException
async def verify_admin_token(x_admin_token: str = Header(...)):
if not is_valid_admin_token(x_admin_token):
raise HTTPException(status_code=401, detail="Invalid admin token")
return x_admin_token
@app.post("/prism.admin.v1.AdminService/{method}")
async def grpc_proxy(
method: str,
request: bytes,
admin_token: str = Depends(verify_admin_token)
):
# Forward with admin token
metadata = [('x-admin-token', admin_token)]
# ... proxy request
CORS (if needed):
from fastapi.middleware.cors import CORSMiddleware
app.add_middleware(
CORSMiddleware,
allow_origins=["https://" + "admin.example.com"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
Alternative: Envoy gRPC-Web Proxy
Instead of FastAPI, use Envoy:
# envoy.yaml
static_resources:
listeners:
- name: listener_0
address:
socket_address:
address: 0.0.0.0
port_value: 8000
filter_chains:
- filters:
- name: envoy.filters.network.http_connection_manager
typed_config:
"@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
codec_type: AUTO
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: backend
domains: ["*"]
routes:
- match:
prefix: "/prism.admin.v1"
route:
cluster: grpc_backend
http_filters:
- name: envoy.filters.http.grpc_web
- name: envoy.filters.http.cors
- name: envoy.filters.http.router
clusters:
- name: grpc_backend
connect_timeout: 0.25s
type: LOGICAL_DNS
lb_policy: ROUND_ROBIN
typed_extension_protocol_options:
envoy.extensions.upstreams.http.v3.HttpProtocolOptions:
"@type": type.googleapis.com/envoy.extensions.upstreams.http.v3.HttpProtocolOptions
explicit_http_config:
http2_protocol_options: {}
load_assignment:
cluster_name: grpc_backend
endpoints:
- lb_endpoints:
- endpoint:
address:
socket_address:
address: prism-proxy
port_value: 8981
Pros: Production-grade, feature-rich Cons: More complex, separate process
Alternatives Considered
-
React/Vue/Angular SPA
- Pros: Rich UI, reactive, component-based
- Cons: Build step, bundle size, complexity
- Rejected: Vanilla JS sufficient for admin UI
-
Server-side rendering (Jinja2)
- Pros: No JavaScript needed, SEO-friendly
- Cons: Full page reloads, less interactive
- Rejected: Admin UI needs interactivity
-
Separate Ember.js app (as originally planned)
- Pros: Full-featured framework, ember-data
- Cons: Large bundle, build complexity, overkill
- Rejected: Too heavy for admin UI
-
grpcurl-based CLI only
- Pros: Simple, no UI needed
- Cons: Not user-friendly for non-technical admins
- Rejected: Web UI provides better UX
Consequences
Positive
- Simple deployment: Single container with FastAPI
- No build step: Vanilla JS loads directly
- gRPC compatible: Uses gRPC-Web protocol
- Lightweight: Minimal dependencies
- Fast development: Python + simple JS
Negative
- Manual DOM updates: No framework reactivity
- Limited UI features: Vanilla JS less powerful than frameworks
- Python dependency: Adds Python to stack (but already used for tooling)
Neutral
- gRPC-Web limitation: Requires proxy (but handled by FastAPI)
- Browser compatibility: Modern browsers only (ES6+)
Implementation Notes
Development Workflow
# Generate gRPC-Web client
buf generate --template admin-ui/buf.gen.grpc-web.yaml
# Run FastAPI dev server
cd admin-ui
uvicorn main:app --reload --port 8000
# Open browser
open http://localhost:8000
Production Build
# Minify CSS
npx tailwindcss -i static/css/styles.css -o static/css/styles.min.css --minify
# Minify JS (optional)
npx terser static/js/config.js -o static/js/config.min.js
# Build Docker image
docker build -t prism/admin-ui:latest ./admin-ui
Requirements
# admin-ui/requirements.txt
fastapi==0.104.1
uvicorn[standard]==0.24.0
grpcio==1.59.0
grpcio-tools==1.59.0
References
- ADR-027: Admin API via gRPC
- gRPC-Web
- FastAPI
- Tailwind CSS
Revision History
- 2025-10-07: Initial draft and acceptance