Created
February 10, 2026 09:35
-
-
Save espennilsen/6512812b74c8d8cd11a71ed3cfa4bb05 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Hannah — Web UI adapter. | |
| * | |
| * Serves a dashboard for monitoring agent activity. Uses Node's built-in | |
| * http module — no framework dependencies. | |
| * | |
| * Routes: | |
| * GET / — Dashboard (inline HTML/CSS/JS) | |
| * GET /api/stats — JSON totals | |
| * GET /api/jobs — JSON recent jobs | |
| * GET /api/jobs/:id — JSON single job + tool calls | |
| * GET /api/daily — JSON daily stats | |
| * GET /api/models — JSON model breakdown | |
| * GET /api/tools — JSON tool breakdown | |
| * GET /api/sections — Registered dashboard sections (for dynamic injection) | |
| * GET /events — SSE stream of real-time events | |
| * | |
| * Extensions can register additional routes via addRoute() and dashboard | |
| * sections via addDashboardSection(). See types.ts for the interfaces. | |
| * | |
| * Also implements Plugin to receive lifecycle events and push them via SSE. | |
| */ | |
| import * as http from "node:http"; | |
| import * as fs from "node:fs"; | |
| import * as path from "node:path"; | |
| import type { Adapter, Plugin, HannahServer, JobRecord, ToolCallRecord, WebRouteHandler, DashboardSection } from "../types.ts"; | |
| import type { WebConfig } from "../config.ts"; | |
| import { jobsApi } from "../extensions/jobs/jobs-db.ts"; | |
| // ── Simple rate limiter ───────────────────────────────────────── | |
| class RateLimiter { | |
| private hits = new Map<string, number[]>(); | |
| constructor(private maxRequests: number, private windowMs: number) {} | |
| isAllowed(key: string): boolean { | |
| const now = Date.now(); | |
| const timestamps = this.hits.get(key)?.filter(t => now - t < this.windowMs) ?? []; | |
| if (timestamps.length >= this.maxRequests) { | |
| this.hits.set(key, timestamps); | |
| return false; | |
| } | |
| timestamps.push(now); | |
| this.hits.set(key, timestamps); | |
| return true; | |
| } | |
| } | |
| // Dashboard HTML is lazy-loaded on first request to avoid crashing at import time. | |
| let _dashboardHtml: string | null = null; | |
| function getDashboardHtml(): string { | |
| if (!_dashboardHtml) { | |
| _dashboardHtml = fs.readFileSync( | |
| path.resolve(import.meta.dirname, "dashboard.html"), | |
| "utf-8", | |
| ); | |
| } | |
| return _dashboardHtml; | |
| } | |
| // Feature pages (tasks, projects, vault, calendar) are now served by their | |
| // respective modules via addWebRoute(). Only the core dashboard stays here. | |
| export class WebAdapter implements Adapter, Plugin { | |
| readonly name = "web"; | |
| private config: WebConfig; | |
| private httpServer: http.Server | null = null; | |
| private server!: HannahServer; | |
| private sseClients = new Set<http.ServerResponse>(); | |
| private startedAt = new Date(); | |
| private customRoutes = new Map<string, WebRouteHandler>(); | |
| private dashboardSections: DashboardSection[] = []; | |
| private promptLimiter = new RateLimiter(10, 60_000); // 10 requests per minute | |
| constructor(config: WebConfig) { | |
| this.config = config; | |
| } | |
| // ── Web extension API ─────────────────────────────────────── | |
| /** | |
| * Register a custom API route. Key format: "METHOD /path". | |
| * The handler receives (req, res, url). | |
| */ | |
| addRoute(method: string, routePath: string, handler: WebRouteHandler): void { | |
| this.customRoutes.set(`${method.toUpperCase()} ${routePath}`, handler); | |
| } | |
| /** | |
| * Register a dashboard section to be rendered in the web UI. | |
| * Sections are served via GET /api/sections and loaded dynamically. | |
| */ | |
| addDashboardSection(section: DashboardSection): void { | |
| this.dashboardSections.push(section); | |
| this.dashboardSections.sort((a, b) => a.order - b.order); | |
| } | |
| /** | |
| * Deliver a heartbeat message via SSE to all connected dashboard clients. | |
| */ | |
| async deliverHeartbeat(message: string): Promise<void> { | |
| this.broadcast({ type: "heartbeat", message, time: new Date().toISOString() }); | |
| } | |
| // ── Adapter interface ─────────────────────────────────────── | |
| async start(server: HannahServer): Promise<void> { | |
| this.server = server; | |
| // Warn if listening beyond localhost without auth | |
| if (this.config.host !== "127.0.0.1" && this.config.host !== "localhost" && !this.config.auth) { | |
| console.warn( | |
| `[web] ⚠️ Listening on ${this.config.host}:${this.config.port} WITHOUT authentication. ` + | |
| `Set web.auth: true and HANNAH_WEB_ADMIN to secure the dashboard.` | |
| ); | |
| } | |
| this.httpServer = http.createServer((req, res) => this.handleRequest(req, res)); | |
| this.httpServer.listen(this.config.port, this.config.host); | |
| } | |
| async stop(): Promise<void> { | |
| for (const client of this.sseClients) { | |
| try { client.end(); } catch {} | |
| } | |
| this.sseClients.clear(); | |
| return new Promise((resolve) => { | |
| if (this.httpServer) { | |
| this.httpServer.close(() => resolve()); | |
| } else { | |
| resolve(); | |
| } | |
| }); | |
| } | |
| // ── Plugin interface ──────────────────────────────────────── | |
| onJobCreated(job: JobRecord): void { | |
| this.broadcast({ type: "job_created", job: this.summarizeJob(job) }); | |
| } | |
| onJobDone(job: JobRecord): void { | |
| this.broadcast({ type: "job_done", job: this.summarizeJob(job) }); | |
| } | |
| onJobFailed(job: JobRecord): void { | |
| this.broadcast({ type: "job_failed", job: this.summarizeJob(job) }); | |
| } | |
| onToolCall(toolCall: ToolCallRecord): void { | |
| this.broadcast({ type: "tool_call", toolCall }); | |
| } | |
| onStreamEvent(event: any): void { | |
| switch (event.type) { | |
| case "agent_start": | |
| this.broadcast({ type: "stream_agent_start", time: new Date().toISOString() }); | |
| break; | |
| case "agent_end": | |
| this.broadcast({ type: "stream_agent_end", time: new Date().toISOString() }); | |
| break; | |
| case "message_update": | |
| if (event.assistantMessageEvent?.type === "text_delta") { | |
| this.broadcast({ | |
| type: "stream_text_delta", | |
| delta: event.assistantMessageEvent.delta, | |
| }); | |
| } | |
| break; | |
| case "tool_execution_start": | |
| this.broadcast({ | |
| type: "stream_tool_start", | |
| toolName: event.toolName, | |
| args: event.args, | |
| }); | |
| break; | |
| case "tool_execution_end": | |
| this.broadcast({ | |
| type: "stream_tool_end", | |
| toolName: event.toolName, | |
| isError: event.isError, | |
| }); | |
| break; | |
| case "turn_start": | |
| this.broadcast({ type: "stream_turn_start" }); | |
| break; | |
| case "turn_end": | |
| this.broadcast({ type: "stream_turn_end" }); | |
| break; | |
| } | |
| } | |
| // ── HTTP handler ──────────────────────────────────────────── | |
| private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void { | |
| const url = new URL(req.url ?? "/", `http://${req.headers.host}`); | |
| const pathname = url.pathname; | |
| // CORS for local dev | |
| res.setHeader("Access-Control-Allow-Origin", "*"); | |
| // HTTP Basic Auth — only enforced when auth is enabled and HANNAH_WEB_ADMIN is set | |
| if (this.config.auth && this.config.adminPassword) { | |
| const auth = req.headers.authorization ?? ""; | |
| let valid = false; | |
| if (auth.startsWith("Basic ")) { | |
| try { | |
| const decoded = Buffer.from(auth.slice(6), "base64").toString("utf-8"); | |
| // Accept any username, just check password | |
| const password = decoded.includes(":") ? decoded.slice(decoded.indexOf(":") + 1) : decoded; | |
| valid = password === this.config.adminPassword; | |
| } catch {} | |
| } | |
| if (!valid) { | |
| res.writeHead(401, { | |
| "Content-Type": "text/plain", | |
| "WWW-Authenticate": 'Basic realm="Hannah Dashboard"', | |
| }); | |
| res.end("Unauthorized"); | |
| return; | |
| } | |
| } | |
| try { | |
| // Check custom routes first — feature modules register via addRoute() | |
| const routeKey = `${req.method?.toUpperCase()} ${pathname}`; | |
| const customHandler = this.customRoutes.get(routeKey); | |
| if (customHandler) { | |
| customHandler(req, res, url); | |
| return; | |
| } | |
| if (pathname === "/" || pathname === "/index.html") { | |
| this.serveDashboard(res); | |
| } else if (pathname === "/events") { | |
| this.serveSSE(req, res); | |
| } else if (pathname === "/api/stats") { | |
| this.json(res, jobsApi.getTotals()); | |
| } else if (pathname === "/api/jobs") { | |
| const limit = parseInt(url.searchParams.get("limit") ?? "50"); | |
| const channel = url.searchParams.get("channel") ?? undefined; | |
| this.json(res, jobsApi.getRecentJobs(limit, channel)); | |
| } else if (pathname.startsWith("/api/jobs/")) { | |
| const id = pathname.slice("/api/jobs/".length); | |
| const job = jobsApi.getJob(id); | |
| if (!job) { | |
| res.writeHead(404, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Job not found" })); | |
| return; | |
| } | |
| this.json(res, { job, toolCalls: jobsApi.getJobToolCalls(id) }); | |
| } else if (pathname === "/api/daily") { | |
| this.json(res, jobsApi.getDailyStats()); | |
| } else if (pathname === "/api/models") { | |
| this.json(res, jobsApi.getModelBreakdown()); | |
| } else if (pathname === "/api/tools") { | |
| this.json(res, jobsApi.getToolBreakdown()); | |
| } else if (pathname === "/api/prompt" && req.method === "POST") { | |
| this.handlePrompt(req, res); | |
| return; | |
| } else if (pathname === "/api/prompt" && req.method === "OPTIONS") { | |
| res.setHeader("Access-Control-Allow-Methods", "POST, OPTIONS"); | |
| res.setHeader("Access-Control-Allow-Headers", "Content-Type, Authorization"); | |
| res.writeHead(204); | |
| res.end(); | |
| return; | |
| } else if (pathname === "/api/sections") { | |
| this.json(res, this.dashboardSections); | |
| } else if (pathname === "/api/commands") { | |
| this.json(res, this.server.getCommands()); | |
| } else if (pathname === "/api/config") { | |
| this.json(res, { | |
| ...this.server.config, | |
| startedAt: this.startedAt.toISOString(), | |
| uptime: Math.floor((Date.now() - this.startedAt.getTime()) / 1000), | |
| sseClients: this.sseClients.size, | |
| }); | |
| } else { | |
| res.writeHead(404, { "Content-Type": "text/plain" }); | |
| res.end("Not found"); | |
| } | |
| } catch (err: any) { | |
| res.writeHead(500, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: err.message })); | |
| } | |
| } | |
| // ── Prompt handler ─────────────────────────────────────────── | |
| private handlePrompt(req: http.IncomingMessage, res: http.ServerResponse): void { | |
| // Rate limiting — 10 requests per minute per IP | |
| const clientIp = req.socket.remoteAddress ?? "unknown"; | |
| if (!this.promptLimiter.isAllowed(clientIp)) { | |
| res.writeHead(429, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Too many requests. Max 10 per minute." })); | |
| return; | |
| } | |
| // Token auth — only enforced when auth is enabled | |
| if (this.config.auth) { | |
| if (!this.config.token) { | |
| res.writeHead(403, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Prompt endpoint disabled. Set HANNAH_WEB_PASSWORD to enable." })); | |
| return; | |
| } | |
| const auth = req.headers.authorization ?? ""; | |
| const token = auth.startsWith("Bearer ") ? auth.slice(7) : ""; | |
| if (token !== this.config.token) { | |
| res.writeHead(401, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Invalid or missing token" })); | |
| return; | |
| } | |
| } | |
| const MAX_BODY_SIZE = 1_048_576; // 1 MB | |
| let body = ""; | |
| let oversized = false; | |
| req.on("data", (chunk: Buffer) => { | |
| body += chunk.toString(); | |
| if (body.length > MAX_BODY_SIZE) { | |
| oversized = true; | |
| req.destroy(); | |
| } | |
| }); | |
| req.on("end", async () => { | |
| if (oversized) { | |
| res.writeHead(413, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Request body too large (max 1MB)" })); | |
| return; | |
| } | |
| try { | |
| const { prompt } = JSON.parse(body); | |
| if (!prompt || typeof prompt !== "string" || !prompt.trim()) { | |
| res.writeHead(400, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Missing prompt" })); | |
| return; | |
| } | |
| // Acknowledge immediately — client can watch SSE for job_created | |
| // to get the real job ID, or poll /api/jobs for recent jobs. | |
| res.writeHead(202, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ status: "accepted" })); | |
| // Run in background | |
| this.server.runJob({ | |
| channel: "web", | |
| chatId: "web-dashboard", | |
| prompt: prompt.trim(), | |
| }).catch((err) => { | |
| console.error("[web] prompt job failed:", err); | |
| }); | |
| } catch (err: any) { | |
| res.writeHead(400, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify({ error: "Invalid JSON" })); | |
| } | |
| }); | |
| } | |
| // ── SSE ───────────────────────────────────────────────────── | |
| private serveSSE(req: http.IncomingMessage, res: http.ServerResponse): void { | |
| res.writeHead(200, { | |
| "Content-Type": "text/event-stream", | |
| "Cache-Control": "no-cache", | |
| "Connection": "keep-alive", | |
| }); | |
| // Send initial connected event | |
| res.write(`data: ${JSON.stringify({ type: "connected", time: new Date().toISOString() })}\n\n`); | |
| this.sseClients.add(res); | |
| req.on("close", () => { | |
| this.sseClients.delete(res); | |
| }); | |
| } | |
| private broadcast(data: any): void { | |
| const payload = `data: ${JSON.stringify(data)}\n\n`; | |
| for (const client of this.sseClients) { | |
| try { client.write(payload); } catch {} | |
| } | |
| } | |
| // ── Helpers ────────────────────────────────────────────────── | |
| private json(res: http.ServerResponse, data: any): void { | |
| res.writeHead(200, { "Content-Type": "application/json" }); | |
| res.end(JSON.stringify(data)); | |
| } | |
| private summarizeJob(job: JobRecord): any { | |
| return { | |
| id: job.id, | |
| channel: job.channel, | |
| status: job.status, | |
| model: job.model, | |
| prompt: job.prompt.slice(0, 200), | |
| total_tokens: job.total_tokens, | |
| cost_total: job.cost_total, | |
| tool_call_count: job.tool_call_count, | |
| duration_ms: job.duration_ms, | |
| error_message: job.error_message, | |
| created_at: job.created_at, | |
| finished_at: job.finished_at, | |
| }; | |
| } | |
| // ── Dashboard HTML ────────────────────────────────────────── | |
| private serveDashboard(res: http.ServerResponse): void { | |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | |
| res.end(getDashboardHtml()); | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment