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:
parent
05975b51a1
commit
7332bd1e8b
3 changed files with 285 additions and 0 deletions
|
|
@ -20,6 +20,7 @@ const navItems: NavItem[] = [
|
||||||
{ name: "Users", href: "/admin/users", icon: "👥" },
|
{ name: "Users", href: "/admin/users", icon: "👥" },
|
||||||
{ name: "Children", href: "/admin/children", icon: "👶" },
|
{ name: "Children", href: "/admin/children", icon: "👶" },
|
||||||
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
|
{ name: "Revenue", href: "/admin/revenue", icon: "💰" },
|
||||||
|
{ name: "Storage", href: "/admin/storage", icon: "💾" },
|
||||||
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
|
{ name: "Analytics", href: "/admin/analytics", icon: "📈" },
|
||||||
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
|
{ name: "AI Usage", href: "/admin/ai", icon: "🤖" },
|
||||||
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
{ name: "Support", href: "/admin/support", icon: "🎫" },
|
||||||
|
|
|
||||||
173
src/app/admin/storage/page.tsx
Normal file
173
src/app/admin/storage/page.tsx
Normal 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 & 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'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>
|
||||||
|
);
|
||||||
|
}
|
||||||
111
src/app/api/admin/storage/route.ts
Normal file
111
src/app/api/admin/storage/route.ts
Normal 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(" | ") } : {}),
|
||||||
|
});
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue