-
Notifications
You must be signed in to change notification settings - Fork 905
feat: DR-7743 global live activity page #7718
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { NextResponse } from "next/server"; | ||
| import { airports } from "@/data/airports-code"; | ||
|
|
||
| const ACCELERATE_ANALYTICS = | ||
| "https://accelerate-analytics-exporter.prisma-data.net/livemap-data"; | ||
|
|
||
| function transformCoordinates(coordinates: { lat: number; lon: number }) { | ||
| let temp_lon = coordinates.lon; | ||
| if (coordinates.lon > 0 || coordinates.lon < 0) { | ||
| temp_lon = temp_lon + 180; | ||
| } else temp_lon = 180; | ||
| temp_lon = (temp_lon * 100) / 360; | ||
|
|
||
| let temp_lat = coordinates.lat; | ||
| if (coordinates.lat > 0 || coordinates.lat < 0) { | ||
| temp_lat = temp_lat * -1 + 90; | ||
| } else temp_lat = 90; | ||
| temp_lat = (temp_lat * 100) / 180; | ||
|
|
||
| return { | ||
| lon: Number(temp_lon.toFixed(2)), | ||
| lat: Number(temp_lat.toFixed(2)), | ||
| }; | ||
| } | ||
|
|
||
| export async function GET() { | ||
| try { | ||
| const response = await fetch(ACCELERATE_ANALYTICS); | ||
|
|
||
| if (!response.ok) { | ||
| return NextResponse.json( | ||
| { message: "Error fetching analytics" }, | ||
| { status: response.status }, | ||
| ); | ||
| } | ||
|
|
||
| const data: Array<{ pop: string; ratio: number }> = await response.json(); | ||
|
|
||
| const cured_data = data | ||
| .filter((pop) => !!pop.pop) | ||
| .map((pop) => { | ||
| const airport = airports.find((a) => a.pop === pop.pop); | ||
| if (!airport) return null; | ||
| return { | ||
| ...pop, | ||
| cured_coord: transformCoordinates(airport.coordinates), | ||
| }; | ||
| }) | ||
| .filter(Boolean); | ||
|
|
||
| const cured_airport_data = airports.map((airport) => { | ||
| const active = cured_data.find((d) => d?.pop === airport.pop); | ||
| return { | ||
| pop: airport.pop, | ||
| cured_coord: transformCoordinates(airport.coordinates), | ||
| ...(active && { ratio: active.ratio }), | ||
| }; | ||
| }); | ||
|
|
||
| return NextResponse.json(cured_airport_data, { | ||
| headers: { | ||
| "Cache-Control": "s-maxage=86400, stale-while-revalidate=59", | ||
| }, | ||
| }); | ||
|
Comment on lines
+60
to
+64
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache duration of 24 hours conflicts with "live" data polling. The 🔄 Proposed cache header adjustment return NextResponse.json(cured_airport_data, {
headers: {
- "Cache-Control": "s-maxage=86400, stale-while-revalidate=59",
+ "Cache-Control": "s-maxage=60, stale-while-revalidate=30",
},
});🤖 Prompt for AI Agents |
||
| } catch { | ||
| return NextResponse.json( | ||
| { message: "Internal Server Error" }, | ||
| { status: 500 }, | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,105 @@ | ||
| "use client"; | ||
|
|
||
| import { useEffect, useRef, useState } from "react"; | ||
| import { cn } from "@/lib/cn"; | ||
|
|
||
| type DataPoint = { | ||
| pop: string; | ||
| ratio?: number; | ||
| cured_coord: { lon: number; lat: number }; | ||
| }; | ||
|
|
||
| function Marker({ data }: { data: DataPoint }) { | ||
| const [showTooltip, setShowTooltip] = useState(false); | ||
| const isActive = Boolean(data.ratio); | ||
|
|
||
| return ( | ||
| <> | ||
| <span | ||
| className={cn( | ||
| "absolute rounded-full", | ||
| isActive | ||
| ? "bg-[#71E8DF99] border border-[#B7F4EE] shadow-[0_0_28px_0_#71E8DF99] opacity-0 animate-[pulsate_2s_ease-in-out_infinite] z-2" | ||
| : "size-2 bg-[rgba(113,128,150,1)] border border-stroke-neutral opacity-100 z-1", | ||
| )} | ||
| style={{ | ||
| ...(isActive && { | ||
| width: `${20 * (1 + (data.ratio || 0))}px`, | ||
| height: `${20 * (1 + (data.ratio || 0))}px`, | ||
| animationDuration: `${2 / (1 + (data.ratio || 0))}s`, | ||
| animationDelay: `${(data.ratio || 0) * 1000}ms`, | ||
| }), | ||
| ...(data.cured_coord && { | ||
| left: `${data.cured_coord.lon}%`, | ||
| top: `${data.cured_coord.lat}%`, | ||
| }), | ||
| transformOrigin: "center", | ||
| }} | ||
| onMouseEnter={() => setShowTooltip(true)} | ||
| onMouseLeave={() => setShowTooltip(false)} | ||
| /> | ||
| {showTooltip && isActive && data.pop && ( | ||
| <span | ||
| className="absolute z-10 px-2 py-1 text-xs font-semibold bg-background-neutral-strong text-foreground-neutral rounded pointer-events-none whitespace-nowrap" | ||
| style={{ | ||
| left: `${data.cured_coord.lon}%`, | ||
| top: `${data.cured_coord.lat}%`, | ||
| transform: "translate(-50%, -150%)", | ||
| }} | ||
| > | ||
| {data.pop} | ||
| </span> | ||
| )} | ||
| </> | ||
| ); | ||
| } | ||
|
|
||
| export function WorldMap() { | ||
| const dataPoints = useRef<DataPoint[]>([]); | ||
| const [points, setPoints] = useState<DataPoint[]>([]); | ||
|
|
||
| useEffect(() => { | ||
| const fetchData = async () => { | ||
| try { | ||
| const response = await fetch("/api/worldmap"); | ||
| if (!response.ok) return; | ||
| const data: DataPoint[] = await response.json(); | ||
| dataPoints.current = data; | ||
| setPoints(data); | ||
| } catch { | ||
| // silently fail | ||
| } | ||
| }; | ||
|
|
||
| fetchData(); | ||
| const timer = setInterval(fetchData, 60000); | ||
| return () => clearInterval(timer); | ||
| }, []); | ||
|
|
||
| return ( | ||
| <div className="relative w-full max-w-[1056px] mx-auto"> | ||
| {/* Map container */} | ||
| <div className="relative mt-12 px-[5px] pb-[6%] pt-[2%] md:pb-[90px] md:pt-[25px]"> | ||
| {points.map((data, idx) => ( | ||
| <Marker key={idx} data={data} /> | ||
| ))} | ||
| {/* eslint-disable-next-line @next/next/no-img-element */} | ||
| <img | ||
| src="/illustrations/world-map/map.svg" | ||
| width={1036} | ||
| height={609} | ||
| alt="World map" | ||
| className="w-full h-auto translate-x-[5px]" | ||
| /> | ||
|
|
||
| {/* Legend */} | ||
| <div className="absolute grid grid-cols-[repeat(4,auto)] gap-4 text-[10px] text-foreground-neutral-weak border border-stroke-neutral rounded-lg px-2.5 py-1.5 w-max -bottom-11 left-1/2 -translate-x-1/2 md:grid-cols-[auto_1fr] md:row-gap-0.5 md:col-gap-1.5 md:mb-[5%] md:bottom-0 md:left-0 md:translate-x-0 lg:mb-[84px]"> | ||
| <span className="size-2.5 rounded-full bg-[#71E8DF99] border border-[#B7F4EE] shadow-[0_0_10px_0_#71E8DF99] self-center" /> | ||
| <span className="self-center">Active Point of Presence</span> | ||
| <span className="size-2.5 rounded-full bg-[rgba(113,128,150,1)] border border-stroke-neutral self-center" /> | ||
| <span className="self-center">Inactive Point of Presence</span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,81 @@ | ||
| import type { Metadata } from "next"; | ||
| import { Button } from "@prisma/eclipse"; | ||
| import { WorldMap } from "./_components/world-map"; | ||
|
|
||
| export const metadata: Metadata = { | ||
| title: "Global Traffic | Prisma", | ||
| description: | ||
| "Track real-time global traffic as developers build and scale with Prisma's commercial products.", | ||
| }; | ||
|
|
||
| export default function GlobalPage() { | ||
| return ( | ||
| <main className="flex-1 w-full z-1 -mt-24 pt-24 relative legal-hero-gradient"> | ||
| <div className="max-w-[1056px] mx-auto px-2.5 pt-10 w-full md:mb-30"> | ||
| {/* Hero */} | ||
| <div className="text-center"> | ||
| <h1 className="text-5xl md:text-6xl font-bold font-sans-display text-foreground-neutral mt-10 mb-0 mx-auto"> | ||
| Live Activity | ||
| </h1> | ||
| <p className="text-lg text-foreground-neutral-weak max-w-[780px] mx-auto mt-4 mb-6 text-balance"> | ||
| Track real-time global traffic as developers build and scale with | ||
| our commercial products. | ||
| </p> | ||
| <div className="flex items-center justify-center gap-3 flex-col sm:flex-row [&>*]:w-full [&>*]:max-w-[300px] sm:[&>*]:w-auto"> | ||
| <Button variant="ppg" size="xl" href="/accelerate"> | ||
| Try Accelerate | ||
| </Button> | ||
| <Button variant="default-stronger" size="xl" href="/postgres"> | ||
| Try Prisma Postgres | ||
| </Button> | ||
| </div> | ||
| </div> | ||
|
|
||
| {/* Map */} | ||
| <WorldMap /> | ||
|
|
||
| {/* Footnote */} | ||
| <p className="text-center text-sm text-foreground-neutral-weak mt-20 md:mt-0 md:mb-20"> | ||
| We pull our live usage data every 60 seconds to keep this map fresh. | ||
| Curious? Take a look at the Network tab. | ||
| </p> | ||
|
|
||
| {/* Share */} | ||
| <div className="text-center py-10"> | ||
| <h3 className="text-lg font-bold text-foreground-neutral mb-4"> | ||
| Share | ||
| </h3> | ||
| <div className="flex items-center justify-center gap-4"> | ||
| <a | ||
| href="https://www.linkedin.com/sharing/share-offsite/?url=https://www.prisma.io/global" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-foreground-neutral-weak hover:text-foreground-neutral transition-colors" | ||
| aria-label="Share on LinkedIn" | ||
| > | ||
| <i className="fa-brands fa-linkedin text-2xl" /> | ||
| </a> | ||
| <a | ||
| href="https://twitter.com/intent/tweet?url=https://www.prisma.io/global&text=See%20Prisma%20Accelerate%27s%20real-time%20global%20traffic!" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-foreground-neutral-weak hover:text-foreground-neutral transition-colors" | ||
| aria-label="Share on X" | ||
| > | ||
| <i className="fa-brands fa-x-twitter text-2xl" /> | ||
| </a> | ||
| <a | ||
| href="https://bsky.app/intent/compose?text=See%20Prisma%20Accelerate%27s%20real-time%20global%20traffic!%20https://www.prisma.io/global" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| className="text-foreground-neutral-weak hover:text-foreground-neutral transition-colors" | ||
| aria-label="Share on Bluesky" | ||
| > | ||
| <i className="fa-brands fa-bluesky text-2xl" /> | ||
| </a> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| </main> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
External fetch lacks a timeout, risking request hangs.
If the analytics endpoint becomes slow or unresponsive, this request will hang indefinitely. Consider adding an
AbortControllerwith a reasonable timeout (e.g., 5-10 seconds) to ensure the API route responds in a bounded time.⏱️ Proposed timeout implementation
export async function GET() { try { - const response = await fetch(ACCELERATE_ANALYTICS); + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), 10000); + + const response = await fetch(ACCELERATE_ANALYTICS, { + signal: controller.signal, + }); + clearTimeout(timeoutId); if (!response.ok) {🤖 Prompt for AI Agents