Add admin Storage & Billing monitor

Per-family R2 storage usage (the basis for usage billing), computed from
SUM(size_bytes) over memories + attachments — same basis as the quota system,
so admin numbers match what users are charged on.

- GET /api/admin/storage: per-family bytes/objects (sorted by heaviest),
  totals, over/approaching free-limit counts, est. R2 cost, 30-day upload trend.
- /admin/storage page: summary cards, daily upload chart, per-family table with
  % of free quota bar and paid/over-limit flags.
- Sidebar: added Storage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Manohar Gupta 2026-05-30 09:45:24 +05:30
parent 05975b51a1
commit 7332bd1e8b
3 changed files with 285 additions and 0 deletions

View file

@ -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: "🎫" },

View file

@ -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<Data>({ 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 (
<div className="p-6 space-y-6">
<div className="flex justify-between items-start flex-wrap gap-3">
<div>
<h1 className="text-2xl font-bold">Storage &amp; Billing</h1>
<p className="text-gray-400">Per-family R2 storage usage the basis for usage billing</p>
</div>
</div>
{data.error && (
<div className="bg-rose-500/10 border border-rose-500/30 text-rose-300 text-sm p-3 rounded-lg">
<span className="font-semibold">Some queries failed:</span> <span className="font-mono break-words">{data.error}</span>
</div>
)}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Card label="Total Storage" value={fmtBytes(summary.totalBytes)} color="text-rose-400" sub={`${summary.totalObjects.toLocaleString()} objects`} />
<Card label="Est. R2 Cost / mo" value={`$${summary.estMonthlyCostUsd.toFixed(2)}`} color="text-amber-400" sub="@ $0.015/GB-mo" />
<Card label="Avg / Family" value={fmtBytes(summary.avgBytesPerFamily)} color="text-blue-400" sub={`${summary.familyCount} families`} />
<Card label="Over Free Limit" value={summary.overLimitCount} color={summary.overLimitCount > 0 ? "text-rose-400" : "text-emerald-400"} sub={`${summary.approachingCount} approaching`} />
</div>
{/* Daily upload volume */}
<div className="bg-gray-800 p-6 rounded-xl">
<h3 className="text-lg font-semibold mb-4">Uploads last 30 days</h3>
<div className="h-32 flex items-end gap-1">
{byDay.length === 0 ? (
<div className="w-full text-center text-gray-500 text-sm self-center">No uploads in the last 30 days</div>
) : byDay.slice(-30).map((d, i) => (
<div key={i} title={`${d.date}: ${fmtBytes(d.bytes)} · ${d.count} files`} className="flex-1 group">
<div className="w-full bg-rose-500 group-hover:bg-rose-400 rounded-t" style={{ height: `${Math.max((d.bytes / maxDayBytes) * 100, d.bytes > 0 ? 3 : 0)}%` }} />
</div>
))}
</div>
</div>
{/* Per-family table */}
<div className="bg-gray-800 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-700 flex justify-between items-center">
<h3 className="text-lg font-semibold">By family</h3>
<span className="text-sm text-gray-500">{summary.paidCount} paid · {summary.freeCount} free</span>
</div>
{loading ? (
<div className="p-8 text-center text-gray-400">Loading</div>
) : families.length === 0 ? (
<div className="p-8 text-center text-gray-500">No families</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-gray-700"><tr>
<th className="px-4 py-3 text-left text-sm font-medium">Family</th>
<th className="px-4 py-3 text-left text-sm font-medium">Tier</th>
<th className="px-4 py-3 text-right text-sm font-medium">Objects</th>
<th className="px-4 py-3 text-right text-sm font-medium">Storage</th>
<th className="px-4 py-3 text-left text-sm font-medium w-48">% of free (1 GiB)</th>
</tr></thead>
<tbody className="divide-y divide-gray-700">
{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 (
<tr key={f.id} className="hover:bg-gray-750">
<td className="px-4 py-3 font-medium">{f.name}</td>
<td className="px-4 py-3">
<span className={`text-xs px-2 py-0.5 rounded font-medium ${f.isPaid ? "bg-rose-500/20 text-rose-400" : "bg-gray-700 text-gray-400"}`}>{f.tier}</span>
</td>
<td className="px-4 py-3 text-right text-sm text-gray-300">
{f.objectCount.toLocaleString()}
<span className="text-gray-600 text-xs"> ({f.memoryCount}📸 {f.attachmentCount}📎)</span>
</td>
<td className="px-4 py-3 text-right text-sm font-medium">{fmtBytes(f.bytes)}</td>
<td className="px-4 py-3">
{f.isPaid ? (
<span className="text-xs text-rose-400">Unlimited (paid)</span>
) : (
<div className="flex items-center gap-2">
<div className="flex-1 bg-gray-700 rounded-full h-2 overflow-hidden">
<div className={`h-full ${barColor}`} style={{ width: `${Math.min(pct, 100)}%` }} />
</div>
<span className={`text-xs w-12 text-right ${f.overLimit ? "text-rose-400 font-bold" : "text-gray-400"}`}>{pct}%</span>
</div>
)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<p className="text-xs text-gray-600">
Usage = SUM(size_bytes) over memories + attachments, matching the quota enforced in the app.
Objects missing a recorded size aren&apos;t counted in bytes. R2 cost is storage only (egress is free on R2).
</p>
</div>
);
}
function Card({ label, value, color, sub }: { label: string; value: string | number; color: string; sub?: string }) {
return (
<div className="bg-gray-800 p-4 rounded-xl">
<div className={`text-2xl font-bold ${color}`}>{value}</div>
<div className="text-sm text-gray-300 mt-0.5">{label}</div>
{sub && <div className="text-xs text-gray-500 mt-0.5">{sub}</div>}
</div>
);
}

View file

@ -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<Record<string, unknown>> = [];
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<string, unknown>) => {
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<string, unknown>) => ({
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(" | ") } : {}),
});
}