ADR-061: Framework-Less Web UI with Go Templates and HTMX
Status
Accepted - 2025-11-07
Supersedes: ADR-028: Admin UI with FastAPI and gRPC-Web
Context
Problem Statement
Prism needs web-based UIs for:
- Operations Dashboard (HUD): Real-time monitoring of proxy, patterns, backends (RFC-050)
- Admin Interface: Namespace management, configuration, operational tasks
Original Approach (ADR-028):
- FastAPI (Python) backend serving static files
- React or Ember.js frontend
- npm + build pipeline (webpack/vite)
- JavaScript frameworks with complex dependencies
Problems with Framework Approach:
- ❌ Build complexity: npm install, webpack config, long build times
- ❌ Dependency hell: 500+ npm packages for simple UI
- ❌ Slow iteration: Change CSS → rebuild → reload (5-10 seconds)
- ❌ Large bundle sizes: 2MB+ JavaScript for basic interactions
- ❌ Framework churn: React/Vue/Svelte versions change rapidly
- ❌ Language mismatch: Python backend + JavaScript frontend = context switching
- ❌ Debugging complexity: Source maps, transpilation issues, framework-specific errors
Requirements
- Fast development: Change template → refresh browser (instant feedback)
- Zero build step: No npm, webpack, babel, transpilers
- Lightweight: Minimal JavaScript, small page sizes
- Native performance: Server-side rendering, progressive enhancement
- Simple deployment: Single Go binary, no Node.js runtime
- Easy maintenance: Less code, fewer dependencies, standard technologies
- Real-time updates: WebSocket or SSE for live data
Inspiration
HashiCorp Internal Tools:
- Consul UI (early versions): Go templates + vanilla JavaScript
- Nomad UI (server-rendered views)
- Philosophy: Simple, maintainable, no framework lock-in
Modern Tooling:
- HTMX: Interactivity without JavaScript frameworks
- D3.js: Best-in-class data visualization
- Mermaid.js: Diagram rendering from text
- Go templates: Fast, type-safe server rendering
Decision
Use framework-less web UI architecture:
Technology Stack
Backend: Go HTTP Server
- Language: Go (same as patterns, consistent with project)
- Framework:
net/httpstandard library +gorilla/muxfor routing - Templates: Go
html/template(standard library) - WebSocket:
gorilla/websocketfor real-time updates - gRPC Client: Native Go gRPC client (no gRPC-Web needed)
Frontend: Minimal JavaScript
- HTML: Server-rendered Go templates
- Interactivity: HTMX (14KB, replaces React/Vue)
- Visualization: D3.js (~70KB, best-in-class charting)
- Diagrams: Mermaid.js (~200KB, text-to-diagram)
- Styling: Plain CSS or lightweight Tailwind (optional)
- WebSocket: Native browser WebSocket API
Deployment
- Binary: Single Go executable (includes embedded templates/assets)
- Assets: Embedded with
//go:embeddirective - Port:
:8095(Dashboard),:8090(Admin UI) - Docker: Optional multi-stage build (no npm layer)
Rationale
Architecture Diagram
┌─────────────────────────────────────────────────────────────┐
│ Browser │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ HTML (Server-Rendered) │ │
│ │ ├─ Go templates: {{range .Patterns}} ... │ │
│ │ ├─ HTMX attributes: hx-get="/api/health" hx-swap │ │
│ │ ├─ D3.js: Render charts on load │ │
│ │ └─ WebSocket: Live metric updates │ │
│ └────────────────────────────────────────────────────────┘ │
│ ↓ ↑ │
│ HTTP/WebSocket │
└──────────────────────────┼──────────────────────────────────┘
│
┌──────────────────────────▼──────────────────────────────────┐
│ Go HTTP Server (:8095) │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Route Handlers │ │
│ │ ├─ GET / → Render dashboard.html │ │
│ │ ├─ GET /api/health → JSON (for HTMX) │ │
│ │ ├─ GET /ws → WebSocket upgrade │ │
│ │ └─ GET /static/* → Serve CSS/JS/assets │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ Data Collectors (Same as RFC-050) │ │
│ │ ├─ Scrape Prometheus: proxy /metrics │ │
│ │ ├─ Query gRPC: pattern.HealthCheck() │ │
│ │ ├─ Query Signoz API: traces/metrics │ │
│ │ └─ Query Admin API: namespaces │ │
│ └────────────────────────────────────────────────────────┘ │
│ │
│ ┌────────────────────────────────────────────────────────┐ │
│ │ WebSocket Hub │ │
│ │ ├─ Broadcast metrics every 2s │ │
│ │ └─ Manage client connections │ │
│ └────────────────────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────┘
Why Go Backend (vs Python/FastAPI)
| Aspect | Go | Python/FastAPI |
|---|---|---|
| Performance | 10-100x faster | Baseline |
| Memory | ~20MB | ~100MB+ |
| Deployment | Single binary | Python + deps |
| Language consistency | Same as patterns | Context switch |
| Type safety | Compile-time | Runtime (unless typed) |
| Concurrency | Goroutines (native) | asyncio (complex) |
| Build time | ~2s | N/A (interpreted) |
Decision: Go provides better performance, simpler deployment, and language consistency with the rest of Prism.
Why HTMX (vs React/Vue/Svelte)
HTMX Philosophy: Extend HTML with attributes for AJAX, WebSocket, SSE
Example: Refresh health data without page reload
<!-- React approach: 50+ lines of JavaScript -->
<div id="health-status"></div>
<script>
function HealthStatus() {
const [data, setData] = useState(null);
useEffect(() => {
fetch('/api/health')
.then(r => r.json())
.then(d => setData(d));
}, []);
return data ? <div>{data.status}</div> : <div>Loading...</div>;
}
ReactDOM.render(<HealthStatus />, document.getElementById('health-status'));
</script>
<!-- HTMX approach: 1 line -->
<div hx-get="/api/health" hx-trigger="load, every 2s" hx-swap="innerHTML">
Loading...
</div>
Benefits:
- ✅ Simplicity: HTML attributes, not JavaScript frameworks
- ✅ Size: 14KB vs 140KB+ (React)
- ✅ Server-driven: HTML rendering stays on server (type-safe templates)
- ✅ Progressive enhancement: Works without JavaScript (degrades gracefully)
- ✅ No build step: Include via CDN
<script src="https://unpkg.com/htmx.org"></script>
Comparison:
| Feature | HTMX | React | Vue | Svelte |
|---|---|---|---|---|
| Size | 14KB | 140KB | 90KB | 60KB |
| Build required | ❌ No | ✅ Yes | ✅ Yes | ✅ Yes |
| Dependencies | 0 | 200+ | 150+ | 100+ |
| Learning curve | Low | High | Medium | Medium |
| SSR complexity | N/A | High | High | Medium |
Why D3.js for Visualization
D3.js: Data-Driven Documents, best-in-class JavaScript visualization library
Alternatives Considered:
| Library | Size | Features | Verdict |
|---|---|---|---|
| D3.js | 70KB | Full control, animations, custom charts | ✅ Chosen |
| Chart.js | 200KB | Simple API, limited customization | ❌ Too basic |
| Recharts | 500KB+ | React component library | ❌ Requires React |
| Plotly | 3MB | Feature-rich, heavy | ❌ Too large |
Why D3.js:
- ✅ Industry standard (used by NYT, FiveThirtyEight, Observable)
- ✅ Full control over SVG rendering
- ✅ Works with vanilla JavaScript (no framework)
- ✅ Excellent animations and transitions
- ✅ Powerful data transformations
Example: Render latency line chart
<div id="latency-chart"></div>
<script>
// D3.js: Full control, ~20 lines
const data = await fetch('/api/performance').then(r => r.json());
const svg = d3.select("#latency-chart")
.append("svg")
.attr("width", 800)
.attr("height", 400);
const line = d3.line()
.x(d => xScale(d.timestamp))
.y(d => yScale(d.latency_p99));
svg.append("path")
.datum(data.latency_history)
.attr("d", line)
.attr("stroke", "steelblue")
.attr("fill", "none");
</script>
Why Mermaid.js for Diagrams
Mermaid.js: Text-to-diagram rendering (flowcharts, sequence diagrams, Gantt charts)
Example: Render system architecture diagram
<div class="mermaid">
graph TB
Browser -->|HTTP| Dashboard
Dashboard -->|gRPC| Proxy
Proxy -->|gRPC| Patterns
</div>
<script type="module">
import mermaid from 'https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.esm.min.mjs';
mermaid.initialize({ startOnLoad: true });
</script>
Benefits:
- ✅ Diagrams as code (version controlled)
- ✅ Auto-layout (no manual positioning)
- ✅ Consistent styling
- ✅ Easy to update
Why Go Templates (vs JSX/Vue Templates)
Go Templates: Type-safe, server-rendered HTML
Example: Render pattern health grid
<!-- dashboard.html -->
<table>
<thead>
<tr>
<th>Pattern</th>
<th>Status</th>
<th>Uptime</th>
</tr>
</thead>
<tbody>
{{range .Patterns}}
<tr>
<td>{{.Name}}</td>
<td class="status-{{.Status}}">{{.Status}}</td>
<td>{{.Uptime}}</td>
</tr>
{{end}}
</tbody>
</table>
Benefits:
- ✅ Type-safe: Compile-time checks for template variables
- ✅ No transpilation: HTML is HTML
- ✅ Server context: Direct access to backend data
- ✅ Security: Auto-escaping prevents XSS
- ✅ Performance: Render once server-side, cache
Implementation
Project Structure
dashboard/
├── main.go # HTTP server entry point
├── handlers/
│ ├── dashboard.go # Dashboard page handler
│ ├── api.go # JSON API handlers (for HTMX)
│ └── websocket.go # WebSocket hub
├── collectors/
│ ├── prometheus.go # Scrape proxy metrics
│ ├── grpc_health.go # Query pattern health
│ ├── signoz.go # Query Signoz API
│ └── admin.go # Query Admin API
├── templates/
│ ├── dashboard.html # Main dashboard template
│ ├── performance.html # Performance view template
│ ├── backends.html # Backend health template
│ └── partials/
│ ├── pattern_grid.html # Reusable pattern grid
│ └── metrics_chart.html # Reusable chart component
├── static/
│ ├── css/
│ │ └── dashboard.css # Custom styles
│ ├── js/
│ │ ├── htmx.min.js # HTMX (14KB)
│ │ ├── d3.v7.min.js # D3.js (70KB)
│ │ ├── mermaid.min.js # Mermaid.js (200KB)
│ │ └── dashboard.js # Custom WebSocket logic
│ └── assets/
│ └── prism-logo.svg # Static assets
└── embed.go # Embed templates/static with go:embed
Main Server (main.go)
package main
import (
"embed"
"html/template"
"log"
"net/http"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
)
//go:embed templates/* static/*
var content embed.FS
var (
templates *template.Template
upgrader = websocket.Upgrader{
ReadBufferSize: 1024,
WriteBufferSize: 1024,
}
)
func init() {
// Parse all templates
templates = template.Must(template.ParseFS(content, "templates/*.html", "templates/partials/*.html"))
}
func main() {
r := mux.NewRouter()
// Serve static files (embedded)
r.PathPrefix("/static/").Handler(http.FileServer(http.FS(content)))
// Page routes (render templates)
r.HandleFunc("/", dashboardHandler).Methods("GET")
r.HandleFunc("/performance", performanceHandler).Methods("GET")
r.HandleFunc("/backends", backendsHandler).Methods("GET")
// API routes (JSON for HTMX)
r.HandleFunc("/api/health", apiHealthHandler).Methods("GET")
r.HandleFunc("/api/patterns", apiPatternsHandler).Methods("GET")
r.HandleFunc("/api/performance", apiPerformanceHandler).Methods("GET")
// WebSocket for real-time updates
r.HandleFunc("/ws", wsHandler)
// Start background data collector
go startCollector()
log.Println("Dashboard starting on :8095")
log.Fatal(http.ListenAndServe(":8095", r))
}
func dashboardHandler(w http.ResponseWriter, r *http.Request) {
data := collectSystemHealth()
templates.ExecuteTemplate(w, "dashboard.html", data)
}
func apiHealthHandler(w http.ResponseWriter, r *http.Request) {
// Return partial HTML for HTMX
data := collectSystemHealth()
templates.ExecuteTemplate(w, "pattern_grid.html", data)
}
func wsHandler(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Println("WebSocket upgrade error:", err)
return
}
defer conn.Close()
ticker := time.NewTicker(2 * time.Second)
defer ticker.Stop()
for range ticker.C {
data := collectSystemHealth()
if err := conn.WriteJSON(data); err != nil {
return
}
}
}
Dashboard Template (templates/dashboard.html)
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Prism Operations Dashboard</title>
<link rel="stylesheet" href="/static/css/dashboard.css">
<script src="/static/js/htmx.min.js"></script>
<script src="/static/js/d3.v7.min.js"></script>
</head>
<body>
<header>
<h1>Prism Operations Dashboard</h1>
<div class="refresh-indicator" id="last-update">Last updated: {{.Timestamp}}</div>
</header>
<main>
<!-- System Status Banner -->
<section class="status-banner status-{{.OverallStatus}}">
<div class="status-icon">{{.OverallStatusIcon}}</div>
<div class="status-text">SYSTEM {{.OverallStatus}}</div>
<div class="metrics-summary">
<span class="metric">✅ {{printf "%.2f" .SuccessRate}}% Success Rate</span>
<span class="metric">📊 {{.RPS}} RPS</span>
<span class="metric">⚡ {{printf "%.1f" .LatencyP99}}ms P99</span>
</div>
</section>
<!-- Pattern Health Grid (Auto-refresh with HTMX) -->
<section class="pattern-health">
<h2>Pattern Health</h2>
<div hx-get="/api/patterns" hx-trigger="load, every 5s" hx-swap="innerHTML">
{{template "pattern_grid.html" .}}
</div>
</section>
<!-- Critical Metrics Charts (D3.js) -->
<section class="metrics-charts">
<h2>Critical Metrics (Last 5 minutes)</h2>
<div class="chart-grid">
<div id="latency-chart" class="chart"></div>
<div id="throughput-chart" class="chart"></div>
<div id="error-chart" class="chart"></div>
</div>
</section>
<!-- Recent Alerts -->
<section class="alerts">
<h2>Recent Alerts</h2>
<ul class="alert-list" hx-get="/api/alerts" hx-trigger="load, every 10s" hx-swap="innerHTML">
{{range .RecentAlerts}}
<li class="alert-{{.Severity}}">
<span class="alert-time">{{.Timestamp}}</span>
{{.Message}}
</li>
{{end}}
</ul>
</section>
</main>
<script src="/static/js/dashboard.js"></script>
<script>
// Render D3.js charts on load
renderLatencyChart({{.LatencyHistory}});
renderThroughputChart({{.ThroughputHistory}});
renderErrorChart({{.ErrorHistory}});
// WebSocket for real-time updates
const ws = new WebSocket('ws://' + location.host + '/ws');
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
updateCharts(data);
document.getElementById('last-update').textContent = 'Last updated: ' + new Date().toLocaleTimeString();
};
</script>
</body>
</html>
Pattern Grid Partial (templates/partials/pattern_grid.html)
<table class="pattern-table">
<thead>
<tr>
<th>Pattern</th>
<th>Status</th>
<th>Phase</th>
<th>Uptime</th>
<th>Restarts</th>
</tr>
</thead>
<tbody>
{{range .Patterns}}
<tr class="pattern-row status-{{.Status}}">
<td class="pattern-name">{{.Name}}</td>
<td class="pattern-status">
<span class="status-indicator status-{{.Status}}">{{.StatusIcon}}</span>
{{.Status}}
</td>
<td class="pattern-phase">{{.Phase}}</td>
<td class="pattern-uptime">{{.Uptime}}</td>
<td class="pattern-restarts">{{.Restarts}}</td>
</tr>
{{end}}
</tbody>
</table>
D3.js Chart Example (static/js/dashboard.js)
// Latency chart rendering
function renderLatencyChart(data) {
const margin = { top: 20, right: 30, bottom: 30, left: 50 };
const width = 600 - margin.left - margin.right;
const height = 300 - margin.top - margin.bottom;
const svg = d3.select("#latency-chart")
.append("svg")
.attr("width", width + margin.left + margin.right)
.attr("height", height + margin.top + margin.bottom)
.append("g")
.attr("transform", `translate(${margin.left},${margin.top})`);
// Scales
const x = d3.scaleTime()
.domain(d3.extent(data, d => new Date(d.timestamp)))
.range([0, width]);
const y = d3.scaleLinear()
.domain([0, d3.max(data, d => Math.max(d.p50, d.p99, d.p999))])
.range([height, 0]);
// Axes
svg.append("g")
.attr("transform", `translate(0,${height})`)
.call(d3.axisBottom(x));
svg.append("g")
.call(d3.axisLeft(y));
// Lines
const lineP50 = d3.line()
.x(d => x(new Date(d.timestamp)))
.y(d => y(d.p50));
const lineP99 = d3.line()
.x(d => x(new Date(d.timestamp)))
.y(d => y(d.p99));
svg.append("path")
.datum(data)
.attr("d", lineP50)
.attr("stroke", "steelblue")
.attr("stroke-width", 2)
.attr("fill", "none");
svg.append("path")
.datum(data)
.attr("d", lineP99)
.attr("stroke", "darkorange")
.attr("stroke-width", 2)
.attr("fill", "none");
// Legend
svg.append("text").attr("x", 10).attr("y", 10).text("P50").attr("fill", "steelblue");
svg.append("text").attr("x", 10).attr("y", 25).text("P99").attr("fill", "darkorange");
}
function updateCharts(data) {
// Update D3.js charts with new data (transition animations)
// Implementation: Update data binding, apply transitions
}
Makefile Integration
# Makefile
.PHONY: dashboard-run dashboard-build dashboard-dev
dashboard-run:
@echo "Starting Dashboard (Go + HTMX)..."
cd dashboard && go run main.go
dashboard-build:
@echo "Building Dashboard binary..."
cd dashboard && go build -o ../bin/prism-dashboard main.go
@echo "Binary: bin/prism-dashboard"
dashboard-dev:
@echo "Starting Dashboard with live reload..."
cd dashboard && air
# air: Live reload tool for Go (install: go install github.com/cosmtrek/air@latest)
dashboard-assets:
@echo "Downloading frontend assets..."
curl -o dashboard/static/js/htmx.min.js https://unpkg.com/htmx.org@1.9.10/dist/htmx.min.js
curl -o dashboard/static/js/d3.v7.min.js https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js
curl -o dashboard/static/js/mermaid.min.js https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js
Embedding Assets
// dashboard/embed.go
package main
import "embed"
//go:embed templates/* static/*
var content embed.FS
// Usage in main.go:
// http.FileServer(http.FS(content))
// templates := template.ParseFS(content, "templates/*.html")
Consequences
Positive
-
Fast Development Cycle
- Change template → save → refresh browser (instant)
- No build step, no npm install, no webpack config
- Live reload with
airtool (optional)
-
Minimal Dependencies
- Go standard library + 2 packages (gorilla/mux, gorilla/websocket)
- 3 frontend libraries (HTMX, D3.js, Mermaid.js) via CDN or local copy
- No npm, no package.json, no node_modules
-
Small Bundle Sizes
- HTMX: 14KB
- D3.js: 70KB
- Mermaid.js: 200KB
- Custom JS: ~10KB
- Total: ~300KB (vs 2-5MB for React bundle)
-
Single Binary Deployment
go buildproduces single executable- Templates and assets embedded with
//go:embed - No separate frontend build/deploy process
-
Type-Safe Templates
- Go templates checked at compile time
- Pass typed structs to templates
- Auto-escaping prevents XSS
-
Progressive Enhancement
- Works without JavaScript (server-rendered HTML)
- HTMX adds interactivity where needed
- Degrades gracefully for accessibility
-
Performance
- Server-side rendering: First paint in
<100ms - Go concurrency: Handle 10k+ concurrent WebSocket connections
- No virtual DOM overhead
- Server-side rendering: First paint in
-
Maintainability
- Standard technologies (HTML, CSS, vanilla JS)
- No framework API to learn
- Code survives framework churn (React 16 → 17 → 18 → 19)
Negative
-
Limited Component Ecosystem
- No pre-built React component libraries
- Mitigation: D3.js + custom components cover 90% of needs
-
Manual State Management
- No Redux/Zustand/MobX
- Mitigation: HTMX handles most state via server, WebSocket for real-time
-
Learning Curve for HTMX
- Developers familiar with React may need to learn HTMX patterns
- Mitigation: HTMX is simpler than React (fewer concepts)
-
D3.js Verbosity
- D3.js charts require more code than Chart.js
- Mitigation: Reusable chart functions, copy-paste examples
-
Go Template Syntax
- Less intuitive than JSX for some developers
- Mitigation: Standard Go idioms, good documentation
Neutral
-
WebSocket vs SSE
- Using WebSocket for real-time updates (bidirectional)
- Could use Server-Sent Events (SSE) for simpler unidirectional push
- Decision: WebSocket chosen for flexibility (future bidirectional commands)
-
CSS Framework
- No strong opinion: plain CSS, Tailwind, or custom
- Recommendation: Start with plain CSS, add Tailwind if needed
Comparison Summary
| Aspect | Framework-Less (Go + HTMX) | Framework-Heavy (FastAPI + React) |
|---|---|---|
| Build time | None (instant reload) | 10-60s (npm build) |
| Dependencies | 5 total | 500+ npm packages |
| Bundle size | 300KB | 2-5MB |
| Initial page load | <100ms | 300-800ms |
| Memory (backend) | 20MB | 100MB+ |
| Binary size | 15MB | N/A (Python runtime) |
| Language | Go (unified) | Python + JavaScript |
| Type safety | Compile-time templates | Runtime (PropTypes/TypeScript) |
| WebSocket | Native goroutines | asyncio complexity |
| Learning curve | Low (HTML + Go templates) | High (React ecosystem) |
| Framework churn | None | High (React/Vue versions) |
Migration Path
From ADR-028 (FastAPI + Ember)
If any FastAPI + Ember code exists:
- Port Python route handlers to Go (straightforward 1:1 mapping)
- Convert Ember templates to Go templates (mostly syntax changes)
- Replace Ember.js with HTMX attributes
- Keep business logic (data collectors, aggregators) with minimal changes
Estimated effort: 2-3 days for complete rewrite (small codebase)
Related Decisions
- ADR-028: Admin UI with FastAPI and gRPC-Web - Superseded by this ADR
- RFC-050: Operations Dashboard (HUD) - Uses this architecture
- RFC-016: Local Development Infrastructure - Dashboard integrates with Signoz/Dex
- ADR-001: Rust for Proxy - Language choices across Prism
- ADR-049: Podman and Container Optimization - Deployment strategy
References
HTMX
- HTMX Official Site
- HTMX Examples
- Hypermedia Systems Book (Carson Gross)
Go Templates
D3.js
Mermaid.js
HashiCorp Internal Tools
- Consul UI Architecture
- Building Simple Web UIs in Go (GopherCon talk)
Revision History
- 2025-11-07: Initial decision for framework-less web UI architecture, supersedes ADR-028