;
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) => `
`,
)
.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`
${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`
| Name |
Platforms |
Conversations |
Messages |
Last Active |
${raw(rowsHtml)}
`);
});
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`
${raw(linkedConversationsHtml)}
`);
});
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`
| Platform |
Title |
Kind |
Linked Space |
Last Seen |
Actions |
${raw(rowsHtml)}
`);
});
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`
| ID |
Schedule |
Prompt |
Next Run |
Status |
Actions |
${raw(rowsHtml)}
`);
});
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`
| Platform |
Space |
User |
Role |
Actions |
${raw(rowsHtml)}
`);
});
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`
`);
});
// ─── 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;
}