-
Notifications
You must be signed in to change notification settings - Fork 914
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
Changes from 1 commit
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,114 @@ | ||
| "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 markerRef = useRef<HTMLSpanElement>(null); | ||
| const [showTooltip, setShowTooltip] = useState(false); | ||
| const isActive = Boolean(data.ratio); | ||
|
|
||
| return ( | ||
| <> | ||
| <span | ||
| ref={markerRef} | ||
| className={cn( | ||
| "absolute -translate-x-1/2 -translate-y-1/2 rounded-full z-1", | ||
| isActive | ||
| ? "bg-[#71E8DF99] border border-[#B7F4EE] animate-[pulsate_2s_ease-in-out_infinite] shadow-[0_0_28px_0_#71E8DF99]" | ||
| : "size-[10px] bg-[rgba(113,128,150,1)] border border-[rgba(113,128,150,0.5)]", | ||
| )} | ||
| style={{ | ||
| ...(isActive && { | ||
| width: `${20 * (1 + (data.ratio || 0))}px`, | ||
| height: `${20 * (1 + (data.ratio || 0))}px`, | ||
| }), | ||
| ...(data.cured_coord && { | ||
| left: `${data.cured_coord.lon}%`, | ||
| top: `${data.cured_coord.lat}%`, | ||
| }), | ||
| ...(isActive && { | ||
| animationDuration: `${2 / (1 + (data.ratio || 0))}s`, | ||
| animationDelay: `${(data.ratio || 0) * 1000}ms`, | ||
| }), | ||
| }} | ||
| onMouseEnter={() => setShowTooltip(true)} | ||
| onMouseLeave={() => setShowTooltip(false)} | ||
| /> | ||
| {showTooltip && isActive && data.pop && markerRef.current && ( | ||
| <span | ||
| className="absolute z-10 px-2 py-1 text-xs font-semibold bg-background-neutral-strong text-foreground-neutral rounded -translate-x-1/2 pointer-events-none" | ||
| style={{ | ||
| left: `${data.cured_coord.lon}%`, | ||
| top: `calc(${data.cured_coord.lat}% - 24px)`, | ||
| }} | ||
| > | ||
| {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-[1036px] mx-auto"> | ||
| <div className="relative"> | ||
| {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" | ||
| /> | ||
| </div> | ||
|
|
||
| {/* Legend */} | ||
| <div className="flex items-center gap-6 mt-6"> | ||
| <div className="flex items-center gap-2"> | ||
| <span className="size-3 rounded-full bg-[#71E8DF99] border border-[#B7F4EE] shadow-[0_0_10px_0_#71E8DF99]" /> | ||
| <span className="text-sm text-foreground-neutral-weak"> | ||
| Active Point of Presence | ||
| </span> | ||
| </div> | ||
| <div className="flex items-center gap-2"> | ||
| <span className="size-3 rounded-full bg-[rgba(113,128,150,1)] border border-[rgba(113,128,150,0.5)]" /> | ||
| <span className="text-sm text-foreground-neutral-weak"> | ||
| Inactive Point of Presence | ||
| </span> | ||
| </div> | ||
| </div> | ||
| </div> | ||
| ); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| 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"> | ||
| {/* Hero */} | ||
| <div className="text-center pt-16 pb-8 px-4"> | ||
| <h1 className="text-5xl md:text-6xl font-bold font-sans-display text-foreground-neutral mb-4"> | ||
| Live Activity | ||
| </h1> | ||
| <p className="text-lg text-foreground-neutral-weak max-w-[600px] mx-auto mb-8"> | ||
| Track real-time global traffic as developers build and scale with our | ||
| commercial products. | ||
| </p> | ||
| <div className="flex items-center justify-center gap-4 flex-wrap"> | ||
| <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 */} | ||
| <div className="px-4 py-12"> | ||
| <WorldMap /> | ||
| <p className="text-center text-sm text-foreground-neutral-weaker mt-6 max-w-[600px] mx-auto"> | ||
| We pull our live usage data every 60 seconds to keep this map fresh. | ||
| Curious? Take a look at the Network tab. | ||
| </p> | ||
| </div> | ||
|
|
||
| {/* Share */} | ||
| <div className="text-center pb-20 px-4"> | ||
| <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> | ||
| </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