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%
11.5 KB · 220 lines tsx
Raw Blame History
1import Link from "next/link";2import { BarChart3 } from "lucide-react";3import { COUNTRIES } from "@fetcha/core";4import { getWorkspace } from "@/lib/session";5import { formatMs, formatNumber, formatPercent } from "@/lib/format";6import { cn } from "@/lib/utils";7import { getCountries, getErrorBreakdown, getRequestSeries, getRequestStats, getSuccessByNetwork, getTopDomains, type Scope } from "@/lib/queries/dashboard";8import { PageHeader } from "@/components/ui/page-header";9import { Stat, StatGrid } from "@/components/ui/stat";10import { Card } from "@/components/ui/card";11import { Button } from "@/components/ui/button";12import { EmptyState } from "@/components/ui/empty-state";13import { Table, TableBody, TableCell, TableEmpty, TableHead, TableHeader, TableRow } from "@/components/ui/table";14import { ChartFrame } from "@/components/dashboard/charts/chart-frame";15import { RequestsBarChart } from "@/components/dashboard/charts/requests-bar-chart";16import { LatencyLineChart } from "@/components/dashboard/charts/latency-line-chart";17import { HorizontalBarChart } from "@/components/dashboard/charts/horizontal-bar-chart";18import { CHART_COLORS, NETWORK_COLORS } from "@/components/dashboard/charts/chart-theme";1920export const dynamic = "force-dynamic";2122const RANGES = [23  { key: "24h", label: "24 hours", hours: 24, bucket: "hour" as const },24  { key: "7d", label: "7 days", hours: 24 * 7, bucket: "day" as const },25  { key: "30d", label: "30 days", hours: 24 * 30, bucket: "day" as const },26];2728export default async function AnalyticsPage({ searchParams }: { searchParams: Promise<Record<string, string | string[] | undefined>> }) {29  const [ws, sp] = await Promise.all([getWorkspace(), searchParams]);30  const scope: Scope = { organizationId: ws.organization.id, projectId: ws.project.id };31  const rangeKey = Array.isArray(sp.range) ? sp.range[0] : sp.range;32  const range = RANGES.find((r) => r.key === rangeKey) ?? RANGES[1]!;33  const now = new Date();34  const from = range.bucket === "hour" ? new Date(now.getTime() - range.hours * 3_600_000) : new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate() - (range.hours / 24 - 1)));3536  const [stats, series, byNetwork, domains, errors, countries] = await Promise.all([37    getRequestStats(scope, from),38    getRequestSeries(scope, from, range.bucket),39    getSuccessByNetwork(scope, from),40    getTopDomains(scope, from, 10),41    getErrorBreakdown(scope, from, 8),42    getCountries(scope, from, 10),43  ]);4445  const hasData = stats.total > 0;46  const latencyHasData = series.some((p) => p.p50 !== null);4748  return (49    <div className="flex flex-col gap-5">50      <PageHeader51        title="Analytics"52        description={`Routing performance for ${ws.project.name}. Times are UTC; latency is measured end to end from Fetcha's edge.`}53        actions={54          <nav className="inline-flex h-9 items-center gap-0.5 rounded-md bg-bg-muted p-1" aria-label="Range">55            {RANGES.map((r) => (56              <Link57                key={r.key}58                href={r.key === "7d" ? "/dashboard/analytics" : `/dashboard/analytics?range=${r.key}`}59                className={cn("inline-flex h-7 items-center rounded-[5px] px-3 text-[12.5px] font-medium transition-colors", r.key === range.key ? "bg-bg text-fg shadow-xs" : "text-fg-muted hover:text-fg")}60                aria-current={r.key === range.key ? "page" : undefined}61              >62                {r.label}63              </Link>64            ))}65          </nav>66        }67      />6869      <StatGrid cols={5}>70        <Stat label="Requests" value={formatNumber(stats.total)} hint={`last ${range.label}`} />71        <Stat label="Success rate" value={stats.successRate === null ? "—" : formatPercent(stats.successRate)} hint={`${formatNumber(stats.failed)} failed`} />72        <Stat label="P50 latency" value={formatMs(stats.p50)} />73        <Stat label="P95 latency" value={formatMs(stats.p95)} />74        <Stat label="Attempts / request" value={stats.avgAttempts === null ? "—" : stats.avgAttempts.toFixed(2)} hint="1.00 = first route worked" />75      </StatGrid>7677      {!hasData ? (78        <EmptyState79          icon={BarChart3}80          title={`No requests in the last ${range.label}`}81          description="Analytics fill in as soon as requests flow. Try a wider range, or send a request from the Playground."82          action={83            <>84              <Button asChild size="sm" variant="primary">85                <Link href="/dashboard/playground">Open Playground</Link>86              </Button>87              {range.key !== "30d" ? (88                <Button asChild size="sm" variant="outline">89                  <Link href="/dashboard/analytics?range=30d">Last 30 days</Link>90                </Button>91              ) : null}92            </>93          }94        />95      ) : null}9697      <div className="grid gap-4 lg:grid-cols-2">98        <ChartFrame99          title="Requests over time"100          description={range.bucket === "hour" ? "Per hour, successful vs failed." : "Per day, successful vs failed."}101          empty={!hasData}102          table={{ columns: [range.bucket === "hour" ? "Hour (UTC)" : "Day", "Successful", "Failed"], rows: series.filter((p) => p.success + p.failed > 0).map((p) => [range.bucket === "hour" ? p.t.slice(0, 16).replace("T", " ") : p.t.slice(0, 10), p.success, p.failed]) }}103        >104          <RequestsBarChart data={series} bucket={range.bucket} height={220} />105        </ChartFrame>106        <ChartFrame107          title="Latency percentiles"108          description="P50 and P95 per bucket. Gaps mean no requests in that bucket."109          empty={!latencyHasData}110          table={{ columns: [range.bucket === "hour" ? "Hour (UTC)" : "Day", "P50", "P95"], rows: series.filter((p) => p.p50 !== null).map((p) => [range.bucket === "hour" ? p.t.slice(0, 16).replace("T", " ") : p.t.slice(0, 10), formatMs(p.p50), formatMs(p.p95)]) }}111        >112          <LatencyLineChart data={series} bucket={range.bucket} height={220} />113        </ChartFrame>114      </div>115116      <div className="grid gap-4 lg:grid-cols-2">117        <ChartFrame118          title="Success rate by network class"119          description="Share of completed requests that succeeded, per resolved network. Only residential is live today."120          empty={!byNetwork.some((n) => n.requests > 0)}121          table={{ columns: ["Network", "Requests", "Success rate"], rows: byNetwork.map((n) => [n.network, n.requests, n.successRate === null ? "—" : formatPercent(n.successRate)]) }}122        >123          <HorizontalBarChart124            data={byNetwork.map((n) => ({ label: n.network === "isp" ? "ISP" : n.network[0]!.toUpperCase() + n.network.slice(1), value: n.successRate ?? 0, color: NETWORK_COLORS[n.network] ?? CHART_COLORS.accent, hint: `${formatNumber(n.requests)} requests` }))}125            max={100}126            format="percent"127            labelWidth={100}128          />129        </ChartFrame>130        <ChartFrame131          title="Failures by error code"132          description="Failed requests grouped by the error code returned to you."133          empty={errors.length === 0}134          emptyMessage={hasData ? "No failed requests in this period." : "No data for this period yet."}135          table={{ columns: ["Code", "Requests", "Share"], rows: errors.map((e) => [e.code, e.requests, formatPercent(e.percent, 1)]) }}136        >137          <HorizontalBarChart data={errors.map((e) => ({ label: e.code, value: e.requests, hint: `${formatPercent(e.percent, 1)} of failures` }))} color={CHART_COLORS.danger} format="number" labelWidth={168} />138        </ChartFrame>139      </div>140141      <div className="grid gap-4 lg:grid-cols-5">142        <Card className="overflow-hidden lg:col-span-3">143          <div className="border-b border-border px-5 py-3">144            <h3 className="text-[14px] font-semibold tracking-tight">Top domains</h3>145            <p className="text-[12.5px] text-fg-muted">Most requested targets and how they behaved.</p>146          </div>147          <Table>148            <TableHeader>149              <TableRow className="hover:bg-transparent">150                <TableHead>Domain</TableHead>151                <TableHead className="text-right">Requests</TableHead>152                <TableHead className="text-right">Success</TableHead>153                <TableHead className="text-right">Avg latency</TableHead>154                <TableHead className="text-right">Blocked</TableHead>155              </TableRow>156            </TableHeader>157            <TableBody>158              {domains.length === 0 ? (159                <TableEmpty colSpan={5}>No domains in this period.</TableEmpty>160              ) : (161                domains.map((d) => (162                  <TableRow key={d.domain}>163                    <TableCell className="max-w-[260px] truncate">164                      <Link href={`/dashboard/requests?range=${range.key}&domain=${encodeURIComponent(d.domain)}`} className="underline-offset-4 hover:underline" title={d.domain}>165                        {d.domain}166                      </Link>167                    </TableCell>168                    <TableCell className="text-right font-mono tabular">{formatNumber(d.requests)}</TableCell>169                    <TableCell className={cn("text-right font-mono tabular", d.successRate !== null && d.successRate < 80 && "text-warning")}>{d.successRate === null ? "—" : formatPercent(d.successRate, 1)}</TableCell>170                    <TableCell className="text-right font-mono tabular">{formatMs(d.avgLatencyMs)}</TableCell>171                    <TableCell className={cn("text-right font-mono tabular", d.blocked > 0 && "text-danger")}>{d.blocked || "—"}</TableCell>172                  </TableRow>173                ))174              )}175            </TableBody>176          </Table>177        </Card>178        <Card className="overflow-hidden lg:col-span-2">179          <div className="border-b border-border px-5 py-3">180            <h3 className="text-[14px] font-semibold tracking-tight">Countries</h3>181            <p className="text-[12.5px] text-fg-muted">Requested exit country.</p>182          </div>183          <Table>184            <TableHeader>185              <TableRow className="hover:bg-transparent">186                <TableHead>Country</TableHead>187                <TableHead className="text-right">Requests</TableHead>188                <TableHead className="text-right">Success</TableHead>189                <TableHead className="text-right">Latency</TableHead>190              </TableRow>191            </TableHeader>192            <TableBody>193              {countries.length === 0 ? (194                <TableEmpty colSpan={4}>No requests in this period.</TableEmpty>195              ) : (196                countries.map((c) => (197                  <TableRow key={c.country ?? "any"}>198                    <TableCell>199                      {c.country ? (200                        <span>201                          <span className="font-mono text-[12.5px] text-fg-subtle">{c.country}</span> {COUNTRIES[c.country] ?? ""}202                        </span>203                      ) : (204                        <span className="text-fg-muted">Any (not specified)</span>205                      )}206                    </TableCell>207                    <TableCell className="text-right font-mono tabular">{formatNumber(c.requests)}</TableCell>208                    <TableCell className="text-right font-mono tabular">{c.successRate === null ? "—" : formatPercent(c.successRate, 1)}</TableCell>209                    <TableCell className="text-right font-mono tabular">{formatMs(c.avgLatencyMs)}</TableCell>210                  </TableRow>211                ))212              )}213            </TableBody>214          </Table>215        </Card>216      </div>217    </div>218  );219}220