/** * Data module — privacy-first analytics dashboard. * * Lightweight module that shows analytics stats from the * self-hosted Umami instance. No database — proxies to Umami API. */ import { Hono } from "hono"; import { renderShell } from "../../server/shell"; import { getAllModules, getModuleInfoList } from "../../shared/module"; import type { RSpaceModule } from "../../shared/module"; import { syncServer } from "../../server/sync-instance"; import { renderLanding } from "./landing"; const routes = new Hono(); const UMAMI_URL = process.env.UMAMI_URL || "https://analytics.rspace.online"; const UMAMI_WEBSITE_ID = process.env.UMAMI_WEBSITE_ID || "292f6ac6-79f8-497b-ba6a-7a51e3b87b9f"; const TRACKED_APPS = [ "rSpace", "rNotes", "rVote", "rFlows", "rCart", "rWallet", "rPubs", "rChoices", "rCal", "rForum", "rInbox", "rFiles", "rTrips", "rTube", "rTasks", "rNetwork", "rData", ]; // ── API routes ── // GET /api/info — module info routes.get("/api/info", (c) => { return c.json({ module: "data", name: "rData", umamiUrl: UMAMI_URL, umamiConfigured: !!UMAMI_URL, features: ["privacy-first", "cookieless", "self-hosted"], trackedApps: TRACKED_APPS.length, }); }); // GET /api/health routes.get("/api/health", (c) => c.json({ ok: true })); // GET /api/stats — proxy to Umami stats API routes.get("/api/stats", async (c) => { const startAt = c.req.query("startAt") || String(Date.now() - 24 * 3600_000); const endAt = c.req.query("endAt") || String(Date.now()); try { const res = await fetch( `${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/stats?startAt=${startAt}&endAt=${endAt}`, { signal: AbortSignal.timeout(5000) } ); if (res.ok) { const data = await res.json(); return c.json({ ...data as Record, trackedApps: TRACKED_APPS.length, apps: TRACKED_APPS, selfHosted: true, dashboardUrl: UMAMI_URL, }); } } catch {} // Fallback when Umami unreachable return c.json({ trackedApps: TRACKED_APPS.length, cookiesSet: 0, scriptSize: "~2KB", selfHosted: true, dashboardUrl: UMAMI_URL, apps: TRACKED_APPS, }); }); // GET /api/active — proxy to Umami active visitors routes.get("/api/active", async (c) => { try { const res = await fetch( `${UMAMI_URL}/api/websites/${UMAMI_WEBSITE_ID}/active`, { signal: AbortSignal.timeout(5000) } ); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ x: 0 }); }); // GET /collect.js — proxy Umami tracker script routes.get("/collect.js", async (c) => { try { const res = await fetch(`${UMAMI_URL}/script.js`, { signal: AbortSignal.timeout(5000) }); if (res.ok) { const script = await res.text(); return new Response(script, { headers: { "Content-Type": "application/javascript", "Cache-Control": "public, max-age=3600", }, }); } } catch {} return new Response("/* umami unavailable */", { headers: { "Content-Type": "application/javascript" }, }); }); // POST /api/collect — proxy Umami event collection routes.post("/api/collect", async (c) => { try { const body = await c.req.text(); const res = await fetch(`${UMAMI_URL}/api/send`, { method: "POST", headers: { "Content-Type": "application/json" }, body, signal: AbortSignal.timeout(5000), }); if (res.ok) return c.json(await res.json()); } catch {} return c.json({ ok: true }); }); // ── Content Tree API ── /** Build a module icon/name lookup from registered modules */ function getModuleMeta(): Map { const map = new Map(); for (const m of getAllModules()) { // Module IDs in doc keys omit the 'r' prefix and are lowercase, e.g. "notes" for rNotes map.set(m.id.replace(/^r/, ""), { name: m.name, icon: m.icon }); // Also map the full ID for direct matches map.set(m.id, { name: m.name, icon: m.icon }); } return map; } /** Extract a human-readable title from an Automerge doc */ function extractTitle(doc: any): string | null { if (!doc) return null; if (doc.meta?.title) return doc.meta.title; if (doc.title) return doc.title; if (doc.name) return doc.name; if (doc.spaceConfig?.title) return doc.spaceConfig.title; return null; } /** Extract tags from an Automerge doc */ function extractTags(doc: any): string[] { if (!doc) return []; if (Array.isArray(doc.tags)) return doc.tags.map(String); if (Array.isArray(doc.meta?.tags)) return doc.meta.tags.map(String); return []; } /** Count items in a doc (for collection-level docs with Record-like data) */ function countItems(doc: any): number | null { if (!doc) return null; // Check common collection patterns if (doc.items && typeof doc.items === "object") return Object.keys(doc.items).length; if (doc.entries && typeof doc.entries === "object") return Object.keys(doc.entries).length; if (doc.notes && typeof doc.notes === "object") return Object.keys(doc.notes).length; if (doc.proposals && typeof doc.proposals === "object") return Object.keys(doc.proposals).length; if (doc.tasks && typeof doc.tasks === "object") return Object.keys(doc.tasks).length; if (doc.events && typeof doc.events === "object") return Object.keys(doc.events).length; if (doc.threads && typeof doc.threads === "object") return Object.keys(doc.threads).length; if (doc.files && typeof doc.files === "object") return Object.keys(doc.files).length; return null; } routes.get("/api/content-tree", (c) => { const space = c.req.query("space") || c.req.param("space") || "demo"; const moduleMeta = getModuleMeta(); const allDocIds = syncServer.listDocs(); const prefix = `${space}:`; const spaceDocIds = allDocIds.filter((id) => id.startsWith(prefix)); // Group by module → collection → items const moduleMap = new Map>>(); for (const docId of spaceDocIds) { const parts = docId.split(":"); // Format: {space}:{module}:{collection}[:{itemId}] if (parts.length < 3) continue; const [, modKey, collection, ...rest] = parts; const itemId = rest.length ? rest.join(":") : null; if (!moduleMap.has(modKey)) moduleMap.set(modKey, new Map()); const colMap = moduleMap.get(modKey)!; if (!colMap.has(collection)) colMap.set(collection, []); const doc = syncServer.getDoc(docId); colMap.get(collection)!.push({ docId, title: extractTitle(doc), itemCount: countItems(doc), createdAt: (doc as any)?.meta?.createdAt ?? null, tags: extractTags(doc), itemId, }); } // Build response const modules: any[] = []; for (const [modKey, colMap] of moduleMap) { const meta = moduleMeta.get(modKey) || { name: modKey, icon: "📄" }; const collections: any[] = []; for (const [colName, items] of colMap) { collections.push({ collection: colName, items: items.map((it) => ({ docId: it.docId, title: it.title || it.itemId || colName, itemCount: it.itemCount, createdAt: it.createdAt, tags: it.tags, })), }); } modules.push({ id: modKey, name: meta.name, icon: meta.icon, collections, }); } // Sort modules alphabetically by name modules.sort((a, b) => a.name.localeCompare(b.name)); return c.json({ space, modules }); }); // ── Tab routing ── const DATA_TABS = [ { id: "tree", label: "Content Tree", icon: "🌳" }, { id: "analytics", label: "Analytics", icon: "📊" }, ] as const; const DATA_TAB_IDS = new Set(DATA_TABS.map((t) => t.id)); function renderDataPage(space: string, activeTab: string) { const isTree = activeTab === "tree"; const body = isTree ? `` : ``; const scripts = isTree ? `` : ``; return renderShell({ title: `${space} — Data | rSpace`, moduleId: "rdata", spaceSlug: space, modules: getModuleInfoList(), theme: "dark", body, scripts, styles: ``, tabs: [...DATA_TABS], activeTab, tabBasePath: process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, }); } // ── Page routes ── routes.get("/", (c) => { const space = c.req.param("space") || "demo"; return c.html(renderDataPage(space, "tree")); }); routes.get("/:tabId", (c, next) => { const space = c.req.param("space") || "demo"; const tabId = c.req.param("tabId"); // Skip API and asset routes — let Hono fall through if (tabId.startsWith("api") || tabId.includes(".")) return next(); if (!DATA_TAB_IDS.has(tabId as any)) { return c.redirect(process.env.NODE_ENV === "production" ? `/rdata` : `/${space}/rdata`, 302); } return c.html(renderDataPage(space, tabId)); }); export const dataModule: RSpaceModule = { id: "rdata", name: "rData", icon: "📊", description: "Privacy-first analytics for the r* ecosystem", scoping: { defaultScope: 'global', userConfigurable: false }, routes, standaloneDomain: "rdata.online", landingPage: renderLanding, feeds: [ { id: "analytics", name: "Analytics Stream", kind: "attention", description: "Page views, active visitors, and engagement metrics across rApps", filterable: true, }, { id: "active-users", name: "Active Users", kind: "attention", description: "Real-time active visitor counts", }, ], acceptsFeeds: ["data", "economic"], outputPaths: [ { path: "datasets", name: "Datasets", icon: "📊", description: "Collected analytics datasets" }, { path: "dashboards", name: "Dashboards", icon: "📈", description: "Analytics dashboards and reports" }, ], };