320 lines
9.7 KiB
TypeScript
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" },
|
|
],
|
|
};
|