diff --git a/src/app/admin/AdminSidebar.tsx b/src/app/admin/AdminSidebar.tsx index d93c95e..b5864f8 100644 --- a/src/app/admin/AdminSidebar.tsx +++ b/src/app/admin/AdminSidebar.tsx @@ -20,6 +20,7 @@ const navItems: NavItem[] = [ { name: "Users", href: "/admin/users", icon: "πŸ‘₯" }, { name: "Children", href: "/admin/children", icon: "πŸ‘Ά" }, { name: "Revenue", href: "/admin/revenue", icon: "πŸ’°" }, + { name: "Storage", href: "/admin/storage", icon: "πŸ’Ύ" }, { name: "Analytics", href: "/admin/analytics", icon: "πŸ“ˆ" }, { name: "AI Usage", href: "/admin/ai", icon: "πŸ€–" }, { name: "Support", href: "/admin/support", icon: "🎫" }, diff --git a/src/app/admin/storage/page.tsx b/src/app/admin/storage/page.tsx new file mode 100644 index 0000000..fe85197 --- /dev/null +++ b/src/app/admin/storage/page.tsx @@ -0,0 +1,173 @@ +"use client"; + +import { useEffect, useState } from "react"; + +interface FamilyRow { + id: string; + name: string; + tier: string; + isPaid: boolean; + bytes: number; + memoryCount: number; + attachmentCount: number; + objectCount: number; + fraction: number; + overLimit: boolean; +} +interface Summary { + totalBytes: number; + totalObjects: number; + familyCount: number; + paidCount: number; + freeCount: number; + overLimitCount: number; + approachingCount: number; + estMonthlyCostUsd: number; + freeLimitBytes: number; + avgBytesPerFamily: number; +} +interface Data { summary: Summary; families: FamilyRow[]; byDay: { date: string; bytes: number; count: number }[]; error?: string } + +const EMPTY: Summary = { + totalBytes: 0, totalObjects: 0, familyCount: 0, paidCount: 0, freeCount: 0, + overLimitCount: 0, approachingCount: 0, estMonthlyCostUsd: 0, freeLimitBytes: 1_073_741_824, avgBytesPerFamily: 0, +}; + +function fmtBytes(n: number): string { + if (!n) return "0 B"; + const u = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(Math.floor(Math.log(n) / Math.log(1024)), u.length - 1); + return `${(n / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${u[i]}`; +} + +export default function AdminStorage() { + const [data, setData] = useState({ summary: EMPTY, families: [], byDay: [] }); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/admin/storage", { credentials: "include" }) + .then(r => r.json()) + .then(d => { + setData({ + summary: { ...EMPTY, ...(d?.summary || {}) }, + families: Array.isArray(d?.families) ? d.families : [], + byDay: Array.isArray(d?.byDay) ? d.byDay : [], + error: d?.error, + }); + setLoading(false); + }) + .catch(() => setLoading(false)); + }, []); + + const { summary, families, byDay } = data; + const maxDayBytes = Math.max(...byDay.map(d => d.bytes), 1); + + return ( +
+
+
+

Storage & Billing

+

Per-family R2 storage usage β€” the basis for usage billing

+
+
+ + {data.error && ( +
+ Some queries failed: {data.error} +
+ )} + +
+ + + + 0 ? "text-rose-400" : "text-emerald-400"} sub={`${summary.approachingCount} approaching`} /> +
+ + {/* Daily upload volume */} +
+

Uploads β€” last 30 days

+
+ {byDay.length === 0 ? ( +
No uploads in the last 30 days
+ ) : byDay.slice(-30).map((d, i) => ( +
+
0 ? 3 : 0)}%` }} /> +
+ ))} +
+
+ + {/* Per-family table */} +
+
+

By family

+ {summary.paidCount} paid Β· {summary.freeCount} free +
+ {loading ? ( +
Loading…
+ ) : families.length === 0 ? ( +
No families
+ ) : ( +
+ + + + + + + + + + {families.map(f => { + const pct = Math.min(Math.round(f.fraction * 100), 999); + const barColor = f.overLimit ? "bg-rose-500" : f.fraction >= 0.8 ? "bg-amber-500" : "bg-emerald-500"; + return ( + + + + + + + + ); + })} + +
FamilyTierObjectsStorage% of free (1 GiB)
{f.name} + {f.tier} + + {f.objectCount.toLocaleString()} + ({f.memoryCount}πŸ“Έ {f.attachmentCount}πŸ“Ž) + {fmtBytes(f.bytes)} + {f.isPaid ? ( + Unlimited (paid) + ) : ( +
+
+
+
+ {pct}% +
+ )} +
+
+ )} +
+ +

+ Usage = SUM(size_bytes) over memories + attachments, matching the quota enforced in the app. + Objects missing a recorded size aren't counted in bytes. R2 cost is storage only (egress is free on R2). +

+
+ ); +} + +function Card({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) { + return ( +
+
{value}
+
{label}
+ {sub &&
{sub}
} +
+ ); +} diff --git a/src/app/api/admin/storage/route.ts b/src/app/api/admin/storage/route.ts new file mode 100644 index 0000000..d0f78ea --- /dev/null +++ b/src/app/api/admin/storage/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/admin-auth"; +import { sql } from "@/db"; + +// Storage / billing monitor. Usage is SUM(size_bytes) across memories + +// attachments per family β€” the SAME basis the quota system enforces +// (src/lib/quota.ts), so admin numbers match what users are charged on. +const FREE_LIMIT_BYTES = 1_073_741_824; // 1 GiB (matches FREE_STORAGE_LIMIT_BYTES) +const R2_USD_PER_GB_MONTH = 0.015; // Cloudflare R2 storage price + +export async function GET(request: Request) { + const auth = await requireAdmin(request); + if (!auth.success) return NextResponse.json({ error: auth.error }, { status: auth.status }); + + const errors: string[] = []; + let families: Array> = []; + let totalBytes = 0; + let totalObjects = 0; + let byDay: { date: string; bytes: number; count: number }[] = []; + + // Per-family usage β€” aggregate each table to one row per family BEFORE joining + // (no cartesian blow-up), then sum. Sorted by heaviest consumer first. + try { + const rows = await sql` + WITH mem AS ( + SELECT family_id, COALESCE(SUM(size_bytes::bigint), 0)::bigint AS bytes, COUNT(*)::int AS cnt + FROM memories GROUP BY family_id + ), + att AS ( + SELECT family_id, COALESCE(SUM(size_bytes::bigint), 0)::bigint AS bytes, COUNT(*)::int AS cnt + FROM attachments GROUP BY family_id + ) + SELECT + f.id, f.name, f.tier, + (COALESCE(m.bytes, 0) + COALESCE(a.bytes, 0))::bigint AS total_bytes, + COALESCE(m.cnt, 0)::int AS memory_count, + COALESCE(a.cnt, 0)::int AS attachment_count + FROM families f + LEFT JOIN mem m ON m.family_id = f.id + LEFT JOIN att a ON a.family_id = f.id + ORDER BY total_bytes DESC + `; + families = rows.map((r: Record) => { + const bytes = Number(r.total_bytes) || 0; + const isPaid = !!r.tier && r.tier !== "free"; + return { + id: r.id, + name: r.name, + tier: r.tier || "free", + isPaid, + bytes, + memoryCount: Number(r.memory_count) || 0, + attachmentCount: Number(r.attachment_count) || 0, + objectCount: (Number(r.memory_count) || 0) + (Number(r.attachment_count) || 0), + // fraction of the free limit (paid families have no limit) + fraction: isPaid ? 0 : bytes / FREE_LIMIT_BYTES, + overLimit: !isPaid && bytes > FREE_LIMIT_BYTES, + }; + }); + totalBytes = families.reduce((s, f) => s + (f.bytes as number), 0); + totalObjects = families.reduce((s, f) => s + (f.objectCount as number), 0); + } catch (e) { + errors.push(`families: ${String(e)}`); + } + + // Daily upload volume (bytes + objects), last 30 days + try { + const rows = await sql` + SELECT day::date AS date, SUM(bytes)::bigint AS bytes, SUM(cnt)::int AS count + FROM ( + SELECT DATE(created_at) AS day, COALESCE(SUM(size_bytes::bigint), 0) AS bytes, COUNT(*) AS cnt + FROM memories WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day + UNION ALL + SELECT DATE(created_at) AS day, COALESCE(SUM(size_bytes::bigint), 0) AS bytes, COUNT(*) AS cnt + FROM attachments WHERE created_at > NOW() - INTERVAL '30 days' GROUP BY day + ) sub + WHERE day IS NOT NULL + GROUP BY day ORDER BY day + `; + byDay = rows.map((r: Record) => ({ + date: r.date instanceof Date ? r.date.toISOString().split("T")[0] : String(r.date).split("T")[0], + bytes: Number(r.bytes) || 0, + count: Number(r.count) || 0, + })); + } catch (e) { + errors.push(`by_day: ${String(e)}`); + } + + const paidCount = families.filter(f => f.isPaid).length; + const overLimitCount = families.filter(f => f.overLimit).length; + const approachingCount = families.filter(f => !f.isPaid && (f.fraction as number) >= 0.8 && !(f.overLimit as boolean)).length; + const estMonthlyCostUsd = (totalBytes / 1e9) * R2_USD_PER_GB_MONTH; + + return NextResponse.json({ + summary: { + totalBytes, + totalObjects, + familyCount: families.length, + paidCount, + freeCount: families.length - paidCount, + overLimitCount, + approachingCount, + estMonthlyCostUsd, + freeLimitBytes: FREE_LIMIT_BYTES, + avgBytesPerFamily: families.length ? Math.round(totalBytes / families.length) : 0, + }, + families, + byDay, + ...(errors.length ? { error: errors.join(" | ") } : {}), + }); +}