import Link from "next/link"; import { Activity, ArrowRight } from "lucide-react"; import { PLAN_LIMITS, isUnlimited, normalizePlan } from "@fetcha/core"; import { getWorkspace } from "@/lib/session"; import { formatBytes, formatCompact, formatDate, formatDateOnly, formatNumber, formatUsd } from "@/lib/format"; import { getUsageMonth, lastMonths, type Scope } from "@/lib/queries/dashboard"; import { cn } from "@/lib/utils"; import { PageHeader, SectionTitle } from "@/components/ui/page-header"; import { Stat, StatGrid } from "@/components/ui/stat"; import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { EmptyState } from "@/components/ui/empty-state"; import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table"; import { QuotaBar } from "@/components/dashboard/charts/quota-bar"; export const dynamic = "force-dynamic"; const GB = 1024 ** 3; function formatQuantity(metric: string, quantity: number, unit: string): string { if (unit === "bytes") return formatBytes(quantity); if (unit === "seconds") return `${formatNumber(quantity, { maximumFractionDigits: 1 })} s`; if (metric === "request") return `${formatNumber(quantity)} req`; return formatNumber(quantity, { maximumFractionDigits: 2 }); } function metricLabel(metric: string): string { switch (metric) { case "request": return "Request"; case "bandwidth": return "Bandwidth"; case "residential_bandwidth": return "Residential bandwidth"; case "mobile_bandwidth": return "Mobile bandwidth"; case "browser_seconds": return "Browser seconds"; default: return metric.replace(/_/g, " "); } } export default async function UsagePage({ searchParams }: { searchParams: Promise> }) { const [ws, sp] = await Promise.all([getWorkspace(), searchParams]); const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id }; const monthParam = Array.isArray(sp.month) ? sp.month[0] : sp.month; const usage = await getUsageMonth(scope, monthParam); const months = lastMonths(6); const isCurrent = usage.month === months[0]!.key; const limits = PLAN_LIMITS[normalizePlan(ws.organization.plan)]; const unlimited = isUnlimited(limits); const projectCap = ws.project.monthlyRequestLimit; const requestLimit = projectCap && (unlimited || projectCap < limits.monthly_requests) ? projectCap : unlimited ? null : limits.monthly_requests; const includedBytes = limits.included_gb * GB; const residentialUsed = usage.residentialBytes || usage.bandwidthBytes; const monthLabel = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(usage.from); const hasData = usage.requests > 0 || usage.ledger.length > 0; return (
{months.map((m) => ( {m.label} ))} } />
{isCurrent ? `Month to date · resets ${formatDateOnly(usage.to)}` : `${formatDateOnly(usage.from)} – ${formatDateOnly(new Date(usage.to.getTime() - 1))}`}}>{monthLabel} live} />
Quotas {unlimited ? `No monthly quota on this private platform for ${monthLabel}; only a project cap you set yourself can limit requests.` : `Included allowances on the ${limits.label} plan for ${monthLabel}.`} 0 ? includedBytes : null} unlimited={includedBytes <= 0} usedLabel={formatBytes(residentialUsed)} limitLabel={includedBytes > 0 ? `${limits.included_gb} GB` : undefined} hint={includedBytes > 0 ? "included allowance" : "unmetered"} /> {limits.label} plan private platform Limits that apply to every project in {ws.organization.name}. No billing.
{[ ["Requests / month", unlimited ? "Unlimited" : formatNumber(limits.monthly_requests)], ["Concurrency", formatNumber(limits.concurrency)], ["Max timeout", `${limits.max_timeout_ms / 1000} s`], ["Max retries", String(limits.max_retries)], ["Network classes", limits.networks.length === 4 ? "All" : limits.networks.join(", ")], ["Browser renders", `${limits.browser_concurrency} concurrent`], ["Crawl jobs", `${formatNumber(limits.crawl_max_pages)} pages · ${limits.crawl_concurrent_jobs} parallel`], ["Log retention", `${limits.retention_days} days`], ].map(([k, v]) => (
{k}
{v}
))}
Spending limits Soft limits email you; hard limits reject new requests with USAGE_LIMIT_REACHED. The stricter of project and organization applies.
Scope Soft limit Hard limit Spend this month {[ { scope: `Project · ${ws.project.name}`, soft: ws.project.softLimitUsd, hard: ws.project.hardLimitUsd }, { scope: `Organization · ${ws.organization.name}`, soft: ws.organization.softLimitUsd, hard: ws.organization.hardLimitUsd }, ].map((r) => ( {r.scope} {r.soft === null ? not set : formatUsd(r.soft)} {r.hard === null ? not set : formatUsd(r.hard)} {formatUsd(usage.spendUsd)} ))}
{!hasData ? ( Open Playground } /> ) : ( <>
Daily usage Day (UTC) Requests Bandwidth Spend {usage.daily.length === 0 ? ( No usage events recorded for this month yet. ) : ( usage.daily.map((d) => ( {d.day} {formatNumber(d.requests)} {formatBytes(d.bandwidthBytes)} {formatUsd(d.spendUsd, true)} )) )}
Last 100 events · append-only, never edited}>Usage ledger Time Metric Quantity Price Request {usage.ledger.length === 0 ? ( No ledger entries for this month. ) : ( usage.ledger.map((e) => ( {formatDate(e.createdAt, { timeStyle: "medium" })} {metricLabel(e.metric)} {formatQuantity(e.metric, e.quantity, e.unit)} {formatUsd(e.priceUsd, true)} {e.requestId ? ( {e.requestId} ) : ( — )} )) )}

The ledger is immutable: corrections are posted as new entries, never by editing past ones. Prices are internal estimates used for spending limits only; nothing is invoiced on this platform.

)}
); }