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%
12.1 KB · 221 lines tsx
Raw Blame History
1import Link from "next/link";2import { ArrowRight, BookOpen, CheckCircle2, Circle, KeyRound, Play } from "lucide-react";3import { PLAN_LIMITS, isUnlimited, normalizePlan } from "@fetcha/core";4import { getWorkspace } from "@/lib/session";5import { formatBytes, formatCompact, formatMs, formatNumber, formatPercent, formatUsd, formatDateOnly } from "@/lib/format";6import { getActiveSessionCount, getMonthStats, getNetworkDistribution, getOnboardingState, getRecentRequests, getRequestSeries, type Scope } from "@/lib/queries/dashboard";7import { PageHeader, SectionTitle } from "@/components/ui/page-header";8import { Stat, StatGrid } from "@/components/ui/stat";9import { Badge } from "@/components/ui/badge";10import { Alert } from "@/components/ui/alert";11import { Button } from "@/components/ui/button";12import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";13import { EmptyState } from "@/components/ui/empty-state";14import { ChartFrame } from "@/components/dashboard/charts/chart-frame";15import { RequestsBarChart } from "@/components/dashboard/charts/requests-bar-chart";16import { NetworkDonut } from "@/components/dashboard/charts/network-donut";17import { QuotaBar } from "@/components/dashboard/charts/quota-bar";18import { RequestsTable } from "@/components/dashboard/requests/requests-table";1920export const dynamic = "force-dynamic";2122export default async function DashboardOverviewPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {23  const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);24  const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };25  const now = new Date();26  const monthStart = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1));27  const thirtyDaysAgo = new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - 29));2829  const [month, series, network, recent, onboarding, activeSessions] = await Promise.all([30    getMonthStats(scope, monthStart),31    getRequestSeries(scope, thirtyDaysAgo, "day"),32    getNetworkDistribution(scope, thirtyDaysAgo),33    getRecentRequests(scope, 8),34    getOnboardingState(scope),35    getActiveSessionCount(scope),36  ]);3738  const limits = PLAN_LIMITS[normalizePlan(ws.organization.plan)];39  const unlimited = isUnlimited(limits);40  const projectLimit = ws.project.monthlyRequestLimit;41  const effectiveLimit = projectLimit && (unlimited || projectLimit < limits.monthly_requests) ? projectLimit : unlimited ? null : limits.monthly_requests;42  const firstName = ws.user.name?.trim().split(/\s+/)[0] || ws.user.email.split("@")[0];43  const verified = sp.verified === "1";44  const seriesHasData = series.some((p) => p.success + p.failed > 0);45  const onboardingDone = onboarding.hasApiKey && onboarding.hasRequest;46  const softLimit = ws.project.softLimitUsd ?? ws.organization.softLimitUsd ?? null;47  const hardLimit = ws.project.hardLimitUsd ?? ws.organization.hardLimitUsd ?? null;48  const limitSource = ws.project.softLimitUsd !== null || ws.project.hardLimitUsd !== null ? "project" : "organization";4950  return (51    <div className="flex flex-col gap-6">52      {verified ? (53        <Alert variant="success" title="Email verified">54          Production API access is unlocked for this organization.55        </Alert>56      ) : null}5758      <PageHeader59        eyebrow={60          <>61            {ws.project.name} · {ws.project.environment}62          </>63        }64        title={65          <span className="inline-flex flex-wrap items-center gap-2.5">66            Welcome back, {firstName}67            <Badge variant="accent">{limits.label} plan</Badge>68          </span>69        }70        description={`Here is what happened in ${ws.project.name} since ${formatDateOnly(monthStart)}. Figures reflect requests billed to this project.`}71        actions={72          <>73            <Button asChild variant="outline" size="sm">74              <Link href="/dashboard/requests">View requests</Link>75            </Button>76            <Button asChild variant="primary" size="sm">77              <Link href="/dashboard/playground">78                <Play /> Open Playground79              </Link>80            </Button>81          </>82        }83      />8485      {!onboardingDone ? (86        <Card>87          <CardHeader className="pb-2">88            <CardTitle>Getting started</CardTitle>89            <CardDescription>Three steps to your first routed request. This card disappears once they are all done.</CardDescription>90          </CardHeader>91          <CardContent>92            <ol className="divide-y divide-border">93              {[94                { done: onboarding.hasApiKey, icon: KeyRound, title: "Create an API key", body: "Keys are shown once at creation. Use a test key while you explore.", href: "/dashboard/api-keys", cta: "Create key" },95                { done: onboarding.hasRequest, icon: Play, title: "Run your first request", body: "Try the Playground or send a POST /v1/fetch with cURL.", href: "/dashboard/playground", cta: "Open Playground" },96                { done: false, icon: BookOpen, title: "Read the quickstart", body: "Five minutes on routing, network classes, sessions and error codes.", href: "/docs/quickstart", cta: "Read docs", external: true },97              ].map((step) => (98                <li key={step.title} className="flex items-start gap-3 py-3 first:pt-0 last:pb-0">99                  {step.done ? <CheckCircle2 className="mt-0.5 size-4 shrink-0 text-success" aria-label="Done" /> : <Circle className="mt-0.5 size-4 shrink-0 text-fg-subtle" aria-label="To do" />}100                  <div className="min-w-0 flex-1">101                    <div className={`text-[13.5px] font-medium ${step.done ? "text-fg-muted line-through decoration-fg-subtle" : ""}`}>{step.title}</div>102                    <p className="text-[12.5px] text-fg-muted">{step.body}</p>103                  </div>104                  {!step.done ? (105                    <Button asChild variant="ghost" size="sm" className="shrink-0">106                      <Link href={step.href} target={step.external ? "_blank" : undefined}>107                        {step.cta} <ArrowRight />108                      </Link>109                    </Button>110                  ) : null}111                </li>112              ))}113            </ol>114          </CardContent>115        </Card>116      ) : null}117118      <section aria-label="This month">119        <StatGrid cols={6}>120          <Stat label="Requests" value={formatNumber(month.total)} hint="this month" />121          <Stat label="Successful" value={formatNumber(month.successful)} hint={month.failed ? `${formatNumber(month.failed)} failed` : "no failures"} />122          <Stat label="Success rate" value={month.successRate === null ? "—" : formatPercent(month.successRate)} hint="of completed requests" />123          <Stat label="Bandwidth" value={formatBytes(month.bytes)} hint="in + out" />124          <Stat label="Estimated spend" value={formatUsd(month.spendUsd)} hint="billed usage" />125          <Stat label="Avg latency" value={formatMs(month.avgLatencyMs)} hint="end to end" />126        </StatGrid>127        <StatGrid cols={5} className="mt-3">128          <Stat label="P50 latency" value={formatMs(month.p50)} />129          <Stat label="P95 latency" value={formatMs(month.p95)} />130          <Stat label="P99 latency" value={formatMs(month.p99)} />131          <Stat label="Attempts / request" value={month.avgAttempts === null ? "—" : month.avgAttempts.toFixed(2)} hint="routes tried on average" />132          <Stat label="Active sessions" value={formatNumber(activeSessions)} hint={<Link href="/dashboard/sessions" className="underline-offset-4 hover:underline">manage</Link>} />133        </StatGrid>134      </section>135136      <Card>137        <CardHeader className="pb-3">138          <CardTitle>Quota and limits</CardTitle>139          <CardDescription>140            {unlimited ? "Requests are unlimited on this private platform" : `Monthly request allowance on the ${limits.label} plan`}141            {projectLimit ? ` (project cap: ${formatNumber(projectLimit)})` : ""}. Spending limits stop new requests with <code className="font-mono text-[12px]">USAGE_LIMIT_REACHED</code>.142          </CardDescription>143        </CardHeader>144        <CardContent className="grid gap-6 lg:grid-cols-2">145          <QuotaBar label="Requests this month" used={month.total} limit={effectiveLimit} unlimited={effectiveLimit === null} usedLabel={formatNumber(month.total)} limitLabel={effectiveLimit ? formatCompact(effectiveLimit) : undefined} hint={effectiveLimit ? `${formatNumber(Math.max(0, effectiveLimit - month.total))} remaining` : "no monthly cap"} />146          <div className="grid grid-cols-2 gap-4 text-[13px]">147            <div>148              <div className="text-[12px] font-medium text-fg-subtle">Soft limit</div>149              <div className="mt-1 font-mono tabular text-[15px]">{softLimit === null ? <span className="text-fg-subtle">not set</span> : formatUsd(softLimit)}</div>150              <div className="text-[11.5px] text-fg-subtle">{softLimit === null ? "alerts only" : `${limitSource} · email alert`}</div>151            </div>152            <div>153              <div className="text-[12px] font-medium text-fg-subtle">Hard limit</div>154              <div className="mt-1 font-mono tabular text-[15px]">{hardLimit === null ? <span className="text-fg-subtle">not set</span> : formatUsd(hardLimit)}</div>155              <div className="text-[11.5px] text-fg-subtle">{hardLimit === null ? "requests never blocked" : `${limitSource} · blocks requests`}</div>156            </div>157            <div className="col-span-2 text-[12px] text-fg-subtle">158              Spend so far: <span className="font-mono tabular text-fg">{formatUsd(month.spendUsd)}</span>159              {hardLimit ? <> of {formatUsd(hardLimit)} ({formatPercent(Math.min(100, (month.spendUsd / hardLimit) * 100), 0)})</> : null} ·{" "}160              <Link href="/dashboard/projects" className="underline-offset-4 hover:underline">161                Edit limits162              </Link>163            </div>164          </div>165        </CardContent>166      </Card>167168      <div className="grid gap-4 lg:grid-cols-3">169        <ChartFrame170          className="lg:col-span-2"171          title="Requests per day"172          description="Last 30 days, UTC. Successful and failed requests stacked."173          empty={!seriesHasData}174          emptyMessage="No requests in the last 30 days."175          table={{ columns: ["Day", "Successful", "Failed"], rows: series.filter((p) => p.success + p.failed > 0).map((p) => [p.t.slice(0, 10), p.success, p.failed]) }}176        >177          <RequestsBarChart data={series} bucket="day" height={240} />178        </ChartFrame>179        <ChartFrame title="Network distribution" description="Resolved network class, last 30 days." empty={network.resolved === 0} emptyMessage="No routed requests yet.">180          <NetworkDonut data={network.shares} />181          {network.unresolved > 0 ? <p className="mt-3 text-[11.5px] text-fg-subtle">{formatNumber(network.unresolved)} request(s) failed before a network was selected and are not counted.</p> : null}182        </ChartFrame>183      </div>184185      <section>186        <SectionTitle187          right={188            <Link href="/dashboard/requests" className="text-[12.5px] text-fg-muted underline-offset-4 hover:text-fg hover:underline">189              All requests <ArrowRight className="ml-0.5 inline size-3.5" />190            </Link>191          }192        >193          Recent requests194        </SectionTitle>195        {recent.length ? (196          <Card className="overflow-hidden">197            <RequestsTable rows={recent} compact />198          </Card>199        ) : (200          <EmptyState201            compact202            icon={Play}203            title="No requests yet"204            description="Send your first request from the Playground or with an API key. Each request shows up here with its route, latency and cost."205            action={206              <>207                <Button asChild size="sm" variant="primary">208                  <Link href="/dashboard/playground">Open Playground</Link>209                </Button>210                <Button asChild size="sm" variant="outline">211                  <Link href="/docs/quickstart">Quickstart</Link>212                </Button>213              </>214            }215          />216        )}217      </section>218    </div>219  );220}221