SPB Git forge

spb/fetcha

Public
11commits 1branches 0releases
1.5 MBsize
maindefault branch
16 days agolast push
TypeScript 97.5% SQL 1.4% Python 0.8%
14.6 KB · 279 lines tsx
Raw Blame History
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 &amp; 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