rspace-online/modules/rdata/mod.ts

320 lines
9.7 KiB
TypeScript

/**
* 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<string, unknown>,
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<string, { name: string; icon: string }> {
const map = new Map<string, { name: string; icon: string }>();
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<string, Map<string, Array<{
docId: string;
title: string | null;
itemCount: number | null;
createdAt: number | null;
tags: string[];
itemId: string | null;
}>>>();
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
? `<folk-content-tree space="${space}"></folk-content-tree>`
: `<folk-analytics-view space="${space}"></folk-analytics-view>`;
const scripts = isTree
? `<script type="module" src="/modules/rdata/folk-content-tree.js"></script>`
: `<script type="module" src="/modules/rdata/folk-analytics-view.js"></script>`;
return renderShell({
title: `${space} — Data | rSpace`,
moduleId: "rdata",
spaceSlug: space,
modules: getModuleInfoList(),
theme: "dark",
body,
scripts,
styles: `<link rel="stylesheet" href="/modules/rdata/data.css">`,
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" },
],
};