import { Hono } from "hono"; import { html, raw } from "hono/html"; import { streamSSE } from "hono/streaming"; import type { ExtensionRegistry } from "../../extensions/loader.js"; import type { MercuryExtensionContext } from "../../extensions/types.js"; import type { MercuryCoreRuntime } from "../runtime.js"; interface DashboardContext { core: MercuryCoreRuntime; adapters: Record; startTime: number; registry?: ExtensionRegistry; extensionCtx?: MercuryExtensionContext; } type HealthStatus = "healthy" | "degraded" | "critical"; export function createDashboardRoutes(ctx: DashboardContext) { const { core, adapters, startTime, registry, extensionCtx } = ctx; const app = new Hono(); // ─── Helpers ──────────────────────────────────────────────────────────── function formatUptime(seconds: number): string { const d = Math.floor(seconds / 86400); const h = Math.floor((seconds % 86400) / 3600); const m = Math.floor((seconds % 3600) / 60); const s = seconds % 60; if (d > 0) return `${d}d ${h}h`; if (h > 0) return `${h}h ${m}m`; if (m > 0) return `${m}m ${s}s`; return `${s}s`; } function formatRelativeTime(timestamp: number): string { const now = Date.now(); const diff = now - timestamp; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `${days}d ago`; if (hours > 0) return `${hours}h ago`; if (minutes > 0) return `${minutes}m ago`; if (seconds > 10) return `${seconds}s ago`; return "just now"; } function formatFutureTime(timestamp: number): string { const now = Date.now(); const diff = timestamp - now; if (diff < 0) return "now"; const seconds = Math.floor(diff / 1000); const minutes = Math.floor(seconds / 60); const hours = Math.floor(minutes / 60); const days = Math.floor(hours / 24); if (days > 0) return `in ${days}d`; if (hours > 0) return `in ${hours}h ${minutes % 60}m`; if (minutes > 0) return `in ${minutes}m`; return `in ${seconds}s`; } function escapeHtml(str: string): string { if (!str) return ""; return str .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """); } function truncate(str: string, len = 40): string { if (!str) return "—"; return str.length > len ? `${str.slice(0, len)}...` : str; } function getSystemHealth(): { status: HealthStatus; message: string; lastError: string | null; } { const adapterEntries = Object.entries(adapters); const disconnected = adapterEntries.filter(([, connected]) => !connected); const queueBacklog = core.queue.pendingCount > 10; // TODO: Track actual errors in the system const lastError = null; if ( disconnected.length === adapterEntries.length && adapterEntries.length > 0 ) { return { status: "critical", message: "All adapters disconnected", lastError, }; } if (queueBacklog) { return { status: "critical", message: `Queue backing up (${core.queue.pendingCount} pending)`, lastError, }; } if (disconnected.length > 0) { return { status: "degraded", message: `${disconnected.map(([n]) => n).join(", ")} disconnected`, lastError, }; } return { status: "healthy", message: "All systems operational", lastError, }; } function renderExtensionWidgets(): string { if (!registry || !extensionCtx) return ""; const allWidgets: Array<{ extName: string; label: string; html: string }> = []; for (const ext of registry.list()) { for (const widget of ext.widgets) { try { const widgetHtml = widget.render(extensionCtx); allWidgets.push({ extName: ext.name, label: widget.label, html: widgetHtml, }); } catch { allWidgets.push({ extName: ext.name, label: widget.label, html: '

Error rendering widget

', }); } } } if (allWidgets.length === 0) return ""; const widgetPanels = allWidgets .map( (w) => `
${escapeHtml(w.label)} ${escapeHtml(w.extName)}
${w.html}
`, ) .join(""); return `
${widgetPanels}
`; } // ─── Page Routes (htmx content swapping) ──────────────────────────────── // Middleware: redirect direct browser access to main dashboard app.use("/page/*", async (c, next) => { const isHtmx = c.req.header("HX-Request") === "true"; if (!isHtmx) { // Direct browser access - redirect to dashboard with the page in hash const path = c.req.path.replace("/dashboard/page/", ""); return c.redirect(`/dashboard#${path}`); } return next(); }); app.get("/page/overview", (c) => { const activeSpaces = core.containerRunner.getActiveSpaces(); const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000); // Active runs const activeRunsHtml = activeSpaces.length > 0 ? activeSpaces .map((spaceId) => { const space = core.db.getSpace(spaceId); const linked = core.db.getSpaceConversations(spaceId); const platform = linked[0]?.platform ?? "space"; const label = space?.name ?? spaceId; return `
${platform} ${escapeHtml(label)} running
`; }) .join("") : '
No active runs
'; // Adapters const adapterEntries = Object.entries(adapters); const adaptersHtml = adapterEntries .map(([name, connected]) => { const status = connected ? "connected" : "disconnected"; const icon = connected ? "🟢" : "🔴"; return `
${icon} ${name} ${status}
`; }) .join(""); // Recent activity const spaces = core.db.listSpaces(); const activity: Array<{ spaceId: string; spaceName: string; platform: string; role: string; preview: string; time: number; }> = []; for (const space of spaces.slice(0, 5)) { const msgs = core.db.getRecentMessages(space.id, 3); const linked = core.db.getSpaceConversations(space.id); const platform = linked[0]?.platform ?? "space"; for (const m of msgs) { activity.push({ spaceId: space.id, spaceName: space.name, platform, role: m.role, preview: m.content.slice(0, 60), time: m.createdAt, }); } } activity.sort((a, b) => b.time - a.time); const activityHtml = activity.length > 0 ? activity .slice(0, 8) .map( (a) => `
${formatRelativeTime(a.time)} ${a.platform} ${escapeHtml(truncate(a.spaceName, 18))} ${a.role} ${escapeHtml(a.preview)}
`, ) .join("") : '
No recent activity
'; // Upcoming tasks const tasks = core.db.listTasks().filter((t) => t.active); const upcomingHtml = tasks.length > 0 ? tasks .slice(0, 3) .map( (t) => `
#${t.id} ${escapeHtml(truncate(t.prompt, 25))} ${formatFutureTime(t.nextRunAt)}
`, ) .join("") : '
No scheduled tasks
'; return c.html(html`
Adapters
${raw(adaptersHtml)}
Active Work ${activeSpaces.length}
${raw(activeRunsHtml)}
Recent Activity View logs →
${raw(activityHtml)}
Upcoming Tasks View all →
${raw(upcomingHtml)}
Stats
${spaces.length}
Spaces
${core.queue.pendingCount}
Queued
${formatUptime(uptimeSeconds)}
Uptime
${raw(renderExtensionWidgets())} `); }); app.get("/page/spaces", (c) => { const spaces = core.db .listSpaces() .map((s) => { const conversations = core.db.getSpaceConversations(s.id); const msgCount = core.db.getRecentMessages(s.id, 1000).length; return { id: s.id, name: s.name, tags: s.tags, conversationCount: conversations.length, platforms: [...new Set(conversations.map((conv) => conv.platform))], lastActivity: s.updatedAt, messageCount: msgCount, }; }) .sort((a, b) => b.lastActivity - a.lastActivity); const rowsHtml = spaces.length > 0 ? spaces .map( (s) => ` ${escapeHtml(s.name)} ${s.platforms.map((p) => `${escapeHtml(p)}`).join(" ") || ''} ${s.conversationCount} ${s.messageCount} ${formatRelativeTime(s.lastActivity)} `, ) .join("") : 'No spaces yet'; return c.html(html`
${raw(rowsHtml)}
Name Platforms Conversations Messages Last Active
`); }); app.get("/page/spaces/:id", (c) => { const spaceId = decodeURIComponent(c.req.param("id")); const group = core.db.listSpaces().find((g) => g.id === spaceId); if (!group) { return c.html(html`
Space "${escapeHtml(spaceId)}" not found
`); } const linkedConversations = core.db.getSpaceConversations(spaceId); const messages = core.db.getRecentMessages(spaceId, 50); const roles = core.db.listRoles(spaceId); const tasks = core.db.listTasks().filter((t) => t.spaceId === spaceId); const configEntries = core.db.listSpaceConfig(spaceId); const messagesHtml = messages.length > 0 ? messages .map( (m) => `
${m.role} ${formatRelativeTime(m.createdAt)}
${escapeHtml(m.content)}
`, ) .join("") : '
No messages yet
'; const linkedConversationsHtml = linkedConversations.length > 0 ? linkedConversations .map( (conv) => `
${escapeHtml(conv.platform)} ${escapeHtml(conv.observedTitle || conv.externalId)} ${escapeHtml(conv.kind)}
`, ) .join("") : '
No linked conversations
'; const rolesHtml = roles.length > 0 ? roles .map( (r) => `
${escapeHtml(r.platformUserId)} ${r.role}
`, ) .join("") : '
No roles assigned
'; const tasksHtml = tasks.length > 0 ? tasks .map( (t) => `
#${t.id} ${escapeHtml(truncate(t.prompt, 30))} ${t.active ? "active" : "paused"}
`, ) .join("") : '
No tasks for this space
'; const configHtml = configEntries.length > 0 ? configEntries .map( (entry) => `
${escapeHtml(entry.key)} ${escapeHtml(entry.value)}
`, ) .join("") : '
No config overrides
'; return c.html(html`
Linked Conversations
${raw(linkedConversationsHtml)}
Roles
${raw(rolesHtml)}
Tasks
${raw(tasksHtml)}
Config
${raw(configHtml)}
Recent Messages
${raw(messagesHtml)}
`); }); app.get("/page/conversations", (c) => { const spaces = core.db.listSpaces(); const conversations = core.db.listConversations(); const rowsHtml = conversations.length > 0 ? conversations .map((conv) => { const title = conv.observedTitle || conv.externalId; const linked = conv.spaceId ? `${escapeHtml(conv.spaceId)}` : `
`; const action = conv.spaceId ? `` : ""; return ` ${escapeHtml(conv.platform)} ${escapeHtml(title)} ${escapeHtml(conv.kind)} ${linked} ${formatRelativeTime(conv.lastSeenAt)} ${action} `; }) .join("") : 'No conversations yet'; return c.html(html`
${raw(rowsHtml)}
Platform Title Kind Linked Space Last Seen Actions
`); }); app.get("/page/tasks", (c) => { const tasks = core.db.listTasks(); const rowsHtml = tasks.length > 0 ? tasks .map( (t) => ` #${t.id} ${escapeHtml(t.cron || "one-shot")} ${escapeHtml(truncate(t.prompt, 40))} ${formatFutureTime(t.nextRunAt)} ${t.active ? "active" : "paused"} ${ t.active ? `` : `` } `, ) .join("") : 'No scheduled tasks'; return c.html(html`
${raw(rowsHtml)}
ID Schedule Prompt Next Run Status Actions
`); }); app.get("/page/permissions", (c) => { const groups = core.db.listSpaces(); const allRoles: Array<{ spaceId: string; platform: string; userId: string; role: string; }> = []; for (const g of groups) { const platform = g.id.split(":")[0]; const groupRoles = core.db.listRoles(g.id); for (const r of groupRoles) { allRoles.push({ spaceId: g.id, platform, userId: r.platformUserId, role: r.role, }); } } const rowsHtml = allRoles.length > 0 ? allRoles .map( (r) => ` ${r.platform} ${escapeHtml(truncate(r.spaceId, 25))} ${escapeHtml(r.userId)} ${r.role} `, ) .join("") : 'No roles assigned'; return c.html(html`
${raw(rowsHtml)}
Platform Space User Role Actions
`); }); app.get("/page/logs", (c) => { // Aggregate recent messages as "logs" for now // In a real system, you'd have a proper log store const groups = core.db.listSpaces(); const logs: Array<{ time: number; level: string; source: string; message: string; spaceId?: string; }> = []; // Add message events as logs for (const g of groups) { const msgs = core.db.getRecentMessages(g.id, 10); const platform = g.id.split(":")[0]; for (const m of msgs) { logs.push({ time: m.createdAt, level: "INFO", source: platform, message: `${m.role}: ${m.content.slice(0, 80)}`, spaceId: g.id, }); } } logs.sort((a, b) => b.time - a.time); const logsHtml = logs.length > 0 ? logs .slice(0, 50) .map( (l) => `
${new Date(l.time).toLocaleTimeString()} ${l.level} ${l.source} ${escapeHtml(l.message)}
`, ) .join("") : '
No logs available
'; return c.html(html`
${raw(logsHtml)}
`); }); // ─── SSE Stream ───────────────────────────────────────────────────────── app.get("/events", (c) => { return streamSSE(c, async (stream) => { const sendEvent = async (event: string, data: string) => { await stream.writeSSE({ event, data: data.replace(/\n/g, "") }); }; const renderHealth = () => { const health = getSystemHealth(); const uptimeSeconds = Math.floor((Date.now() - startTime) / 1000); const icon = health.status === "healthy" ? "🟢" : health.status === "degraded" ? "🟡" : "🔴"; const lastError = health.lastError ? `Last error: ${health.lastError}` : ""; return `
${icon} ${health.message}
up ${formatUptime(uptimeSeconds)} ${lastError ? `${lastError}` : ""}
`; }; const renderActiveCount = () => { const count = core.containerRunner.activeCount; return count > 0 ? `${count} running` : ""; }; // Send initial state await sendEvent("health", renderHealth()); await sendEvent("active-count", renderActiveCount()); // Update loop let running = true; let lastActiveCount = core.containerRunner.activeCount; stream.onAbort(() => { running = false; }); while (running) { await stream.sleep(1000); // Always update health (includes uptime) await sendEvent("health", renderHealth()); // Update active count only on change const currentActiveCount = core.containerRunner.activeCount; if (currentActiveCount !== lastActiveCount) { await sendEvent("active-count", renderActiveCount()); lastActiveCount = currentActiveCount; } } }); }); // ─── Dashboard Actions (no auth required, admin-only UI) ──────────────── app.post("/api/tasks/:id/run", async (c) => { const taskId = Number.parseInt(c.req.param("id"), 10); const task = core.db.listTasks().find((t) => t.id === taskId); if (!task) { return c.json({ error: "Task not found" }, 404); } const triggered = await core.scheduler.triggerTask(taskId); if (!triggered) { return c.json({ error: "Task not found or inactive" }, 400); } return c.json({ ok: true }); }); app.post("/api/tasks/:id/pause", (c) => { const taskId = Number.parseInt(c.req.param("id"), 10); const task = core.db.listTasks().find((t) => t.id === taskId); if (!task) { return c.json({ error: "Task not found" }, 404); } core.db.setTaskActive(taskId, false); return c.json({ ok: true }); }); app.post("/api/tasks/:id/resume", (c) => { const taskId = Number.parseInt(c.req.param("id"), 10); const task = core.db.listTasks().find((t) => t.id === taskId); if (!task) { return c.json({ error: "Task not found" }, 404); } core.db.setTaskActive(taskId, true); return c.json({ ok: true }); }); app.delete("/api/tasks/:id", (c) => { const taskId = Number.parseInt(c.req.param("id"), 10); const task = core.db.listTasks().find((t) => t.id === taskId); if (!task) { return c.json({ error: "Task not found" }, 404); } const deleted = core.db.deleteTask(taskId, task.spaceId); if (!deleted) { return c.json({ error: "Failed to delete task" }, 500); } return c.json({ ok: true }); }); app.delete("/api/roles", (c) => { const spaceId = c.req.query("spaceId"); const platformUserId = c.req.query("platformUserId"); if (!spaceId || !platformUserId) { return c.json({ error: "Missing spaceId or platformUserId" }, 400); } core.db.deleteRole(spaceId, platformUserId); return c.json({ ok: true }); }); app.post("/api/conversations/:id/link", async (c) => { const conversationId = Number.parseInt(c.req.param("id"), 10); if (!Number.isFinite(conversationId) || conversationId < 1) { return c.json({ error: "Invalid conversation ID" }, 400); } const form = await c.req.parseBody(); const spaceId = typeof form.spaceId === "string" ? form.spaceId : undefined; if (!spaceId) { return c.json({ error: "Missing spaceId" }, 400); } const space = core.db.getSpace(spaceId); if (!space) { return c.json({ error: "Space not found" }, 404); } const linked = core.db.linkConversation(conversationId, spaceId); if (!linked) { return c.json({ error: "Conversation not found" }, 404); } return c.json({ ok: true }); }); app.post("/api/conversations/:id/unlink", (c) => { const conversationId = Number.parseInt(c.req.param("id"), 10); if (!Number.isFinite(conversationId) || conversationId < 1) { return c.json({ error: "Invalid conversation ID" }, 400); } const unlinked = core.db.unlinkConversation(conversationId); if (!unlinked) { return c.json({ error: "Conversation not found" }, 404); } return c.json({ ok: true }); }); app.post("/api/stop", (c) => { const spaceId = c.req.header("X-Mercury-Space"); if (!spaceId) { return c.json({ error: "Missing X-Mercury-Space header" }, 400); } core.containerRunner.abort(spaceId); return c.json({ ok: true }); }); return app; }