Skip to main content

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:

  1. Operations Dashboard (HUD): Real-time monitoring of proxy, patterns, backends (RFC-050)
  2. 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

  1. Fast development: Change template → refresh browser (instant feedback)
  2. Zero build step: No npm, webpack, babel, transpilers
  3. Lightweight: Minimal JavaScript, small page sizes
  4. Native performance: Server-side rendering, progressive enhancement
  5. Simple deployment: Single Go binary, no Node.js runtime
  6. Easy maintenance: Less code, fewer dependencies, standard technologies
  7. 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/http standard library + gorilla/mux for routing
  • Templates: Go html/template (standard library)
  • WebSocket: gorilla/websocket for 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:embed directive
  • 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)

AspectGoPython/FastAPI
Performance10-100x fasterBaseline
Memory~20MB~100MB+
DeploymentSingle binaryPython + deps
Language consistencySame as patternsContext switch
Type safetyCompile-timeRuntime (unless typed)
ConcurrencyGoroutines (native)asyncio (complex)
Build time~2sN/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:

FeatureHTMXReactVueSvelte
Size14KB140KB90KB60KB
Build required❌ No✅ Yes✅ Yes✅ Yes
Dependencies0200+150+100+
Learning curveLowHighMediumMedium
SSR complexityN/AHighHighMedium

Why D3.js for Visualization

D3.js: Data-Driven Documents, best-in-class JavaScript visualization library

Alternatives Considered:

LibrarySizeFeaturesVerdict
D3.js70KBFull control, animations, custom chartsChosen
Chart.js200KBSimple API, limited customization❌ Too basic
Recharts500KB+React component library❌ Requires React
Plotly3MBFeature-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

  1. Fast Development Cycle

    • Change template → save → refresh browser (instant)
    • No build step, no npm install, no webpack config
    • Live reload with air tool (optional)
  2. 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
  3. Small Bundle Sizes

    • HTMX: 14KB
    • D3.js: 70KB
    • Mermaid.js: 200KB
    • Custom JS: ~10KB
    • Total: ~300KB (vs 2-5MB for React bundle)
  4. Single Binary Deployment

    • go build produces single executable
    • Templates and assets embedded with //go:embed
    • No separate frontend build/deploy process
  5. Type-Safe Templates

    • Go templates checked at compile time
    • Pass typed structs to templates
    • Auto-escaping prevents XSS
  6. Progressive Enhancement

    • Works without JavaScript (server-rendered HTML)
    • HTMX adds interactivity where needed
    • Degrades gracefully for accessibility
  7. Performance

    • Server-side rendering: First paint in <100ms
    • Go concurrency: Handle 10k+ concurrent WebSocket connections
    • No virtual DOM overhead
  8. Maintainability

    • Standard technologies (HTML, CSS, vanilla JS)
    • No framework API to learn
    • Code survives framework churn (React 16 → 17 → 18 → 19)

Negative

  1. Limited Component Ecosystem

    • No pre-built React component libraries
    • Mitigation: D3.js + custom components cover 90% of needs
  2. Manual State Management

    • No Redux/Zustand/MobX
    • Mitigation: HTMX handles most state via server, WebSocket for real-time
  3. Learning Curve for HTMX

    • Developers familiar with React may need to learn HTMX patterns
    • Mitigation: HTMX is simpler than React (fewer concepts)
  4. D3.js Verbosity

    • D3.js charts require more code than Chart.js
    • Mitigation: Reusable chart functions, copy-paste examples
  5. Go Template Syntax

    • Less intuitive than JSX for some developers
    • Mitigation: Standard Go idioms, good documentation

Neutral

  1. 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)
  2. CSS Framework

    • No strong opinion: plain CSS, Tailwind, or custom
    • Recommendation: Start with plain CSS, add Tailwind if needed

Comparison Summary

AspectFramework-Less (Go + HTMX)Framework-Heavy (FastAPI + React)
Build timeNone (instant reload)10-60s (npm build)
Dependencies5 total500+ npm packages
Bundle size300KB2-5MB
Initial page load<100ms300-800ms
Memory (backend)20MB100MB+
Binary size15MBN/A (Python runtime)
LanguageGo (unified)Python + JavaScript
Type safetyCompile-time templatesRuntime (PropTypes/TypeScript)
WebSocketNative goroutinesasyncio complexity
Learning curveLow (HTML + Go templates)High (React ecosystem)
Framework churnNoneHigh (React/Vue versions)

Migration Path

From ADR-028 (FastAPI + Ember)

If any FastAPI + Ember code exists:

  1. Port Python route handlers to Go (straightforward 1:1 mapping)
  2. Convert Ember templates to Go templates (mostly syntax changes)
  3. Replace Ember.js with HTMX attributes
  4. Keep business logic (data collectors, aggregators) with minimal changes

Estimated effort: 2-3 days for complete rewrite (small codebase)

References

HTMX

Go Templates

D3.js

Mermaid.js

HashiCorp Internal Tools

Revision History

  • 2025-11-07: Initial decision for framework-less web UI architecture, supersedes ADR-028