TypeScript 97.5%
SQL 1.4%
Python 0.8%
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