TypeScript 97.5%
SQL 1.4%
Python 0.8%
1import Link from "next/link";2import { Activity, ArrowRight } from "lucide-react";3import { PLAN_LIMITS, isUnlimited, normalizePlan } from "@fetcha/core";4import { getWorkspace } from "@/lib/session";5import { formatBytes, formatCompact, formatDate, formatDateOnly, formatNumber, formatUsd } from "@/lib/format";6import { getUsageMonth, lastMonths, type Scope } from "@/lib/queries/dashboard";7import { cn } from "@/lib/utils";8import { PageHeader, SectionTitle } from "@/components/ui/page-header";9import { Stat, StatGrid } from "@/components/ui/stat";10import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";11import { Badge } from "@/components/ui/badge";12import { Button } from "@/components/ui/button";13import { EmptyState } from "@/components/ui/empty-state";14import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";15import { QuotaBar } from "@/components/dashboard/charts/quota-bar";1617export const dynamic = "force-dynamic";1819const GB = 1024 ** 3;2021function formatQuantity(metric: string, quantity: number, unit: string): string {22 if (unit === "bytes") return formatBytes(quantity);23 if (unit === "seconds") return `${formatNumber(quantity, { maximumFractionDigits: 1 })} s`;24 if (metric === "request") return `${formatNumber(quantity)} req`;25 return formatNumber(quantity, { maximumFractionDigits: 2 });26}2728function metricLabel(metric: string): string {29 switch (metric) {30 case "request":31 return "Request";32 case "bandwidth":33 return "Bandwidth";34 case "residential_bandwidth":35 return "Residential bandwidth";36 case "mobile_bandwidth":37 return "Mobile bandwidth";38 case "browser_seconds":39 return "Browser seconds";40 default:41 return metric.replace(/_/g, " ");42 }43}4445export default async function UsagePage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {46 const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);47 const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };48 const monthParam = Array.isArray(sp.month) ? sp.month[0] : sp.month;49 const usage = await getUsageMonth(scope, monthParam);50 const months = lastMonths(6);51 const isCurrent = usage.month === months[0]!.key;5253 const limits = PLAN_LIMITS[normalizePlan(ws.organization.plan)];54 const unlimited = isUnlimited(limits);55 const projectCap = ws.project.monthlyRequestLimit;56 const requestLimit = projectCap && (unlimited || projectCap < limits.monthly_requests) ? projectCap : unlimited ? null : limits.monthly_requests;57 const includedBytes = limits.included_gb * GB;58 const residentialUsed = usage.residentialBytes || usage.bandwidthBytes;5960 const monthLabel = new Intl.DateTimeFormat("en-US", { month: "long", year: "numeric", timeZone: "UTC" }).format(usage.from);61 const hasData = usage.requests > 0 || usage.ledger.length > 0;6263 return (64 <div className="flex flex-col gap-6">65 <PageHeader66 title="Usage"67 description={`Usage for ${ws.project.name}. Fetcha is a private platform with no billing: amounts are internal cost estimates used only for optional spending limits. The ledger below is the source of truth.`}68 actions={69 <nav className="inline-flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1" aria-label="Month">70 {months.map((m) => (71 <Link72 key={m.key}73 href={m.key === months[0]!.key ? "/dashboard/usage" : `/dashboard/usage?month=${m.key}`}74 className={cn("inline-flex h-7 items-center rounded-[5px] px-2.5 text-[12.5px] font-medium transition-colors", m.key === usage.month ? "bg-bg text-fg shadow-xs" : "text-fg-muted hover:text-fg")}75 aria-current={m.key === usage.month ? "page" : undefined}76 >77 {m.label}78 </Link>79 ))}80 </nav>81 }82 />8384 <section aria-label={monthLabel}>85 <SectionTitle right={<span className="text-[12px] text-fg-subtle">{isCurrent ? `Month to date · resets ${formatDateOnly(usage.to)}` : `${formatDateOnly(usage.from)} – ${formatDateOnly(new Date(usage.to.getTime() - 1))}`}</span>}>{monthLabel}</SectionTitle>86 <StatGrid cols={4}>87 <Stat label="Requests" value={formatNumber(usage.requests)} hint={requestLimit ? `of ${formatCompact(requestLimit)} included` : "unlimited"} />88 <Stat label="Successful" value={formatNumber(usage.successful)} hint={usage.requests ? `${((usage.successful / usage.requests) * 100).toFixed(1)}% of requests` : "—"} />89 <Stat label="Bandwidth" value={formatBytes(usage.bytes)} hint="transferred in + out" />90 <Stat label="Estimated spend" value={formatUsd(usage.spendUsd)} hint="internal estimate, not billed" />91 </StatGrid>92 <StatGrid cols={3} className="mt-3">93 <Stat label="Residential bandwidth" value={formatBytes(residentialUsed)} hint="no allowance cap" />94 <Stat label="Mobile bandwidth" value={formatBytes(usage.mobileBytes)} hint="no allowance cap" />95 <Stat label="Browser seconds" value={formatNumber(usage.browserSeconds)} hint={<Badge variant="success">live</Badge>} />96 </StatGrid>97 </section>9899 <div className="grid gap-4 lg:grid-cols-3">100 <Card className="lg:col-span-2">101 <CardHeader>102 <CardTitle>Quotas</CardTitle>103 <CardDescription>{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}.`}</CardDescription>104 </CardHeader>105 <CardContent className="grid gap-6 sm:grid-cols-2">106 <QuotaBar label="Requests" used={usage.requests} limit={requestLimit} unlimited={requestLimit === null} usedLabel={formatNumber(usage.requests)} limitLabel={requestLimit ? formatNumber(requestLimit) : undefined} hint={projectCap && requestLimit === projectCap ? "project cap" : requestLimit === null ? "unlimited" : "hard stop at the limit"} />107 <QuotaBar label="Residential bandwidth" used={residentialUsed} limit={includedBytes > 0 ? includedBytes : null} unlimited={includedBytes <= 0} usedLabel={formatBytes(residentialUsed)} limitLabel={includedBytes > 0 ? `${limits.included_gb} GB` : undefined} hint={includedBytes > 0 ? "included allowance" : "unmetered"} />108 </CardContent>109 </Card>110 <Card>111 <CardHeader>112 <CardTitle className="flex items-center justify-between">113 {limits.label} plan114 <Badge variant="solid">private platform</Badge>115 </CardTitle>116 <CardDescription>Limits that apply to every project in {ws.organization.name}. No billing.</CardDescription>117 </CardHeader>118 <CardContent>119 <dl className="divide-y divide-border text-[13px]">120 {[121 ["Requests / month", unlimited ? "Unlimited" : formatNumber(limits.monthly_requests)],122 ["Concurrency", formatNumber(limits.concurrency)],123 ["Max timeout", `${limits.max_timeout_ms / 1000} s`],124 ["Max retries", String(limits.max_retries)],125 ["Network classes", limits.networks.length === 4 ? "All" : limits.networks.join(", ")],126 ["Browser renders", `${limits.browser_concurrency} concurrent`],127 ["Crawl jobs", `${formatNumber(limits.crawl_max_pages)} pages · ${limits.crawl_concurrent_jobs} parallel`],128 ["Log retention", `${limits.retention_days} days`],129 ].map(([k, v]) => (130 <div key={k} className="flex items-center justify-between py-1.5">131 <dt className="text-fg-muted">{k}</dt>132 <dd className="font-mono tabular">{v}</dd>133 </div>134 ))}135 </dl>136 <Button asChild variant="outline" size="sm" className="mt-4 w-full">137 <Link href="/dashboard/billing">138 Plan & access <ArrowRight />139 </Link>140 </Button>141 </CardContent>142 </Card>143 </div>144145 <Card>146 <CardHeader className="flex-row items-start justify-between gap-3">147 <div>148 <CardTitle>Spending limits</CardTitle>149 <CardDescription>150 Soft limits email you; hard limits reject new requests with <code className="font-mono text-[12px]">USAGE_LIMIT_REACHED</code>. The stricter of project and organization applies.151 </CardDescription>152 </div>153 <div className="flex shrink-0 gap-2">154 <Button asChild variant="ghost" size="sm">155 <Link href="/dashboard/projects">Project limits</Link>156 </Button>157 <Button asChild variant="ghost" size="sm">158 <Link href="/dashboard/settings">Org limits</Link>159 </Button>160 </div>161 </CardHeader>162 <CardContent>163 <Table>164 <TableHeader>165 <TableRow className="hover:bg-transparent">166 <TableHead>Scope</TableHead>167 <TableHead className="text-right">Soft limit</TableHead>168 <TableHead className="text-right">Hard limit</TableHead>169 <TableHead className="text-right">Spend this month</TableHead>170 </TableRow>171 </TableHeader>172 <TableBody>173 {[174 { scope: `Project · ${ws.project.name}`, soft: ws.project.softLimitUsd, hard: ws.project.hardLimitUsd },175 { scope: `Organization · ${ws.organization.name}`, soft: ws.organization.softLimitUsd, hard: ws.organization.hardLimitUsd },176 ].map((r) => (177 <TableRow key={r.scope}>178 <TableCell>{r.scope}</TableCell>179 <TableCell className="text-right font-mono tabular">{r.soft === null ? <span className="text-fg-subtle">not set</span> : formatUsd(r.soft)}</TableCell>180 <TableCell className="text-right font-mono tabular">{r.hard === null ? <span className="text-fg-subtle">not set</span> : formatUsd(r.hard)}</TableCell>181 <TableCell className="text-right font-mono tabular">{formatUsd(usage.spendUsd)}</TableCell>182 </TableRow>183 ))}184 </TableBody>185 </Table>186 </CardContent>187 </Card>188189 {!hasData ? (190 <EmptyState191 icon={Activity}192 title={`No usage in ${monthLabel}`}193 description="Usage is metered per request the moment it completes. Send a request from the Playground or the API and it will show up here within seconds."194 action={195 <Button asChild size="sm" variant="primary">196 <Link href="/dashboard/playground">Open Playground</Link>197 </Button>198 }199 />200 ) : (201 <>202 <section>203 <SectionTitle>Daily usage</SectionTitle>204 <Card className="overflow-hidden">205 <Table>206 <TableHeader>207 <TableRow className="hover:bg-transparent">208 <TableHead>Day (UTC)</TableHead>209 <TableHead className="text-right">Requests</TableHead>210 <TableHead className="text-right">Bandwidth</TableHead>211 <TableHead className="text-right">Spend</TableHead>212 </TableRow>213 </TableHeader>214 <TableBody>215 {usage.daily.length === 0 ? (216 <TableEmpty colSpan={4}>No usage events recorded for this month yet.</TableEmpty>217 ) : (218 usage.daily.map((d) => (219 <TableRow key={d.day}>220 <TableCell className="font-mono tabular">{d.day}</TableCell>221 <TableCell className="text-right font-mono tabular">{formatNumber(d.requests)}</TableCell>222 <TableCell className="text-right font-mono tabular">{formatBytes(d.bandwidthBytes)}</TableCell>223 <TableCell className="text-right font-mono tabular">{formatUsd(d.spendUsd, true)}</TableCell>224 </TableRow>225 ))226 )}227 </TableBody>228 </Table>229 </Card>230 </section>231232 <section>233 <SectionTitle right={<span className="text-[12px] text-fg-subtle">Last 100 events · append-only, never edited</span>}>Usage ledger</SectionTitle>234 <Card className="overflow-hidden">235 <Table>236 <TableHeader>237 <TableRow className="hover:bg-transparent">238 <TableHead>Time</TableHead>239 <TableHead>Metric</TableHead>240 <TableHead className="text-right">Quantity</TableHead>241 <TableHead className="text-right">Price</TableHead>242 <TableHead>Request</TableHead>243 </TableRow>244 </TableHeader>245 <TableBody>246 {usage.ledger.length === 0 ? (247 <TableEmpty colSpan={5}>No ledger entries for this month.</TableEmpty>248 ) : (249 usage.ledger.map((e) => (250 <TableRow key={e.id}>251 <TableCell className="whitespace-nowrap text-fg-muted" title={e.createdAt.toISOString()}>252 {formatDate(e.createdAt, { timeStyle: "medium" })}253 </TableCell>254 <TableCell>{metricLabel(e.metric)}</TableCell>255 <TableCell className="text-right font-mono tabular">{formatQuantity(e.metric, e.quantity, e.unit)}</TableCell>256 <TableCell className="text-right font-mono tabular">{formatUsd(e.priceUsd, true)}</TableCell>257 <TableCell>258 {e.requestId ? (259 <Link href={`/dashboard/requests/${e.requestId}`} className="font-mono text-[12.5px] text-accent underline-offset-4 hover:underline" prefetch={false}>260 {e.requestId}261 </Link>262 ) : (263 <span className="text-fg-subtle">—</span>264 )}265 </TableCell>266 </TableRow>267 ))268 )}269 </TableBody>270 </Table>271 </Card>272 <p className="mt-2 text-[12px] text-fg-subtle">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.</p>273 </section>274 </>275 )}276 </div>277 );278}279