From 830138cee993e1174d0385fc98eebaa48f317ba7 Mon Sep 17 00:00:00 2001 From: 0xstt Date: Mon, 17 Nov 2025 23:25:10 -0500 Subject: [PATCH 01/19] create stats/playground page --- app/(home)/stats/playground/page.tsx | 553 +++++++ app/api/playground/favorite/route.ts | 109 ++ app/api/playground/route.ts | 205 +++ components/stats/ConfigurableChart.tsx | 1332 +++++++++++++++++ .../migration.sql | 49 + prisma/schema.prisma | 31 + 6 files changed, 2279 insertions(+) create mode 100644 app/(home)/stats/playground/page.tsx create mode 100644 app/api/playground/favorite/route.ts create mode 100644 app/api/playground/route.ts create mode 100644 components/stats/ConfigurableChart.tsx create mode 100644 prisma/migrations/20251117182712_add_playground_tables/migration.sql diff --git a/app/(home)/stats/playground/page.tsx b/app/(home)/stats/playground/page.tsx new file mode 100644 index 00000000000..7a786b20751 --- /dev/null +++ b/app/(home)/stats/playground/page.tsx @@ -0,0 +1,553 @@ +"use client"; +import { useState, useMemo, useEffect, useRef, useCallback, Suspense } from "react"; +import { useSession } from "next-auth/react"; +import { useSearchParams, useRouter } from "next/navigation"; +import ConfigurableChart from "@/components/stats/ConfigurableChart"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Search, X, Save, Globe, Lock, Copy, Check, Pencil, Loader2, Heart } from "lucide-react"; +import { useLoginModalTrigger } from "@/hooks/useLoginModal"; +import { LoginModal } from "@/components/login/LoginModal"; + +interface ChartConfig { + id: string; + title: string; + colSpan: 6 | 12; + dataSeries?: any[]; // DataSeries array from ConfigurableChart +} + +function PlaygroundContent() { + const { data: session, status } = useSession(); + const searchParams = useSearchParams(); + const router = useRouter(); + const playgroundId = searchParams.get("id"); + const { openLoginModal } = useLoginModalTrigger(); + + const [playgroundName, setPlaygroundName] = useState("My Playground"); + const [savedPlaygroundName, setSavedPlaygroundName] = useState("My Playground"); + const [isEditingName, setIsEditingName] = useState(false); + const [isPublic, setIsPublic] = useState(false); + const [savedIsPublic, setSavedIsPublic] = useState(false); + const [savedLink, setSavedLink] = useState(null); + const [linkCopied, setLinkCopied] = useState(false); + const [isSaving, setIsSaving] = useState(false); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [currentPlaygroundId, setCurrentPlaygroundId] = useState(playgroundId); + const [isOwner, setIsOwner] = useState(true); + const [isFavorited, setIsFavorited] = useState(false); + const [favoriteCount, setFavoriteCount] = useState(0); + const [isFavoriting, setIsFavoriting] = useState(false); + const hasLoadedRef = useRef(false); + + const initialCharts: ChartConfig[] = [ + { id: "1", title: "Chart 1", colSpan: 6, dataSeries: [] }, + ]; + const [charts, setCharts] = useState(initialCharts); + const [savedCharts, setSavedCharts] = useState(initialCharts); + const [searchTerm, setSearchTerm] = useState(""); + + const handleColSpanChange = useCallback((chartId: string, newColSpan: 6 | 12) => { + setCharts((prev) => + prev.map((chart) => + chart.id === chartId ? { ...chart, colSpan: newColSpan } : chart + ) + ); + }, []); + + // Memoized callbacks for chart updates + const handleTitleChange = useCallback((chartId: string, newTitle: string) => { + setCharts((prev) => + prev.map((c) => + c.id === chartId ? { ...c, title: newTitle } : c + ) + ); + }, []); + + const handleDataSeriesChange = useCallback((chartId: string, dataSeries: any[]) => { + setCharts((prev) => { + const currentChart = prev.find(c => c.id === chartId); + // Only update if dataSeries actually changed + if (currentChart && JSON.stringify(currentChart.dataSeries) === JSON.stringify(dataSeries)) { + return prev; + } + return prev.map((c) => + c.id === chartId ? { ...c, dataSeries } : c + ); + }); + }, []); + + const addChart = () => { + const newId = String(charts.length + 1); + setCharts([...charts, { id: newId, title: `Chart ${newId}`, colSpan: 12 }]); + }; + + const removeChart = (chartId: string) => { + setCharts((prev) => prev.filter((chart) => chart.id !== chartId)); + }; + + // Load playground data if ID is provided + useEffect(() => { + const loadPlayground = async () => { + if (!playgroundId || hasLoadedRef.current) return; + + // Allow loading for public playgrounds even without auth + if (status === "loading") return; + + hasLoadedRef.current = true; + setIsLoading(true); + setError(null); + + try { + const response = await fetch(`/api/playground?id=${playgroundId}`); + + if (!response.ok) { + if (response.status === 404) { + setError("Playground not found"); + } else { + throw new Error("Failed to load playground"); + } + return; + } + + const playground = await response.json(); + setPlaygroundName(playground.name); + setSavedPlaygroundName(playground.name); + setIsPublic(playground.is_public); + setSavedIsPublic(playground.is_public); + setCurrentPlaygroundId(playground.id); + setIsOwner(playground.is_owner || false); + setIsFavorited(playground.is_favorited || false); + setFavoriteCount(playground.favorite_count || 0); + + // Load charts from saved data + if (playground.charts && Array.isArray(playground.charts)) { + const loadedCharts = playground.charts.map((chart: any, index: number) => ({ + id: chart.id || String(index + 1), + title: chart.title || `Chart ${index + 1}`, + colSpan: chart.colSpan || 12, + dataSeries: chart.dataSeries || [] + })); + setCharts(loadedCharts); + setSavedCharts(loadedCharts.map((chart: ChartConfig) => ({ + ...chart, + dataSeries: chart.dataSeries ? [...chart.dataSeries] : [] + }))); + } + + // Set shareable link + const link = `${window.location.origin}/stats/playground?id=${playground.id}`; + setSavedLink(link); + } catch (err) { + console.error("Error loading playground:", err); + setError(err instanceof Error ? err.message : "Failed to load playground"); + } finally { + setIsLoading(false); + } + }; + + loadPlayground(); + }, [playgroundId, status]); + + // Reset hasLoadedRef when playgroundId changes + useEffect(() => { + hasLoadedRef.current = false; + }, [playgroundId]); + + const handleSave = async () => { + console.log("handleSave", status); + if (status === "unauthenticated") { + const callbackUrl = window.location.pathname + window.location.search; + openLoginModal(callbackUrl); + return; + } + + if (status === "loading") { + return; // Wait for authentication status to be determined + } + + setIsSaving(true); + setError(null); + + try { + const payload = { + name: playgroundName, + isPublic, + charts: charts.map(chart => ({ + id: chart.id, + title: chart.title, + colSpan: chart.colSpan, + dataSeries: chart.dataSeries || [] + })) + }; + + let response; + if (currentPlaygroundId) { + // Update existing playground + response = await fetch("/api/playground", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + id: currentPlaygroundId, + ...payload + }) + }); + } else { + // Create new playground + response = await fetch("/api/playground", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload) + }); + } + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.error || "Failed to save playground"); + } + + const playground = await response.json(); + + // Update saved state + setSavedCharts(charts.map(chart => ({ + ...chart, + dataSeries: chart.dataSeries ? [...chart.dataSeries] : [] + }))); + setSavedPlaygroundName(playgroundName); + setSavedIsPublic(isPublic); + setCurrentPlaygroundId(playground.id); + + // Update URL and link + const link = `${window.location.origin}/stats/playground?id=${playground.id}`; + setSavedLink(link); + router.replace(`/stats/playground?id=${playground.id}`, { scroll: false }); + setLinkCopied(false); + } catch (err) { + console.error("Error saving playground:", err); + setError(err instanceof Error ? err.message : "Failed to save playground"); + } finally { + setIsSaving(false); + } + }; + + // Check if there are unsaved changes + const hasChanges = useMemo(() => { + // Check if name changed + if (playgroundName !== savedPlaygroundName) return true; + + // Check if public/private changed + if (isPublic !== savedIsPublic) return true; + + // Check if charts changed (count, titles, colSpans, or dataSeries) + if (charts.length !== savedCharts.length) return true; + + for (let i = 0; i < charts.length; i++) { + const current = charts[i]; + const saved = savedCharts[i]; + if (!saved || current.id !== saved.id || current.title !== saved.title || current.colSpan !== saved.colSpan) { + return true; + } + + // Check if dataSeries changed + const currentDataSeries = current.dataSeries || []; + const savedDataSeries = saved.dataSeries || []; + + if (currentDataSeries.length !== savedDataSeries.length) return true; + + // Deep compare dataSeries + const currentSorted = [...currentDataSeries].sort((a, b) => a.id.localeCompare(b.id)); + const savedSorted = [...savedDataSeries].sort((a, b) => a.id.localeCompare(b.id)); + + for (let j = 0; j < currentSorted.length; j++) { + const currentSeries = currentSorted[j]; + const savedSeries = savedSorted[j]; + + if (!savedSeries || + currentSeries.id !== savedSeries.id || + currentSeries.name !== savedSeries.name || + currentSeries.color !== savedSeries.color || + currentSeries.yAxis !== savedSeries.yAxis || + currentSeries.visible !== savedSeries.visible || + currentSeries.chartStyle !== savedSeries.chartStyle || + currentSeries.chainId !== savedSeries.chainId || + currentSeries.metricKey !== savedSeries.metricKey || + currentSeries.zIndex !== savedSeries.zIndex) { + return true; + } + } + } + + return false; + }, [charts, savedCharts, playgroundName, savedPlaygroundName, isPublic, savedIsPublic]); + + const copyLink = async () => { + if (savedLink) { + await navigator.clipboard.writeText(savedLink); + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + } + }; + + const handleFavorite = async () => { + if (status === "unauthenticated") { + const callbackUrl = window.location.pathname + window.location.search; + openLoginModal(callbackUrl); + return; + } + + if (!currentPlaygroundId || isFavoriting) return; + + setIsFavoriting(true); + try { + if (isFavorited) { + // Unfavorite + const response = await fetch(`/api/playground/favorite?playgroundId=${currentPlaygroundId}`, { + method: "DELETE" + }); + + if (!response.ok) { + throw new Error("Failed to unfavorite playground"); + } + + const data = await response.json(); + setIsFavorited(false); + setFavoriteCount(data.favorite_count || favoriteCount - 1); + } else { + // Favorite + const response = await fetch("/api/playground/favorite", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ playgroundId: currentPlaygroundId }) + }); + + if (!response.ok) { + throw new Error("Failed to favorite playground"); + } + + const data = await response.json(); + setIsFavorited(true); + setFavoriteCount(data.favorite_count || favoriteCount + 1); + } + } catch (err) { + console.error("Error toggling favorite:", err); + setError(err instanceof Error ? err.message : "Failed to update favorite"); + } finally { + setIsFavoriting(false); + } + }; + + const filteredCharts = charts.filter((chart) => + chart.title.toLowerCase().includes(searchTerm.toLowerCase()) + ); + + if (isLoading) { + return ( +
+
+ +
+
+ ); + } + + return ( +
+ +
+ {/* Header */} +
+
+
+

{ + if (!isOwner) return; + const newName = e.currentTarget.textContent || "My Playground"; + setPlaygroundName(newName); + setIsEditingName(false); + }} + onKeyDown={(e) => { + if (!isOwner) return; + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + e.currentTarget.textContent = playgroundName; + e.currentTarget.blur(); + } + }} + onFocus={() => { + if (isOwner) setIsEditingName(true); + }} + className="text-4xl sm:text-4xl font-semibold tracking-tight text-black dark:text-white outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 rounded px-2 -mx-2 min-w-[200px]" + style={{ + cursor: isOwner && isEditingName ? "text" : isOwner ? "pointer" : "default", + }} + > + {playgroundName} +

+ {isOwner && } +
+
+ {isOwner ? ( + <> + + + + ) : ( + + )} +
+
+

+ Create and customize multiple charts. Resize charts using the button next to the Export button. +

+ {error && ( +
+

{error}

+
+ )} + {savedLink && ( +
+ + {savedLink} + + +
+ )} +
+ + {/* Search and Add Chart */} + {isOwner && ( +
+
+ + setSearchTerm(e.target.value)} + className="pl-10 pr-10 rounded-lg border-[#e1e2ea] dark:border-neutral-700 bg-[#fcfcfd] dark:bg-neutral-800 transition-colors focus-visible:border-black dark:focus-visible:border-white focus-visible:ring-0 text-black dark:text-white placeholder:text-neutral-500 dark:placeholder:text-neutral-400" + /> + {searchTerm && ( + + )} +
+ +
+ )} + + {/* Charts Grid */} +
+ {filteredCharts.map((chart) => ( +
+ handleColSpanChange(chart.id, newColSpan) : undefined} + onTitleChange={isOwner ? (newTitle) => handleTitleChange(chart.id, newTitle) : undefined} + onDataSeriesChange={isOwner ? (dataSeries) => handleDataSeriesChange(chart.id, dataSeries) : undefined} + disableControls={!isOwner} + /> +
+ ))} +
+ + {filteredCharts.length === 0 && ( +
+

+ No charts found matching "{searchTerm}" +

+
+ )} +
+
+ ); +} + +export default function PlaygroundPage() { + return ( + +
+ +
+ + }> + +
+ ); +} + diff --git a/app/api/playground/favorite/route.ts b/app/api/playground/favorite/route.ts new file mode 100644 index 00000000000..c3f2da34231 --- /dev/null +++ b/app/api/playground/favorite/route.ts @@ -0,0 +1,109 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthSession } from '@/lib/auth/authSession'; +import { prisma } from '@/prisma/prisma'; + +// POST /api/playground/favorite - Favorite a playground +export async function POST(req: NextRequest) { + try { + const session = await getAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); + } + + const body = await req.json(); + const { playgroundId } = body; + + if (!playgroundId) { + return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); + } + + // Verify playground exists and is public or owned by user + const playground = await prisma.playground.findFirst({ + where: { + id: playgroundId, + OR: [ + { user_id: session.user.id }, + { is_public: true } + ] + } + }); + + if (!playground) { + return NextResponse.json({ error: 'Playground not found' }, { status: 404 }); + } + + // Check if already favorited + const existingFavorite = await prisma.playgroundFavorite.findUnique({ + where: { + playground_id_user_id: { + playground_id: playgroundId, + user_id: session.user.id + } + } + }); + + if (existingFavorite) { + return NextResponse.json({ error: 'Playground already favorited' }, { status: 400 }); + } + + // Create favorite + await prisma.playgroundFavorite.create({ + data: { + playground_id: playgroundId, + user_id: session.user.id + } + }); + + // Get updated favorite count + const favoriteCount = await prisma.playgroundFavorite.count({ + where: { playground_id: playgroundId } + }); + + return NextResponse.json({ + success: true, + favorite_count: favoriteCount + }); + } catch (error) { + console.error('Error favoriting playground:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// DELETE /api/playground/favorite - Unfavorite a playground +export async function DELETE(req: NextRequest) { + try { + const session = await getAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const playgroundId = searchParams.get('playgroundId'); + + if (!playgroundId) { + return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); + } + + // Delete favorite + await prisma.playgroundFavorite.deleteMany({ + where: { + playground_id: playgroundId, + user_id: session.user.id + } + }); + + // Get updated favorite count + const favoriteCount = await prisma.playgroundFavorite.count({ + where: { playground_id: playgroundId } + }); + + return NextResponse.json({ + success: true, + favorite_count: favoriteCount + }); + } catch (error) { + console.error('Error unfavoriting playground:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/app/api/playground/route.ts b/app/api/playground/route.ts new file mode 100644 index 00000000000..e259aba4fbc --- /dev/null +++ b/app/api/playground/route.ts @@ -0,0 +1,205 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { getAuthSession } from '@/lib/auth/authSession'; +import { prisma } from '@/prisma/prisma'; + +// GET /api/playground - Get user's playgrounds +export async function GET(req: NextRequest) { + try { + const session = await getAuthSession(); + const { searchParams } = new URL(req.url); + const playgroundId = searchParams.get('id'); + const includePublic = searchParams.get('includePublic') === 'true'; + + if (playgroundId) { + // Get specific playground - allow public access even without auth + const playground = await prisma.playground.findFirst({ + where: { + id: playgroundId, + OR: session?.user ? [ + { user_id: session.user.id }, + { is_public: true } + ] : [ + { is_public: true } + ] + }, + include: { + favorites: session?.user ? { + where: { + user_id: session.user.id + } + } : false, + _count: { + select: { favorites: true } + } + } + }); + + if (!playground) { + return NextResponse.json({ error: 'Playground not found' }, { status: 404 }); + } + + const isOwner = session?.user ? playground.user_id === session.user.id : false; + const isFavorited = session?.user && playground.favorites ? playground.favorites.length > 0 : false; + const favoriteCount = playground._count.favorites; + + return NextResponse.json({ + ...playground, + is_owner: isOwner, + is_favorited: isFavorited, + favorite_count: favoriteCount, + favorites: undefined, // Remove favorites array from response + _count: undefined // Remove _count from response + }); + } + + // Get all user's playgrounds - requires authentication + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); + } + + const where: any = { user_id: session.user.id }; + + if (includePublic) { + const playgrounds = await prisma.playground.findMany({ + where: { + OR: [ + { user_id: session.user.id }, + { is_public: true } + ] + }, + orderBy: { updated_at: 'desc' }, + take: 100 + }); + return NextResponse.json(playgrounds); + } + + const playgrounds = await prisma.playground.findMany({ + where, + orderBy: { updated_at: 'desc' }, + take: 100 + }); + + return NextResponse.json(playgrounds); + } catch (error) { + console.error('Error fetching playgrounds:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// POST /api/playground - Create new playground +export async function POST(req: NextRequest) { + try { + const session = await getAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); + } + + const body = await req.json(); + if (!body) { + return NextResponse.json({ error: 'No body provided.' }, { status: 400 }); + } + + if (!body.name) { + return NextResponse.json({ error: 'Name is required.' }, { status: 400 }); + } + + const { name, isPublic, charts } = body; + + const playground = await prisma.playground.create({ + data: { + user_id: session.user.id, + name, + is_public: isPublic || false, + charts: charts || [] + } + }); + + return NextResponse.json(playground); + } catch (error) { + console.error('Error creating playground:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// PUT /api/playground - Update existing playground +export async function PUT(req: NextRequest) { + try { + const session = await getAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); + } + + const body = await req.json(); + if (!body || !body.id) { + return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); + } + + const { id, name, isPublic, charts } = body; + + // Verify ownership + const existing = await prisma.playground.findFirst({ + where: { + id, + user_id: session.user.id + } + }); + + if (!existing) { + return NextResponse.json({ error: 'Playground not found or unauthorized' }, { status: 404 }); + } + + const updateData: any = {}; + if (name !== undefined) updateData.name = name; + if (isPublic !== undefined) updateData.is_public = isPublic; + if (charts !== undefined) updateData.charts = charts; + + const playground = await prisma.playground.update({ + where: { id }, + data: updateData + }); + + return NextResponse.json(playground); + } catch (error) { + console.error('Error updating playground:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + +// DELETE /api/playground - Delete playground +export async function DELETE(req: NextRequest) { + try { + const session = await getAuthSession(); + if (!session?.user) { + return NextResponse.json({ error: 'Unauthorized, please sign in to continue.' }, { status: 401 }); + } + + const { searchParams } = new URL(req.url); + const id = searchParams.get('id'); + + if (!id) { + return NextResponse.json({ error: 'Playground ID is required.' }, { status: 400 }); + } + + // Verify ownership + const existing = await prisma.playground.findFirst({ + where: { + id, + user_id: session.user.id + } + }); + + if (!existing) { + return NextResponse.json({ error: 'Playground not found or unauthorized' }, { status: 404 }); + } + + await prisma.playground.delete({ + where: { id } + }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting playground:', error); + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }); + } +} + diff --git a/components/stats/ConfigurableChart.tsx b/components/stats/ConfigurableChart.tsx new file mode 100644 index 00000000000..a735db629cb --- /dev/null +++ b/components/stats/ConfigurableChart.tsx @@ -0,0 +1,1332 @@ +"use client"; +import { useState, useMemo, useEffect, useRef } from "react"; +import { + Area, + Bar, + CartesianGrid, + Line, + LineChart, + XAxis, + YAxis, + Tooltip, + Brush, + ResponsiveContainer, + ComposedChart, +} from "recharts"; +import { Card, CardContent } from "@/components/ui/card"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Search, + X, + Eye, + EyeOff, + Plus, + Camera, + Loader2, + ChevronLeft, + GripVertical, + Layers, + Pencil, + Maximize2, + Minimize2, +} from "lucide-react"; +import l1ChainsData from "@/constants/l1-chains.json"; +import Image from "next/image"; +import { useTheme } from "next-themes"; +import { AvalancheLogo } from "@/components/navigation/avalanche-logo"; + +// Types +interface TimeSeriesDataPoint { + date: string; + value: number | string; +} + +interface TimeSeriesMetric { + current_value: number | string; + data: TimeSeriesDataPoint[]; +} + +interface ICMDataPoint { + date: string; + messageCount: number; +} + +interface ICMMetric { + current_value: number; + data: ICMDataPoint[]; +} + +interface ChainMetrics { + activeAddresses: TimeSeriesMetric; + activeSenders: TimeSeriesMetric; + cumulativeAddresses: TimeSeriesMetric; + cumulativeDeployers: TimeSeriesMetric; + txCount: TimeSeriesMetric; + cumulativeTxCount: TimeSeriesMetric; + cumulativeContracts: TimeSeriesMetric; + contracts: TimeSeriesMetric; + deployers: TimeSeriesMetric; + gasUsed: TimeSeriesMetric; + avgGps: TimeSeriesMetric; + maxGps: TimeSeriesMetric; + avgTps: TimeSeriesMetric; + maxTps: TimeSeriesMetric; + avgGasPrice: TimeSeriesMetric; + maxGasPrice: TimeSeriesMetric; + feesPaid: TimeSeriesMetric; + icmMessages: ICMMetric; + last_updated: number; +} + +export interface DataSeries { + id: string; + name: string; + color: string; + yAxis: "left" | "right"; + visible: boolean; + chartStyle: "line" | "bar" | "area"; + chainId: string; + chainName: string; + metricKey: string; + zIndex: number; +} + +export interface ChartDataPoint { + date: string; + [key: string]: string | number; +} + +export interface ConfigurableChartProps { + title?: string; + initialDataSeries?: Partial[]; + colSpan?: 6 | 12; + onColSpanChange?: (colSpan: 6 | 12) => void; + onTitleChange?: (title: string) => void; + onDataSeriesChange?: (dataSeries: DataSeries[]) => void; + disableControls?: boolean; +} + +const DEFAULT_COLORS = [ + "#FF6B35", // Orange + "#4ECDC4", // Cyan + "#45B7D1", // Blue + "#FFA07A", // Light Salmon + "#98D8C8", // Mint + "#F7DC6F", // Yellow + "#A855F7", // Purple + "#EC4899", // Pink +]; + +// Available metrics from ChainMetricsPage +const AVAILABLE_METRICS = [ + { id: "activeAddresses", name: "Active Addresses" }, + { id: "activeSenders", name: "Active Senders" }, + { id: "cumulativeAddresses", name: "Cumulative Addresses" }, + { id: "cumulativeDeployers", name: "Cumulative Deployers" }, + { id: "txCount", name: "Transactions" }, + { id: "cumulativeTxCount", name: "Cumulative Transactions" }, + { id: "cumulativeContracts", name: "Cumulative Contracts" }, + { id: "contracts", name: "Contracts" }, + { id: "deployers", name: "Deployers" }, + { id: "gasUsed", name: "Gas Used" }, + { id: "avgGps", name: "Avg GPS" }, + { id: "maxGps", name: "Max GPS" }, + { id: "avgTps", name: "Avg TPS" }, + { id: "maxTps", name: "Max TPS" }, + { id: "avgGasPrice", name: "Avg Gas Price" }, + { id: "maxGasPrice", name: "Max Gas Price" }, + { id: "feesPaid", name: "Fees Paid" }, + { id: "icmMessages", name: "ICM Messages" }, +]; + +export default function ConfigurableChart({ + title = "Chart", + initialDataSeries = [], + colSpan = 12, + onColSpanChange, + onTitleChange, + onDataSeriesChange, + disableControls = false, +}: ConfigurableChartProps) { + const { resolvedTheme } = useTheme(); + const [isMounted, setIsMounted] = useState(false); + const [dataSeries, setDataSeries] = useState(() => { + if (initialDataSeries.length > 0) { + return initialDataSeries.map((ds, idx) => ({ + id: ds.id || `series-${idx}`, + name: ds.name || `Series ${idx + 1}`, + color: ds.color || DEFAULT_COLORS[idx % DEFAULT_COLORS.length], + yAxis: ds.yAxis || "left", + visible: ds.visible !== undefined ? ds.visible : true, + chartStyle: ds.chartStyle || "line", + chainId: ds.chainId || "", + chainName: ds.chainName || "", + metricKey: ds.metricKey || "", + zIndex: ds.zIndex !== undefined ? ds.zIndex : idx + 1, + })); + } + return []; + }); + const [draggedIndex, setDraggedIndex] = useState(null); + const [dragOverIndex, setDragOverIndex] = useState(null); + const [stackSameMetrics, setStackSameMetrics] = useState(false); + + const [chartData, setChartData] = useState>({}); + const [loadingMetrics, setLoadingMetrics] = useState>(new Set()); + const [resolution, setResolution] = useState<"D" | "W" | "M" | "Q" | "Y">("D"); + const [chartTitle, setChartTitle] = useState(title); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [showMetricFilter, setShowMetricFilter] = useState(false); + const [showChainSelector, setShowChainSelector] = useState(false); + const chartContainerRef = useRef(null); + const [selectedMetric, setSelectedMetric] = useState(null); + const [metricSearchTerm, setMetricSearchTerm] = useState(""); + const [chainSearchTerm, setChainSearchTerm] = useState(""); + const [brushRange, setBrushRange] = useState<{ + startIndex: number; + endIndex: number; + } | null>(null); + + useEffect(() => { + setIsMounted(true); + }, []); + + useEffect(() => { + setChartTitle(title); + }, [title]); + + // Notify parent when dataSeries changes + const prevDataSeriesRef = useRef(dataSeries); + useEffect(() => { + // Only call callback if dataSeries actually changed + const hasChanged = JSON.stringify(prevDataSeriesRef.current) !== JSON.stringify(dataSeries); + if (hasChanged && onDataSeriesChange) { + prevDataSeriesRef.current = dataSeries; + onDataSeriesChange(dataSeries); + } + }, [dataSeries, onDataSeriesChange]); + + // Close dropdowns when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + const target = event.target as HTMLElement; + if ( + !target.closest(".metric-filter-dropdown") && + !target.closest(".chain-selector-dropdown") && + !target.closest("button") + ) { + setShowMetricFilter(false); + setShowChainSelector(false); + setSelectedMetric(null); + } + }; + + if (showMetricFilter || showChainSelector) { + document.addEventListener("mousedown", handleClickOutside); + return () => { + document.removeEventListener("mousedown", handleClickOutside); + }; + } + }, [showMetricFilter, showChainSelector]); + + // Fetch metric data for a chain + const fetchMetricData = async (chainId: string, metricKey: string) => { + const seriesId = `${chainId}-${metricKey}`; + if (loadingMetrics.has(seriesId) || chartData[seriesId]) return; + + setLoadingMetrics((prev) => new Set(prev).add(seriesId)); + + try { + const response = await fetch(`/api/chain-stats/${chainId}?timeRange=all`); + if (!response.ok) { + throw new Error(`Failed to fetch data: ${response.status}`); + } + + const chainMetrics: ChainMetrics = await response.json(); + const metric = chainMetrics[metricKey as keyof ChainMetrics]; + + if (!metric) { + throw new Error(`Metric ${metricKey} not found`); + } + + let data: ChartDataPoint[] = []; + if (metricKey === "icmMessages") { + const icmMetric = metric as ICMMetric; + data = icmMetric.data + .map((point: ICMDataPoint) => ({ + date: point.date, + [seriesId]: point.messageCount, + })) + .reverse(); + } else { + const tsMetric = metric as TimeSeriesMetric; + data = tsMetric.data + .map((point: TimeSeriesDataPoint) => ({ + date: point.date, + [seriesId]: + typeof point.value === "string" + ? Number.parseFloat(point.value) + : point.value, + })) + .reverse(); + } + + setChartData((prev) => ({ ...prev, [seriesId]: data })); + } catch (error) { + console.error(`Error fetching ${metricKey} for chain ${chainId}:`, error); + } finally { + setLoadingMetrics((prev) => { + const next = new Set(prev); + next.delete(seriesId); + return next; + }); + } + }; + + // Fetch data for all visible series + useEffect(() => { + dataSeries.forEach((series) => { + if (series.visible && series.chainId && series.metricKey) { + fetchMetricData(series.chainId, series.metricKey); + } + }); + }, [dataSeries]); + + // Merge all chart data + const mergedData = useMemo(() => { + const dateMap = new Map(); + + Object.values(chartData).forEach((data) => { + data.forEach((point) => { + if (!dateMap.has(point.date)) { + dateMap.set(point.date, { date: point.date }); + } + Object.keys(point).forEach((key) => { + if (key !== "date") { + dateMap.get(point.date)![key] = point[key]; + } + }); + }); + }); + + return Array.from(dateMap.values()).sort((a, b) => + a.date.localeCompare(b.date) + ); + }, [chartData]); + + // Aggregate data based on resolution + const aggregatedData = useMemo(() => { + if (resolution === "D" || mergedData.length === 0) return mergedData; + + const grouped = new Map< + string, + { sum: Record; count: number; date: string } + >(); + + mergedData.forEach((point) => { + const date = new Date(point.date); + let key: string; + + if (resolution === "W") { + const weekStart = new Date(date); + weekStart.setDate(date.getDate() - date.getDay()); + key = weekStart.toISOString().split("T")[0]; + } else if (resolution === "M") { + key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart( + 2, + "0" + )}`; + } else if (resolution === "Q") { + const quarter = Math.floor(date.getMonth() / 3) + 1; + key = `${date.getFullYear()}-Q${quarter}`; + } else { + key = String(date.getFullYear()); + } + + if (!grouped.has(key)) { + grouped.set(key, { + sum: {}, + count: 0, + date: key, + }); + } + + const group = grouped.get(key)!; + Object.keys(point).forEach((k) => { + if (k !== "date") { + if (!group.sum[k]) group.sum[k] = 0; + const value = typeof point[k] === "number" ? point[k] : 0; + // For cumulative metrics, take max; for others, sum + if (k.includes("cumulative") || k.includes("Cumulative")) { + group.sum[k] = Math.max(group.sum[k], value); + } else { + group.sum[k] += value; + } + } + }); + group.count += 1; + }); + + return Array.from(grouped.values()) + .map((group) => ({ + date: group.date, + ...group.sum, + })) + .sort((a, b) => a.date.localeCompare(b.date)); + }, [mergedData, resolution]); + + // Set default brush range + useEffect(() => { + if (aggregatedData.length === 0) return; + if (resolution === "D") { + setBrushRange({ + startIndex: Math.max(0, aggregatedData.length - 90), + endIndex: aggregatedData.length - 1, + }); + } else { + setBrushRange({ + startIndex: 0, + endIndex: aggregatedData.length - 1, + }); + } + }, [resolution, aggregatedData.length]); + + const displayData = brushRange + ? aggregatedData.slice(brushRange.startIndex, brushRange.endIndex + 1) + : aggregatedData; + + const visibleSeries = useMemo(() => { + return dataSeries + .filter((s) => s.visible) + .sort((a, b) => a.zIndex - b.zIndex); // Sort by z-index: lower values render first (behind) + }, [dataSeries]); + + // Group series by metricKey for stacking + const seriesByMetric = useMemo(() => { + const grouped: Record = {}; + visibleSeries.forEach((series) => { + if (!grouped[series.metricKey]) { + grouped[series.metricKey] = []; + } + grouped[series.metricKey].push(series); + }); + return grouped; + }, [visibleSeries]); + + + const formatXAxis = (value: string) => { + if (resolution === "Q") { + const parts = value.split("-"); + if (parts.length === 2) return `${parts[1]} '${parts[0].slice(-2)}`; + return value; + } + if (resolution === "Y") return value; + const date = new Date(value); + if (resolution === "M") { + return date.toLocaleDateString("en-US", { + month: "short", + year: "2-digit", + }); + } + return date.toLocaleDateString("en-US", { month: "short", day: "numeric" }); + }; + + const formatTooltipDate = (value: string) => { + if (resolution === "Y") return value; + if (resolution === "Q") { + const parts = value.split("-"); + if (parts.length === 2) return `${parts[1]} ${parts[0]}`; + return value; + } + const date = new Date(value); + if (resolution === "M") { + return date.toLocaleDateString("en-US", { + month: "long", + year: "numeric", + }); + } + return date.toLocaleDateString("en-US", { + day: "numeric", + month: "long", + year: "numeric", + }); + }; + + const formatYAxis = (value: number) => { + if (value >= 1e9) return `${(value / 1e9).toFixed(1)}B`; + if (value >= 1e6) return `${(value / 1e6).toFixed(1)}M`; + if (value >= 1e3) return `${(value / 1e3).toFixed(1)}K`; + return value.toLocaleString(); + }; + + const toggleSeriesVisibility = (seriesId: string) => { + setDataSeries((prev) => + prev.map((s) => + s.id === seriesId ? { ...s, visible: !s.visible } : s + ) + ); + }; + + const removeSeries = (seriesId: string) => { + setDataSeries((prev) => prev.filter((s) => s.id !== seriesId)); + setChartData((prev) => { + const next = { ...prev }; + delete next[seriesId]; + return next; + }); + }; + + const handleMetricClick = (metricId: string) => { + setSelectedMetric(metricId); + setShowMetricFilter(false); + setShowChainSelector(true); + setChainSearchTerm(""); + }; + + const handleChainSelect = (chainId: string, chainName: string) => { + if (!selectedMetric) return; + + const metric = AVAILABLE_METRICS.find((m) => m.id === selectedMetric); + if (!metric) return; + + const chain = l1ChainsData.find((c) => c.chainId === chainId); + const chainColor = chain?.color || DEFAULT_COLORS[0]; + + const seriesId = `${chainId}-${selectedMetric}`; + const existingSeries = dataSeries.find((s) => s.id === seriesId); + + if (existingSeries) { + toggleSeriesVisibility(seriesId); + setShowChainSelector(false); + setSelectedMetric(null); + return; + } + + const seriesName = `${chainName}: ${metric.name}`; + + // Group by metric: same metrics use same Y-axis by default + const existingSeriesForMetric = dataSeries.filter( + (s) => s.metricKey === selectedMetric + ); + const defaultYAxis = + existingSeriesForMetric.length > 0 + ? existingSeriesForMetric[0].yAxis + : dataSeries.length % 2 === 0 + ? "left" + : "right"; + + // Default z-index: higher for newer series (appear on top) + const maxZIndex = dataSeries.length > 0 + ? Math.max(...dataSeries.map(s => s.zIndex)) + : 0; + const defaultZIndex = maxZIndex + 1; + + const newSeries: DataSeries = { + id: seriesId, + name: seriesName, + color: chainColor, + yAxis: defaultYAxis, + visible: true, + chartStyle: "line", + chainId: chainId, + chainName: chainName, + metricKey: selectedMetric, + zIndex: defaultZIndex, + }; + + setDataSeries([...dataSeries, newSeries]); + setShowChainSelector(false); + setSelectedMetric(null); + fetchMetricData(chainId, selectedMetric); + }; + + const updateSeriesProperty = ( + seriesId: string, + property: keyof DataSeries, + value: any + ) => { + setDataSeries((prev) => + prev.map((s) => (s.id === seriesId ? { ...s, [property]: value } : s)) + ); + }; + + const filteredMetrics = AVAILABLE_METRICS.filter((m) => + m.name.toLowerCase().includes(metricSearchTerm.toLowerCase()) + ); + + const filteredChains = l1ChainsData.filter((chain) => + chain.chainName.toLowerCase().includes(chainSearchTerm.toLowerCase()) + ); + + const getThemedLogoUrl = (logoUrl: string): string => { + if (!isMounted || !logoUrl) return logoUrl; + if (resolvedTheme === "dark") { + return logoUrl.replace(/Light/g, "Dark"); + } else { + return logoUrl.replace(/Dark/g, "Light"); + } + }; + + const renderChart = () => { + if (visibleSeries.length === 0) { + return ( +
+ No data series selected. Click "+ Add" to add a metric. +
+ ); + } + + const hasLeftAxis = visibleSeries.some((s) => s.yAxis === "left"); + const hasRightAxis = visibleSeries.some((s) => s.yAxis === "right"); + + return ( + + + + + {hasLeftAxis && ( + + )} + {hasRightAxis && ( + + )} + { + if (!active || !payload?.length) return null; + const date = payload[0]?.payload?.date; + return ( +
+
+ {formatTooltipDate(date)} +
+ {payload.map((entry: any, idx: number) => ( +
+
+ {entry.name}: + + {formatYAxis(entry.value)} + +
+ ))} +
+ ); + }} + /> + {Object.entries(seriesByMetric).map(([metricKey, seriesList]) => { + const isStacked = stackSameMetrics && seriesList.length > 1; + const stackId = isStacked ? `stack-${metricKey}` : undefined; + + return seriesList.map((series) => { + const yAxisId = series.yAxis === "left" ? "left" : "right"; + const dataKey = series.id; + const isLoading = loadingMetrics.has(dataKey); + + if (isLoading) { + return null; + } + + if (series.chartStyle === "bar") { + return ( + + ); + } else if (series.chartStyle === "area") { + return ( + + ); + } else { + // Lines don't support stacking, render normally + return ( + + ); + } + }); + }).flat()} + + + ); + }; + + const handleScreenshot = async () => { + if (!chartContainerRef.current) return; + + try { + // Try to use html2canvas if available (optional dependency) + try { + // @ts-ignore - html2canvas may not be installed + const html2canvasModule = await import("html2canvas"); + if (html2canvasModule?.default) { + const html2canvas = html2canvasModule.default; + const canvas = await html2canvas(chartContainerRef.current, { + backgroundColor: resolvedTheme === "dark" ? "#000000" : "#ffffff", + scale: 2, + logging: false, + }); + + const link = document.createElement("a"); + link.download = `${chartTitle || "chart"}-${new Date().toISOString().split("T")[0]}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + return; + } + } catch (importError) { + // html2canvas not available, fall through to SVG method + } + + // Fallback: Capture SVG from Recharts + const chartArea = chartContainerRef.current.querySelector('[class*="recharts"]') || chartContainerRef.current; + const svgElement = chartArea.querySelector("svg"); + + if (svgElement) { + const svgData = new XMLSerializer().serializeToString(svgElement); + const svgBlob = new Blob([svgData], { type: "image/svg+xml;charset=utf-8" }); + const url = URL.createObjectURL(svgBlob); + + const img = document.createElement("img"); + img.onload = () => { + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d"); + if (!ctx) { + URL.revokeObjectURL(url); + return; + } + + canvas.width = img.width; + canvas.height = img.height; + ctx.fillStyle = resolvedTheme === "dark" ? "#000000" : "#ffffff"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.drawImage(img, 0, 0); + URL.revokeObjectURL(url); + + const link = document.createElement("a"); + link.download = `${chartTitle || "chart"}-${new Date().toISOString().split("T")[0]}.png`; + link.href = canvas.toDataURL("image/png"); + link.click(); + }; + + img.onerror = () => { + URL.revokeObjectURL(url); + console.error("Failed to load SVG for screenshot"); + }; + + img.src = url; + } else { + console.error("No SVG element found in chart"); + } + } catch (error) { + console.error("Failed to capture screenshot:", error); + } + }; + + const toggleColSpan = () => { + if (onColSpanChange) { + onColSpanChange(colSpan === 12 ? 6 : 12); + } + }; + + return ( + + + {/* Header Controls */} +
+ {/* Data Series Legends */} +
+ {dataSeries.map((series) => { + const index = dataSeries.findIndex(s => s.id === series.id); + const isLoading = loadingMetrics.has(series.id); + const chain = l1ChainsData.find((c) => c.chainId === series.chainId); + const isDragging = draggedIndex === index; + const isDragOver = dragOverIndex === index; + + return ( +
{ + if (disableControls) { + e.preventDefault(); + return; + } + // Only allow drag from grip icon or empty space, not from buttons/selects + const target = e.target as HTMLElement; + if (target.tagName === "BUTTON" || target.tagName === "SELECT" || target.tagName === "INPUT" || target.closest("button") || target.closest("select") || target.closest("input")) { + e.preventDefault(); + return; + } + setDraggedIndex(index); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/html", series.id); + }} + onDragOver={(e) => { + e.preventDefault(); + e.stopPropagation(); + e.dataTransfer.dropEffect = "move"; + if (draggedIndex !== null && draggedIndex !== index) { + setDragOverIndex(index); + } + }} + onDragEnter={(e) => { + e.preventDefault(); + if (draggedIndex !== null && draggedIndex !== index) { + setDragOverIndex(index); + } + }} + onDragLeave={(e) => { + // Only clear if we're actually leaving the element (not entering a child) + const rect = e.currentTarget.getBoundingClientRect(); + const x = e.clientX; + const y = e.clientY; + if (x < rect.left || x > rect.right || y < rect.top || y > rect.bottom) { + if (dragOverIndex === index) { + setDragOverIndex(null); + } + } + }} + onDrop={(e) => { + e.preventDefault(); + e.stopPropagation(); + + if (draggedIndex === null || draggedIndex === index) { + setDraggedIndex(null); + setDragOverIndex(null); + return; + } + + const newSeries = [...dataSeries]; + const draggedItem = newSeries[draggedIndex]; + + // Remove the dragged item from its original position + newSeries.splice(draggedIndex, 1); + + // Calculate the new insertion index + // If dragging forward (draggedIndex < index), adjust index by -1 + // If dragging backward (draggedIndex > index), use index as-is + const insertIndex = draggedIndex < index ? index - 1 : index; + + // Insert the dragged item at the new position + newSeries.splice(insertIndex, 0, draggedItem); + + // Update z-index based on new order + const updatedSeries = newSeries.map((s, idx) => ({ + ...s, + zIndex: idx + 1, + })); + + setDataSeries(updatedSeries); + setDraggedIndex(null); + setDragOverIndex(null); + }} + onDragEnd={() => { + setDraggedIndex(null); + setDragOverIndex(null); + }} + className={`group flex items-center gap-2 px-3 py-2 rounded-lg bg-white dark:bg-neutral-900 border transition-all ${ + disableControls + ? "cursor-default border-gray-200 dark:border-neutral-800" + : isDragging + ? "opacity-50 cursor-grabbing border-gray-300 dark:border-neutral-700 shadow-lg scale-95" + : "cursor-grab border-gray-200 dark:border-neutral-800 hover:border-gray-300 dark:hover:border-neutral-700 hover:shadow-sm" + } ${ + isDragOver ? "border-blue-500 dark:border-blue-400 shadow-md ring-2 ring-blue-500/20 dark:ring-blue-400/20" : "" + } ${!series.visible ? "opacity-60" : ""}`} + > + {!disableControls && ( +
{ + // Make the grip icon area draggable + const div = e.currentTarget.parentElement; + if (div) { + div.draggable = true; + } + }} + className="cursor-grab active:cursor-grabbing text-gray-400 dark:text-gray-500 hover:text-gray-600 dark:hover:text-gray-400 transition-colors" + > + +
+ )} + + {series.visible && !isLoading && !disableControls && ( +
+ + +
+ + updateSeriesProperty( + series.id, + "color", + e.target.value + ) + } + className="w-7 h-7 rounded border border-gray-200 dark:border-neutral-700 cursor-pointer hover:border-gray-300 dark:hover:border-neutral-600 transition-colors appearance-none p-0 overflow-hidden" + style={{ + backgroundColor: series.color, + }} + onClick={(e) => e.stopPropagation()} + /> +
+
+ )} + {!disableControls && ( + + )} +
+ ); + })} +
+ + {/* Configuration Controls */} + {!disableControls && ( +
+
+ setStackSameMetrics(e.target.checked)} + className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer" + /> + +
+ +
+
+ + {showMetricFilter && ( +
+
+
+ + setMetricSearchTerm(e.target.value)} + className="pl-8 pr-8 text-sm" + /> + {metricSearchTerm && ( + + )} +
+
+
+ {filteredMetrics.map((metric) => ( + + ))} +
+
+ )} + {showChainSelector && selectedMetric && ( +
+
+
+ +
+
+ + setChainSearchTerm(e.target.value)} + className="pl-8 pr-8 text-sm" + /> + {chainSearchTerm && ( + + )} +
+
+
+ {filteredChains.map((chain) => { + const seriesId = `${chain.chainId}-${selectedMetric}`; + const isAdded = dataSeries.some((s) => s.id === seriesId); + return ( + + ); + })} +
+
+ )} +
+ + {onColSpanChange && ( + + )} +
+
+ )} +
+ + {/* Chart Header */} +
+
+

{ + if (disableControls) return; + const newTitle = e.currentTarget.textContent || title; + setChartTitle(newTitle); + setIsEditingTitle(false); + if (onTitleChange) { + onTitleChange(newTitle); + } + }} + onKeyDown={(e) => { + if (disableControls) return; + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + e.currentTarget.textContent = chartTitle; + e.currentTarget.blur(); + } + }} + onFocus={() => { + if (!disableControls) setIsEditingTitle(true); + }} + className="text-base sm:text-lg font-normal outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 rounded px-1 -mx-1 min-w-[100px]" + style={{ + cursor: disableControls ? "default" : isEditingTitle ? "text" : "pointer", + }} + > + {chartTitle} +

+ {!disableControls && } +
+
+ {(["D", "W", "M", "Q", "Y"] as const).map((p) => { + const primaryColor = visibleSeries[0]?.color || "#888"; + return ( + + ); + })} +
+
+ + {/* Chart Area */} +
+ {/* Watermark */} +
+
+ + Builder Hub +
+
+
+ {renderChart()} + + {/* Brush Slider */} + {aggregatedData.length > 0 && visibleSeries.length > 0 && ( +
+ + + { + if (disableControls) return; + if ( + e.startIndex !== undefined && + e.endIndex !== undefined + ) { + setBrushRange({ + startIndex: e.startIndex, + endIndex: e.endIndex, + }); + } + }} + travellerWidth={8} + tickFormatter={formatXAxis} + > + + + + + + +
+ )} +
+
+
+
+ ); +} diff --git a/prisma/migrations/20251117182712_add_playground_tables/migration.sql b/prisma/migrations/20251117182712_add_playground_tables/migration.sql new file mode 100644 index 00000000000..1235739f117 --- /dev/null +++ b/prisma/migrations/20251117182712_add_playground_tables/migration.sql @@ -0,0 +1,49 @@ +-- CreateTable +CREATE TABLE IF NOT EXISTS "Playground" ( + "id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "is_public" BOOLEAN NOT NULL DEFAULT false, + "charts" JSONB NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMPTZ(3) NOT NULL, + + CONSTRAINT "Playground_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE IF NOT EXISTS "PlaygroundFavorite" ( + "id" TEXT NOT NULL, + "playground_id" TEXT NOT NULL, + "user_id" TEXT NOT NULL, + "created_at" TIMESTAMPTZ(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PlaygroundFavorite_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Playground_user_id_idx" ON "Playground"("user_id"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Playground_is_public_idx" ON "Playground"("is_public"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "Playground_created_at_idx" ON "Playground"("created_at"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "PlaygroundFavorite_playground_id_idx" ON "PlaygroundFavorite"("playground_id"); + +-- CreateIndex +CREATE INDEX IF NOT EXISTS "PlaygroundFavorite_user_id_idx" ON "PlaygroundFavorite"("user_id"); + +-- CreateIndex +CREATE UNIQUE INDEX IF NOT EXISTS "PlaygroundFavorite_playground_id_user_id_key" ON "PlaygroundFavorite"("playground_id", "user_id"); + +-- AddForeignKey +ALTER TABLE "Playground" ADD CONSTRAINT IF NOT EXISTS "Playground_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlaygroundFavorite" ADD CONSTRAINT IF NOT EXISTS "PlaygroundFavorite_playground_id_fkey" FOREIGN KEY ("playground_id") REFERENCES "Playground"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlaygroundFavorite" ADD CONSTRAINT IF NOT EXISTS "PlaygroundFavorite_user_id_fkey" FOREIGN KEY ("user_id") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 04fee70e7a3..f1ba7440d17 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,8 @@ model User { badges UserBadge[] managedTestnetNodes NodeRegistration[] consoleLog ConsoleLog[] + playgrounds Playground[] + playgroundFavorites PlaygroundFavorite[] @relation("PlaygroundFavorites") } model VerificationToken { @@ -220,4 +222,33 @@ model ConsoleLog { @@index([user_id]) @@index([created_at]) +} + +model Playground { + id String @id @default(uuid()) + user_id String + name String + is_public Boolean @default(false) + charts Json // Array of chart configurations + created_at DateTime @default(now()) @db.Timestamptz(3) + updated_at DateTime @updatedAt @db.Timestamptz(3) + user User @relation(fields: [user_id], references: [id], onDelete: Cascade) + favorites PlaygroundFavorite[] + + @@index([user_id]) + @@index([is_public]) + @@index([created_at]) +} + +model PlaygroundFavorite { + id String @id @default(uuid()) + playground_id String + user_id String + created_at DateTime @default(now()) @db.Timestamptz(3) + playground Playground @relation(fields: [playground_id], references: [id], onDelete: Cascade) + user User @relation("PlaygroundFavorites", fields: [user_id], references: [id], onDelete: Cascade) + + @@unique([playground_id, user_id]) + @@index([playground_id]) + @@index([user_id]) } \ No newline at end of file From 5928b37b7466105c7df8987a590026ba2c0393e9 Mon Sep 17 00:00:00 2001 From: 0xstt Date: Tue, 18 Nov 2025 00:00:16 -0500 Subject: [PATCH 02/19] add creator data to the playground --- app/(home)/stats/playground/page.tsx | 220 +++++++++++++++++-------- app/api/playground/route.ts | 18 +- components/stats/ConfigurableChart.tsx | 24 +-- scripts/versions.json | 8 +- 4 files changed, 171 insertions(+), 99 deletions(-) diff --git a/app/(home)/stats/playground/page.tsx b/app/(home)/stats/playground/page.tsx index 7a786b20751..20f72efc88f 100644 --- a/app/(home)/stats/playground/page.tsx +++ b/app/(home)/stats/playground/page.tsx @@ -5,7 +5,7 @@ import { useSearchParams, useRouter } from "next/navigation"; import ConfigurableChart from "@/components/stats/ConfigurableChart"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { Search, X, Save, Globe, Lock, Copy, Check, Pencil, Loader2, Heart } from "lucide-react"; +import { Search, X, Save, Globe, Lock, Copy, Check, Pencil, Loader2, Heart, Share2 } from "lucide-react"; import { useLoginModalTrigger } from "@/hooks/useLoginModal"; import { LoginModal } from "@/components/login/LoginModal"; @@ -38,6 +38,15 @@ function PlaygroundContent() { const [isFavorited, setIsFavorited] = useState(false); const [favoriteCount, setFavoriteCount] = useState(0); const [isFavoriting, setIsFavoriting] = useState(false); + const [creator, setCreator] = useState<{ + id: string; + name: string | null; + user_name: string | null; + image: string | null; + profile_privacy: string | null; + } | null>(null); + const [createdAt, setCreatedAt] = useState(null); + const [updatedAt, setUpdatedAt] = useState(null); const hasLoadedRef = useRef(false); const initialCharts: ChartConfig[] = [ @@ -119,6 +128,9 @@ function PlaygroundContent() { setIsOwner(playground.is_owner || false); setIsFavorited(playground.is_favorited || false); setFavoriteCount(playground.favorite_count || 0); + setCreator(playground.creator || null); + setCreatedAt(playground.created_at || null); + setUpdatedAt(playground.updated_at || null); // Load charts from saved data if (playground.charts && Array.isArray(playground.charts)) { @@ -357,42 +369,92 @@ function PlaygroundContent() { {/* Header */}
-
-

{ - if (!isOwner) return; - const newName = e.currentTarget.textContent || "My Playground"; - setPlaygroundName(newName); - setIsEditingName(false); - }} - onKeyDown={(e) => { - if (!isOwner) return; - if (e.key === "Enter") { - e.preventDefault(); - e.currentTarget.blur(); - } - if (e.key === "Escape") { - e.currentTarget.textContent = playgroundName; - e.currentTarget.blur(); - } - }} - onFocus={() => { - if (isOwner) setIsEditingName(true); - }} - className="text-4xl sm:text-4xl font-semibold tracking-tight text-black dark:text-white outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 rounded px-2 -mx-2 min-w-[200px]" - style={{ - cursor: isOwner && isEditingName ? "text" : isOwner ? "pointer" : "default", - }} - > - {playgroundName} -

- {isOwner && } +
+
+

{ + if (!isOwner) return; + const newName = e.currentTarget.textContent || "My Playground"; + setPlaygroundName(newName); + setIsEditingName(false); + }} + onKeyDown={(e) => { + if (!isOwner) return; + if (e.key === "Enter") { + e.preventDefault(); + e.currentTarget.blur(); + } + if (e.key === "Escape") { + e.currentTarget.textContent = playgroundName; + e.currentTarget.blur(); + } + }} + onFocus={() => { + if (isOwner) setIsEditingName(true); + }} + className="text-4xl sm:text-4xl font-semibold tracking-tight text-black dark:text-white outline-none focus:ring-2 focus:ring-blue-500 dark:focus:ring-blue-400 rounded px-2 -mx-2 min-w-[200px]" + style={{ + cursor: isOwner && isEditingName ? "text" : isOwner ? "pointer" : "default", + }} + > + {playgroundName} +

+ {isOwner && } +
+ {creator && creator.profile_privacy === "public" && ( +
+ {creator.image && ( + {creator.user_name + )} + + {creator.user_name || creator.name || "Unknown User"} + +
+ )} + {(createdAt || updatedAt) && ( +
+ {createdAt && ( + <> + Created {new Date(createdAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} + + )} + {createdAt && updatedAt && ( + • + )} + {updatedAt && ( + Updated {new Date(updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} + )} +
+ )}
{isOwner ? ( <> + {savedLink && ( + + )} + )} - + + )}

- Create and customize multiple charts. Resize charts using the button next to the Export button. + Create and customize multiple charts with real-time chain metrics. Add metrics, configure visualizations, and share your insights.

{error && (

{error}

)} - {savedLink && ( -
- - {savedLink} - - -
- )}
{/* Search and Add Chart */} diff --git a/app/api/playground/route.ts b/app/api/playground/route.ts index e259aba4fbc..38e88d2f36e 100644 --- a/app/api/playground/route.ts +++ b/app/api/playground/route.ts @@ -30,6 +30,15 @@ export async function GET(req: NextRequest) { } : false, _count: { select: { favorites: true } + }, + user: { + select: { + id: true, + name: true, + user_name: true, + image: true, + profile_privacy: true + } } } }); @@ -48,7 +57,14 @@ export async function GET(req: NextRequest) { is_favorited: isFavorited, favorite_count: favoriteCount, favorites: undefined, // Remove favorites array from response - _count: undefined // Remove _count from response + _count: undefined, // Remove _count from response + creator: playground.user ? { + id: playground.user.id, + name: playground.user.name, + user_name: playground.user.user_name, + image: playground.user.image, + profile_privacy: playground.user.profile_privacy + } : undefined }); } diff --git a/components/stats/ConfigurableChart.tsx b/components/stats/ConfigurableChart.tsx index a735db629cb..3c7175fabbb 100644 --- a/components/stats/ConfigurableChart.tsx +++ b/components/stats/ConfigurableChart.tsx @@ -710,29 +710,7 @@ export default function ConfigurableChart({ if (!chartContainerRef.current) return; try { - // Try to use html2canvas if available (optional dependency) - try { - // @ts-ignore - html2canvas may not be installed - const html2canvasModule = await import("html2canvas"); - if (html2canvasModule?.default) { - const html2canvas = html2canvasModule.default; - const canvas = await html2canvas(chartContainerRef.current, { - backgroundColor: resolvedTheme === "dark" ? "#000000" : "#ffffff", - scale: 2, - logging: false, - }); - - const link = document.createElement("a"); - link.download = `${chartTitle || "chart"}-${new Date().toISOString().split("T")[0]}.png`; - link.href = canvas.toDataURL("image/png"); - link.click(); - return; - } - } catch (importError) { - // html2canvas not available, fall through to SVG method - } - - // Fallback: Capture SVG from Recharts + // Capture SVG from Recharts const chartArea = chartContainerRef.current.querySelector('[class*="recharts"]') || chartContainerRef.current; const svgElement = chartArea.querySelector("svg"); diff --git a/scripts/versions.json b/scripts/versions.json index 3a8c31fe351..244fc147c36 100644 --- a/scripts/versions.json +++ b/scripts/versions.json @@ -5,9 +5,9 @@ "avaplatform/icm-relayer": "v1.7.4" }, "testnet": { - "avaplatform/avalanchego": "v1.14.0", - "avaplatform/subnet-evm_avalanchego": "v0.8.0_v1.14.0", - "avaplatform/icm-relayer": "v1.7.4" + "avaplatform/avalanchego": "v1.14.0-fuji", + "avaplatform/subnet-evm_avalanchego": "v0.8.0-fuji_v1.14.0-fuji", + "avaplatform/icm-relayer": "v1.7.2-fuji" }, "ava-labs/icm-contracts": "4d5ab0b6dbc653770cfe9709878c9406eb28b71c" -} +} \ No newline at end of file From 17bf3e60893b6e18a7eac58a420c2ce81599db8e Mon Sep 17 00:00:00 2001 From: 0xstt Date: Tue, 18 Nov 2025 00:21:07 -0500 Subject: [PATCH 03/19] share charts on x, enable some chart controls for non-creators --- app/(home)/stats/playground/page.tsx | 122 +++++++++++++++---------- app/layout.config.tsx | 12 ++- components/stats/ConfigurableChart.tsx | 25 ++--- 3 files changed, 97 insertions(+), 62 deletions(-) diff --git a/app/(home)/stats/playground/page.tsx b/app/(home)/stats/playground/page.tsx index 20f72efc88f..1fcd61d6c41 100644 --- a/app/(home)/stats/playground/page.tsx +++ b/app/(home)/stats/playground/page.tsx @@ -300,6 +300,14 @@ function PlaygroundContent() { } }; + const shareOnX = () => { + if (savedLink) { + const text = `Check out my avalanche ecosystem stats playground`; + const url = `https://twitter.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent(savedLink)}`; + window.open(url, '_blank', 'noopener,noreferrer'); + } + }; + const handleFavorite = async () => { if (status === "unauthenticated") { const callbackUrl = window.location.pathname + window.location.search; @@ -437,23 +445,33 @@ function PlaygroundContent() { {isOwner ? ( <> {savedLink && ( - + <> + + + )} + <> + + + )}
-

- Create and customize multiple charts with real-time chain metrics. Add metrics, configure visualizations, and share your insights. -

+ {!playgroundId && ( +

+ Create and customize multiple charts with real-time chain metrics. Add metrics, configure visualizations, and share your insights. +

+ )} {error && (

{error}

@@ -553,8 +583,7 @@ function PlaygroundContent() {
{/* Search and Add Chart */} - {isOwner && ( -
+
)}
- + {isOwner && ( + + )}
- )} {/* Charts Grid */}
diff --git a/app/layout.config.tsx b/app/layout.config.tsx index a18569bfd7a..67efc4ad4df 100644 --- a/app/layout.config.tsx +++ b/app/layout.config.tsx @@ -26,11 +26,8 @@ import { Ticket, Earth, ArrowLeftRight, - Shield, Triangle, - GraduationCap, - BookOpen, - Users, + DraftingCompass, } from 'lucide-react'; import Image from 'next/image'; import { UserButtonWrapper } from '@/components/login/user-button/UserButtonWrapper'; @@ -135,6 +132,13 @@ export const stats: LinkItemType = { description: "View the latest metrics for the Avalanche Primary Network validators.", }, + { + icon: , + text: "Playground", + url: "/stats/playground", + description: + "Create and customize multiple charts with real-time chain metrics.", + }, ], }; diff --git a/components/stats/ConfigurableChart.tsx b/components/stats/ConfigurableChart.tsx index 3c7175fabbb..ab6cd7aec15 100644 --- a/components/stats/ConfigurableChart.tsx +++ b/components/stats/ConfigurableChart.tsx @@ -926,10 +926,14 @@ export default function ConfigurableChart({ )} - {series.visible ? ( - - ) : ( - + {!disableControls && ( + <> + {series.visible ? ( + + ) : ( + + )} + )} {series.visible && !isLoading && !disableControls && ( @@ -1217,18 +1221,15 @@ export default function ConfigurableChart({
)}
-
+
{isOwner ? ( <> {savedLink && ( <> )} @@ -523,35 +523,35 @@ function PlaygroundContent() { <> )} From 98d081d62873cc27eee1ab1c4c53662c61ab089b Mon Sep 17 00:00:00 2001 From: 0xstt Date: Tue, 18 Nov 2025 00:49:18 -0500 Subject: [PATCH 06/19] keep show stacked metrics data option in the db --- app/(home)/stats/playground/page.tsx | 14 +++++++++++++- components/stats/ConfigurableChart.tsx | 14 ++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/app/(home)/stats/playground/page.tsx b/app/(home)/stats/playground/page.tsx index 10a5b3f1da8..1d3dc6546fb 100644 --- a/app/(home)/stats/playground/page.tsx +++ b/app/(home)/stats/playground/page.tsx @@ -14,6 +14,7 @@ interface ChartConfig { title: string; colSpan: 6 | 12; dataSeries?: any[]; // DataSeries array from ConfigurableChart + stackSameMetrics?: boolean; } function PlaygroundContent() { @@ -86,6 +87,14 @@ function PlaygroundContent() { }); }, []); + const handleStackSameMetricsChange = useCallback((chartId: string, stackSameMetrics: boolean) => { + setCharts((prev) => + prev.map((c) => + c.id === chartId ? { ...c, stackSameMetrics } : c + ) + ); + }, []); + const addChart = () => { const newId = String(charts.length + 1); setCharts([...charts, { id: newId, title: `Chart ${newId}`, colSpan: 12 }]); @@ -138,7 +147,8 @@ function PlaygroundContent() { id: chart.id || String(index + 1), title: chart.title || `Chart ${index + 1}`, colSpan: chart.colSpan || 12, - dataSeries: chart.dataSeries || [] + dataSeries: chart.dataSeries || [], + stackSameMetrics: chart.stackSameMetrics || false })); setCharts(loadedCharts); setSavedCharts(loadedCharts.map((chart: ChartConfig) => ({ @@ -626,9 +636,11 @@ function PlaygroundContent() { title={chart.title} colSpan={chart.colSpan} initialDataSeries={chart.dataSeries || []} + initialStackSameMetrics={chart.stackSameMetrics || false} onColSpanChange={isOwner ? (newColSpan) => handleColSpanChange(chart.id, newColSpan) : undefined} onTitleChange={isOwner ? (newTitle) => handleTitleChange(chart.id, newTitle) : undefined} onDataSeriesChange={isOwner ? (dataSeries) => handleDataSeriesChange(chart.id, dataSeries) : undefined} + onStackSameMetricsChange={isOwner ? (stackSameMetrics) => handleStackSameMetricsChange(chart.id, stackSameMetrics) : undefined} disableControls={!isOwner} />
diff --git a/components/stats/ConfigurableChart.tsx b/components/stats/ConfigurableChart.tsx index 855f699860b..e165d7d4a92 100644 --- a/components/stats/ConfigurableChart.tsx +++ b/components/stats/ConfigurableChart.tsx @@ -100,10 +100,12 @@ export interface ChartDataPoint { export interface ConfigurableChartProps { title?: string; initialDataSeries?: Partial[]; + initialStackSameMetrics?: boolean; colSpan?: 6 | 12; onColSpanChange?: (colSpan: 6 | 12) => void; onTitleChange?: (title: string) => void; onDataSeriesChange?: (dataSeries: DataSeries[]) => void; + onStackSameMetricsChange?: (stackSameMetrics: boolean) => void; disableControls?: boolean; } @@ -143,10 +145,12 @@ const AVAILABLE_METRICS = [ export default function ConfigurableChart({ title = "Chart", initialDataSeries = [], + initialStackSameMetrics = false, colSpan = 12, onColSpanChange, onTitleChange, onDataSeriesChange, + onStackSameMetricsChange, disableControls = false, }: ConfigurableChartProps) { const { resolvedTheme } = useTheme(); @@ -170,7 +174,7 @@ export default function ConfigurableChart({ }); const [draggedIndex, setDraggedIndex] = useState(null); const [dragOverIndex, setDragOverIndex] = useState(null); - const [stackSameMetrics, setStackSameMetrics] = useState(false); + const [stackSameMetrics, setStackSameMetrics] = useState(initialStackSameMetrics); const [chartData, setChartData] = useState>({}); const [loadingMetrics, setLoadingMetrics] = useState>(new Set()); @@ -1014,7 +1018,13 @@ export default function ConfigurableChart({ type="checkbox" id="stack-same-metrics" checked={stackSameMetrics} - onChange={(e) => setStackSameMetrics(e.target.checked)} + onChange={(e) => { + const newValue = e.target.checked; + setStackSameMetrics(newValue); + if (onStackSameMetricsChange) { + onStackSameMetricsChange(newValue); + } + }} className="w-4 h-4 rounded border-gray-300 dark:border-gray-600 text-blue-600 focus:ring-blue-500 dark:focus:ring-blue-400 cursor-pointer" />