diff --git a/app/(home)/stats/playground/page.tsx b/app/(home)/stats/playground/page.tsx new file mode 100644 index 00000000000..e6e46012a71 --- /dev/null +++ b/app/(home)/stats/playground/page.tsx @@ -0,0 +1,1042 @@ +"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 { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Search, X, Save, Globe, Lock, Copy, Check, Pencil, Loader2, Heart, Share2, Eye, CalendarIcon, RefreshCw } from "lucide-react"; +import { format } from "date-fns"; +import { cn } from "@/lib/utils"; +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 + stackSameMetrics?: boolean; + startTime?: string | null; // Local start time filter (ISO string) + endTime?: string | null; // Local end time filter (ISO string) +} + +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(""); + const [savedPlaygroundName, setSavedPlaygroundName] = useState(""); + 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 [viewCount, setViewCount] = useState(0); + 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); + // Global time filters - ISO strings are the source of truth + const [globalStartTime, setGlobalStartTime] = useState(null); + const [globalEndTime, setGlobalEndTime] = useState(null); + const [savedGlobalStartTime, setSavedGlobalStartTime] = useState(null); + const [savedGlobalEndTime, setSavedGlobalEndTime] = useState(null); + + // Temporary state for editing (only used when popover is open) - includes date and time + const [tempGlobalStartTime, setTempGlobalStartTime] = useState(undefined); + const [tempGlobalEndTime, setTempGlobalEndTime] = useState(undefined); + + const [reloadTrigger, setReloadTrigger] = useState(0); + const [showGlobalTimeFilterPopover, setShowGlobalTimeFilterPopover] = useState(false); + + // Derive date range and time strings from ISO strings + const globalDateRange = useMemo<{ from: Date | undefined; to: Date | undefined }>(() => { + if (globalStartTime && globalEndTime) { + return { + from: new Date(globalStartTime), + to: new Date(globalEndTime), + }; + } + return { from: undefined, to: undefined }; + }, [globalStartTime, globalEndTime]); + + const globalStartTimeStr = useMemo(() => { + return globalStartTime ? new Date(globalStartTime).toTimeString().slice(0, 5) : "00:00"; + }, [globalStartTime]); + + const globalEndTimeStr = useMemo(() => { + return globalEndTime ? new Date(globalEndTime).toTimeString().slice(0, 5) : "23:59"; + }, [globalEndTime]); + 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 handleStackSameMetricsChange = useCallback((chartId: string, stackSameMetrics: boolean) => { + setCharts((prev) => + prev.map((c) => + c.id === chartId ? { ...c, stackSameMetrics } : c + ) + ); + }, []); + + const handleChartTimeFilterChange = useCallback((chartId: string, startTime: string | null, endTime: string | null) => { + setCharts((prev) => + prev.map((c) => + c.id === chartId ? { ...c, startTime, endTime } : c + ) + ); + }, []); + + const addChart = () => { + const newId = String(charts.length + 1); + setCharts([...charts, { id: newId, title: `Chart ${newId}`, colSpan: 12, dataSeries: [], stackSameMetrics: false }]); + }; + + const removeChart = (chartId: string) => { + setCharts((prev) => { + // If this is the last chart, create a new blank one instead of removing it + if (prev.length === 1) { + const newId = String(prev.length + 1); + return [{ id: newId, title: `Blank Chart`, colSpan: 6, dataSeries: [], stackSameMetrics: false }]; + } + // Otherwise, remove the chart normally + return 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); + setViewCount(playground.view_count || 0); + setCreator(playground.creator || null); + setCreatedAt(playground.created_at || null); + setUpdatedAt(playground.updated_at || null); + + // Load global time filters + const startTime = playground.globalStartTime || null; + const endTime = playground.globalEndTime || null; + setGlobalStartTime(startTime); + setGlobalEndTime(endTime); + setSavedGlobalStartTime(startTime); + setSavedGlobalEndTime(endTime); + + // Initialize temp state from loaded times + if (startTime && endTime) { + setTempGlobalStartTime(new Date(startTime)); + setTempGlobalEndTime(new Date(endTime)); + } else { + setTempGlobalStartTime(undefined); + setTempGlobalEndTime(undefined); + } + + // 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 || [], + stackSameMetrics: chart.stackSameMetrics || false, + startTime: chart.startTime || null, + endTime: chart.endTime || null + })); + 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]); + + // Initialize temp state when popover opens + useEffect(() => { + if (showGlobalTimeFilterPopover) { + setTempGlobalStartTime(globalStartTime ? new Date(globalStartTime) : undefined); + setTempGlobalEndTime(globalEndTime ? new Date(globalEndTime) : undefined); + } + }, [showGlobalTimeFilterPopover, globalStartTime, globalEndTime]); + + + // Track view count when playground loads (only for non-owners) + useEffect(() => { + if (!currentPlaygroundId || isOwner) return; + + // Use sessionStorage to prevent duplicate counts from same session + const viewKey = `playground_view_${currentPlaygroundId}`; + const hasViewed = sessionStorage.getItem(viewKey); + + if (!hasViewed) { + // Track view asynchronously without blocking + fetch(`/api/playground/${currentPlaygroundId}/view`, { + method: 'POST', + }) + .then(res => res.json()) + .then(data => { + if (data.success && data.view_count !== undefined) { + setViewCount(data.view_count); + sessionStorage.setItem(viewKey, 'true'); + } + }) + .catch(err => { + console.error('Failed to track view:', err); + // Silently fail - don't disrupt user experience + }); + } + }, [currentPlaygroundId, isOwner]); + + 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, + globalStartTime: globalStartTime || null, + globalEndTime: globalEndTime || null, + charts: charts.map(chart => ({ + id: chart.id, + title: chart.title, + colSpan: chart.colSpan, + dataSeries: chart.dataSeries || [], + stackSameMetrics: chart.stackSameMetrics || false, + startTime: chart.startTime || null, + endTime: chart.endTime || null + })) + }; + + 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); + setSavedGlobalStartTime(globalStartTime); + setSavedGlobalEndTime(globalEndTime); + 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 global time filters changed + if (globalStartTime !== savedGlobalStartTime || globalEndTime !== savedGlobalEndTime) 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 || current.stackSameMetrics !== saved.stackSameMetrics || current.startTime !== saved.startTime || current.endTime !== saved.endTime) { + 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, globalStartTime, globalEndTime, savedGlobalStartTime, savedGlobalEndTime]); + + const copyLink = async () => { + if (savedLink) { + await navigator.clipboard.writeText(savedLink); + setLinkCopied(true); + setTimeout(() => setLinkCopied(false), 2000); + } + }; + + const shareOnX = () => { + if (savedLink) { + const text = isOwner + ? `check out my @avax ecosystem dashboard` + : `check out this @avax ecosystem dashboard`; + 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; + 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 ( +
+
+ {/* Header Skeleton */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Search Skeleton */} +
+
+
+
+ + {/* Chart Skeleton */} +
+
+
+
+
+
+
+
+
+
+ ); + } + + 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 || "My Playground"} +

+ {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 && ( + + {createdAt && } + Updated {new Date(updatedAt).toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" })} + + )} + + )} +
+
+
+ {isOwner ? ( + <> + {savedLink && ( + <> + + + + )} + {currentPlaygroundId && ( + <> + + {viewCount > 0 && ( + + )} + + )} + + + + ) : ( + <> + {savedLink && ( + <> + + + + )} + {currentPlaygroundId && ( + <> + + {viewCount > 0 && ( + + )} + + )} + + )} +
+
+ {!playgroundId && ( +

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

+ )} + {error && ( +
+

{error}

+
+ )} +
+ + {/* Search and Add Chart */} +
+
+ + setSearchTerm(e.target.value)} + className="h-10 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 && ( + + )} +
+ {isOwner && ( + <> + {/* Global Time Filters */} + + + + + + { + // Update dates, preserving existing times if dates already exist + if (range?.from) { + const newDate = new Date(range.from); + if (tempGlobalStartTime) { + // Preserve time from existing tempGlobalStartTime + newDate.setHours(tempGlobalStartTime.getHours(), tempGlobalStartTime.getMinutes(), 0, 0); + } else { + // Default to 00:00 if no existing time + newDate.setHours(0, 0, 0, 0); + } + setTempGlobalStartTime(newDate); + } else { + setTempGlobalStartTime(undefined); + } + + if (range?.to) { + const newDate = new Date(range.to); + if (tempGlobalEndTime) { + // Preserve time from existing tempGlobalEndTime + newDate.setHours(tempGlobalEndTime.getHours(), tempGlobalEndTime.getMinutes(), 0, 0); + } else { + // Default to 23:59 if no existing time + newDate.setHours(23, 59, 0, 0); + } + setTempGlobalEndTime(newDate); + } else { + setTempGlobalEndTime(undefined); + } + }} + initialFocus + /> +
+
+ + { + const [hours, minutes] = e.target.value.split(":").map(Number); + if (tempGlobalStartTime) { + const updated = new Date(tempGlobalStartTime); + updated.setHours(hours, minutes, 0, 0); + setTempGlobalStartTime(updated); + } else { + // If no date selected, create a new date with today's date + const today = new Date(); + today.setHours(hours, minutes, 0, 0); + setTempGlobalStartTime(today); + } + }} + className="text-xs sm:text-sm" + disabled={!tempGlobalStartTime} + /> +
+
+ + { + const [hours, minutes] = e.target.value.split(":").map(Number); + if (tempGlobalEndTime) { + const updated = new Date(tempGlobalEndTime); + updated.setHours(hours, minutes, 0, 0); + setTempGlobalEndTime(updated); + } else { + // If no date selected, create a new date with today's date + const today = new Date(); + today.setHours(hours, minutes, 0, 0); + setTempGlobalEndTime(today); + } + }} + className="text-xs sm:text-sm" + disabled={!tempGlobalEndTime} + /> +
+
+ +
+
+
+
+ + + )} +
+ + {/* 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} + onStackSameMetricsChange={isOwner ? (stackSameMetrics) => handleStackSameMetricsChange(chart.id, stackSameMetrics) : undefined} + onRemove={isOwner ? () => removeChart(chart.id) : undefined} + disableControls={!isOwner} + startTime={chart.startTime || globalStartTime || null} + endTime={chart.endTime || globalEndTime || null} + onTimeFilterChange={isOwner ? (startTime, endTime) => handleChartTimeFilterChange(chart.id, startTime, endTime) : undefined} + reloadTrigger={reloadTrigger} + /> +
+ ))} +
+ + {filteredCharts.length === 0 && ( +
+

+ No charts found matching "{searchTerm}" +

+
+ )} +
+
+ ); +} + +export default function PlaygroundPage() { + return ( + +
+ +
+
+ }> + + + ); +} + diff --git a/app/api/chain-stats/[chainId]/route.ts b/app/api/chain-stats/[chainId]/route.ts index e842bb4b166..0944f627630 100644 --- a/app/api/chain-stats/[chainId]/route.ts +++ b/app/api/chain-stats/[chainId]/route.ts @@ -30,12 +30,26 @@ let cachedData: Map { try { - const { startTimestamp, endTimestamp } = getTimestampsFromTimeRange(timeRange); + // Use provided timestamps if available, otherwise use timeRange + let finalStartTimestamp: number; + let finalEndTimestamp: number; + + if (startTimestamp !== undefined && endTimestamp !== undefined) { + finalStartTimestamp = startTimestamp; + finalEndTimestamp = endTimestamp; + } else { + const timestamps = getTimestampsFromTimeRange(timeRange); + finalStartTimestamp = timestamps.startTimestamp; + finalEndTimestamp = timestamps.endTimestamp; + } + let allResults: any[] = []; const avalanche = new Avalanche({ @@ -46,8 +60,8 @@ async function getTimeSeriesData( const params: any = { chainId: chainId, metric: metricType as any, - startTimestamp, - endTimestamp, + startTimestamp: finalStartTimestamp, + endTimestamp: finalEndTimestamp, timeInterval: "day", pageSize, }; @@ -82,19 +96,29 @@ async function getTimeSeriesData( } } -async function getICMData(chainId: string, timeRange: string): Promise { +async function getICMData(chainId: string, timeRange: string, startTimestamp?: number, endTimestamp?: number): Promise { try { - const getDaysFromTimeRange = (range: string): number => { - switch (range) { - case '7d': return 7; - case '30d': return 30; - case '90d': return 90; - case 'all': return 365; - default: return 30; - } - }; + let days: number; + + if (startTimestamp !== undefined && endTimestamp !== undefined) { + // Calculate days from timestamps + const startDate = new Date(startTimestamp * 1000); + const endDate = new Date(endTimestamp * 1000); + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()); + days = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + } else { + const getDaysFromTimeRange = (range: string): number => { + switch (range) { + case '7d': return 7; + case '30d': return 30; + case '90d': return 90; + case 'all': return 365; + default: return 30; + } + }; + days = getDaysFromTimeRange(timeRange); + } - const days = getDaysFromTimeRange(timeRange); const response = await fetch(`https://idx6.solokhin.com/api/${chainId}/metrics/dailyMessageVolume?days=${days}`, { headers: { 'Accept': 'application/json' }, }); @@ -108,7 +132,7 @@ async function getICMData(chainId: string, timeRange: string): Promise b.timestamp - a.timestamp) .map((item: any) => ({ timestamp: item.timestamp, @@ -117,6 +141,15 @@ async function getICMData(chainId: string, timeRange: string): Promise { + return item.timestamp >= startTimestamp && item.timestamp <= endTimestamp; + }); + } + + return filteredData; } catch (error) { console.warn(`Failed to fetch ICM data for chain ${chainId}:`, error); return []; @@ -131,6 +164,8 @@ export async function GET( try { const { searchParams } = new URL(request.url); const timeRange = searchParams.get('timeRange') || '30d'; + const startTimestampParam = searchParams.get('startTimestamp'); + const endTimestampParam = searchParams.get('endTimestamp'); const resolvedParams = await params; const chainId = resolvedParams.chainId; @@ -141,7 +176,34 @@ export async function GET( ); } - const cacheKey = `${chainId}-${timeRange}`; + // Parse timestamps if provided + const startTimestamp = startTimestampParam ? parseInt(startTimestampParam, 10) : undefined; + const endTimestamp = endTimestampParam ? parseInt(endTimestampParam, 10) : undefined; + + // Validate timestamps + if (startTimestamp !== undefined && isNaN(startTimestamp)) { + return NextResponse.json( + { error: 'Invalid startTimestamp parameter' }, + { status: 400 } + ); + } + if (endTimestamp !== undefined && isNaN(endTimestamp)) { + return NextResponse.json( + { error: 'Invalid endTimestamp parameter' }, + { status: 400 } + ); + } + if (startTimestamp !== undefined && endTimestamp !== undefined && startTimestamp > endTimestamp) { + return NextResponse.json( + { error: 'startTimestamp must be less than or equal to endTimestamp' }, + { status: 400 } + ); + } + + // Create cache key including timestamps if provided + const cacheKey = startTimestamp !== undefined && endTimestamp !== undefined + ? `${chainId}-${startTimestamp}-${endTimestamp}` + : `${chainId}-${timeRange}`; if (searchParams.get('clearCache') === 'true') { cachedData.clear(); @@ -150,7 +212,8 @@ export async function GET( const cached = cachedData.get(cacheKey); if (cached && Date.now() - cached.timestamp < STATS_CONFIG.CACHE.LONG_DURATION) { - if (cached.icmTimeRange !== timeRange) { + // Only refetch ICM data if timeRange changed (not for timestamp-based queries) + if (startTimestamp === undefined && endTimestamp === undefined && cached.icmTimeRange !== timeRange) { try { const newICMData = await getICMData(chainId, timeRange); cached.data.icmMessages = createICMMetric(newICMData); @@ -199,24 +262,24 @@ export async function GET( feesPaidData, icmData, ] = await Promise.all([ - getTimeSeriesData('activeAddresses', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('activeSenders', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('cumulativeAddresses', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('cumulativeDeployers', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('txCount', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('cumulativeTxCount', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('cumulativeContracts', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('contracts', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('deployers', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('gasUsed', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('avgGps', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('maxGps', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('avgTps', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('maxTps', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('avgGasPrice', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('maxGasPrice', chainId, timeRange, pageSize, fetchAllPages), - getTimeSeriesData('feesPaid', chainId, timeRange, pageSize, fetchAllPages), - getICMData(chainId, timeRange), + getTimeSeriesData('activeAddresses', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('activeSenders', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('cumulativeAddresses', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('cumulativeDeployers', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('txCount', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('cumulativeTxCount', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('cumulativeContracts', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('contracts', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('deployers', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('gasUsed', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('avgGps', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('maxGps', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('avgTps', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('maxTps', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('avgGasPrice', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('maxGasPrice', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getTimeSeriesData('feesPaid', chainId, timeRange, startTimestamp, endTimestamp, pageSize, fetchAllPages), + getICMData(chainId, timeRange, startTimestamp, endTimestamp), ]); const metrics: ChainMetrics = { diff --git a/app/api/playground/[id]/view/route.ts b/app/api/playground/[id]/view/route.ts new file mode 100644 index 00000000000..d3df74e5671 --- /dev/null +++ b/app/api/playground/[id]/view/route.ts @@ -0,0 +1,42 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { prisma } from '@/prisma/prisma'; + +// POST /api/playground/[id]/view - Increment view count +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id: playgroundId } = await params; + + if (!playgroundId) { + return NextResponse.json({ error: 'Playground ID is required' }, { status: 400 }); + } + + // Increment view count atomically + const playground = await prisma.statsPlayground.update({ + where: { id: playgroundId }, + data: { + view_count: { + increment: 1 + } + }, + select: { + view_count: true + } + }); + + return NextResponse.json({ + success: true, + view_count: playground.view_count + }); + } catch (error) { + console.error('Error incrementing view count:', error); + // Don't fail the request if view tracking fails + return NextResponse.json({ + success: false, + error: 'Failed to track view' + }, { status: 500 }); + } +} + diff --git a/app/api/playground/favorite/route.ts b/app/api/playground/favorite/route.ts new file mode 100644 index 00000000000..8324f1d494c --- /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.statsPlayground.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.statsPlaygroundFavorite.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.statsPlaygroundFavorite.create({ + data: { + playground_id: playgroundId, + user_id: session.user.id + } + }); + + // Get updated favorite count + const favoriteCount = await prisma.statsPlaygroundFavorite.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.statsPlaygroundFavorite.deleteMany({ + where: { + playground_id: playgroundId, + user_id: session.user.id + } + }); + + // Get updated favorite count + const favoriteCount = await prisma.statsPlaygroundFavorite.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..9136ff11315 --- /dev/null +++ b/app/api/playground/route.ts @@ -0,0 +1,248 @@ +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.statsPlayground.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 } + }, + user: { + select: { + id: true, + name: true, + user_name: true, + image: true, + profile_privacy: 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; + + // Extract global time filters and charts array from JSON structure + const chartsData = playground.charts as any; + const chartsArray = Array.isArray(chartsData) ? chartsData : (chartsData?.charts || []); + const globalStartTime = Array.isArray(chartsData) ? null : (chartsData?.globalStartTime || null); + const globalEndTime = Array.isArray(chartsData) ? null : (chartsData?.globalEndTime || null); + + return NextResponse.json({ + ...playground, + charts: chartsArray, + globalStartTime, + globalEndTime, + is_owner: isOwner, + is_favorited: isFavorited, + favorite_count: favoriteCount, + view_count: playground.view_count || 0, + favorites: undefined, // Remove favorites array 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 + }); + } + + // 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.statsPlayground.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.statsPlayground.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, globalStartTime, globalEndTime } = body; + + // Store global time filters in charts JSON structure + const chartsData = { + globalStartTime: globalStartTime || null, + globalEndTime: globalEndTime || null, + charts: charts || [] + }; + + const playground = await prisma.statsPlayground.create({ + data: { + user_id: session.user.id, + name, + is_public: isPublic || false, + charts: chartsData as any + } + }); + + 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, globalStartTime, globalEndTime } = body; + + // Verify ownership + const existing = await prisma.statsPlayground.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 || globalStartTime !== undefined || globalEndTime !== undefined) { + // Handle both old format (array) and new format (object) + const existingCharts = existing.charts as any; + const chartsArray = Array.isArray(existingCharts) ? existingCharts : (existingCharts?.charts || []); + + updateData.charts = { + globalStartTime: globalStartTime !== undefined ? (globalStartTime || null) : (existingCharts?.globalStartTime || null), + globalEndTime: globalEndTime !== undefined ? (globalEndTime || null) : (existingCharts?.globalEndTime || null), + charts: charts !== undefined ? charts : chartsArray + } as any; + } + + const playground = await prisma.statsPlayground.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.statsPlayground.findFirst({ + where: { + id, + user_id: session.user.id + } + }); + + if (!existing) { + return NextResponse.json({ error: 'Playground not found or unauthorized' }, { status: 404 }); + } + + await prisma.statsPlayground.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/app/layout.config.tsx b/app/layout.config.tsx index 205b60664c8..f8c660ace22 100644 --- a/app/layout.config.tsx +++ b/app/layout.config.tsx @@ -26,10 +26,8 @@ import { Ticket, Earth, ArrowLeftRight, - Shield, + DraftingCompass, GraduationCap, - BookOpen, - Users, } from 'lucide-react'; import Image from 'next/image'; import { UserButtonWrapper } from '@/components/login/user-button/UserButtonWrapper'; @@ -134,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 new file mode 100644 index 00000000000..403512c553f --- /dev/null +++ b/components/stats/ConfigurableChart.tsx @@ -0,0 +1,1637 @@ +"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 { Calendar } from "@/components/ui/calendar"; +import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { + Search, + X, + Eye, + EyeOff, + Plus, + Camera, + Loader2, + ChevronLeft, + GripVertical, + Layers, + Pencil, + Maximize2, + Minimize2, + Trash2, + CalendarIcon, + RefreshCw, +} 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[]; + initialStackSameMetrics?: boolean; + colSpan?: 6 | 12; + onColSpanChange?: (colSpan: 6 | 12) => void; + onTitleChange?: (title: string) => void; + onDataSeriesChange?: (dataSeries: DataSeries[]) => void; + onStackSameMetricsChange?: (stackSameMetrics: boolean) => void; + onRemove?: () => void; + disableControls?: boolean; + startTime?: string | null; + endTime?: string | null; + onTimeFilterChange?: (startTime: string | null, endTime: string | null) => void; + reloadTrigger?: number; +} + +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 = [], + initialStackSameMetrics = false, + colSpan = 12, + onColSpanChange, + onTitleChange, + onDataSeriesChange, + onStackSameMetricsChange, + onRemove, + disableControls = false, + startTime, + endTime, + onTimeFilterChange, + reloadTrigger = 0, +}: 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(initialStackSameMetrics); + + 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 [showTimeFilterPopover, setShowTimeFilterPopover] = useState(false); + const [localReloadTrigger, setLocalReloadTrigger] = useState(0); + const prevReloadTriggerRef = useRef(reloadTrigger); + // Use refs to track latest filter values for reload + const startTimeRef = useRef(startTime); + const endTimeRef = useRef(endTime); + + // Update refs when values change + useEffect(() => { + startTimeRef.current = startTime; + endTimeRef.current = endTime; + }, [startTime, endTime]); + // Temporary state for editing (only used when popover is open) - includes date and time + const [tempStartTime, setTempStartTime] = useState( + startTime ? new Date(startTime) : undefined + ); + const [tempEndTime, setTempEndTime] = useState( + endTime ? new Date(endTime) : undefined + ); + 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]); + + // Initialize temp state when popover opens + useEffect(() => { + if (showTimeFilterPopover) { + setTempStartTime(startTime ? new Date(startTime) : undefined); + setTempEndTime(endTime ? new Date(endTime) : undefined); + } + }, [showTimeFilterPopover, startTime, endTime]); + + // 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, forceReload: boolean = false) => { + const seriesId = `${chainId}-${metricKey}`; + + // Use refs to get latest values, especially important during reloads + const effectiveStartTime = startTimeRef.current; + const effectiveEndTime = endTimeRef.current; + + // Convert ISO timestamps to Unix timestamps (seconds) + const startTimestamp = effectiveStartTime ? Math.floor(new Date(effectiveStartTime).getTime() / 1000) : undefined; + const endTimestamp = effectiveEndTime ? Math.floor(new Date(effectiveEndTime).getTime() / 1000) : undefined; + + // Create cache key that includes timestamps + const cacheKey = startTimestamp && endTimestamp + ? `${seriesId}-${startTimestamp}-${endTimestamp}` + : `${seriesId}-all`; + + // Check if we already have this data cached (unless forcing reload) + if (!forceReload && chartData[cacheKey]) { + return; // Data already loaded for this time range + } + + if (loadingMetrics.has(cacheKey)) { + return; // Already loading this data + } + + setLoadingMetrics((prev) => new Set(prev).add(cacheKey)); + + try { + // Build query string with timestamps if available + let queryString = 'timeRange=all'; + if (startTimestamp !== undefined && endTimestamp !== undefined) { + queryString += `&startTimestamp=${startTimestamp}&endTimestamp=${endTimestamp}`; + } + + const response = await fetch(`/api/chain-stats/${chainId}?${queryString}`); + 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, // Keep seriesId for compatibility + [cacheKey]: data // Cache with timestamp key for future lookups + })); + } catch (error) { + console.error(`Error fetching ${metricKey} for chain ${chainId}:`, error); + } finally { + setLoadingMetrics((prev) => { + const next = new Set(prev); + next.delete(cacheKey); + return next; + }); + } + }; + + // Clear chart data cache when reload trigger changes (only on manual reload) + useEffect(() => { + if (reloadTrigger !== prevReloadTriggerRef.current) { + prevReloadTriggerRef.current = reloadTrigger; + // Clear cache completely + setChartData({}); + // Use setTimeout to ensure state updates (like globalStartTime/globalEndTime) have propagated + setTimeout(() => { + dataSeries.forEach((series) => { + if (series.visible && series.chainId && series.metricKey) { + fetchMetricData(series.chainId, series.metricKey, true); + } + }); + }, 10); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [reloadTrigger]); + + // Clear cache and reload when local reload trigger changes (only on manual reload) + useEffect(() => { + if (localReloadTrigger > 0) { + // Clear cache completely + setChartData({}); + // Use setTimeout to ensure state updates (like startTime/endTime) have propagated + setTimeout(() => { + dataSeries.forEach((series) => { + if (series.visible && series.chainId && series.metricKey) { + fetchMetricData(series.chainId, series.metricKey, true); + } + }); + }, 10); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [localReloadTrigger]); + + // Fetch data for all visible series (only on initial load or when series change) + 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(); + + // Only process data entries that match seriesId pattern (not cache keys with timestamps) + dataSeries.forEach((series) => { + const seriesId = `${series.chainId}-${series.metricKey}`; + const data = chartData[seriesId]; + + if (data) { + data.forEach((point) => { + if (!dateMap.has(point.date)) { + dateMap.set(point.date, { date: point.date }); + } + Object.keys(point).forEach((k) => { + if (k !== "date") { + dateMap.get(point.date)![k] = point[k]; + } + }); + }); + } + }); + + return Array.from(dateMap.values()).sort((a, b) => + a.date.localeCompare(b.date) + ); + }, [chartData, dataSeries]); + + // 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]); + + // Data is already filtered by the API, so use aggregatedData directly + const filteredData = aggregatedData; + + // Calculate filtered date range in days to determine which resolutions to enable + const filteredDaysCount = useMemo(() => { + if (startTime && endTime) { + const startDate = new Date(startTime); + const endDate = new Date(endTime); + const diffTime = Math.abs(endDate.getTime() - startDate.getTime()); + const days = Math.ceil(diffTime / (1000 * 60 * 60 * 24)); + return days; + } + + // If no filter, use the data length as an estimate (assuming daily data) + return filteredData.length; + }, [startTime, endTime, filteredData.length]); + + // Determine which resolutions should be enabled based on filtered days + const isResolutionEnabled = useMemo(() => { + return { + D: true, // Daily is always enabled + W: filteredDaysCount >= 7 * 2, // Weekly needs at least 7 days + M: filteredDaysCount >= 30 * 2, // Monthly needs at least 30 days + Q: filteredDaysCount >= 90 * 2, // Quarterly needs at least 90 days + Y: filteredDaysCount >= 365 * 2, // Yearly needs at least 365 days + }; + }, [filteredDaysCount]); + + // Auto-switch to Daily if current resolution becomes disabled + useEffect(() => { + if (!isResolutionEnabled[resolution]) { + setResolution("D"); + } + }, [isResolutionEnabled, resolution]); + + // Set default brush range + useEffect(() => { + if (filteredData.length === 0) return; + if (resolution === "D") { + setBrushRange({ + startIndex: Math.max(0, filteredData.length - 90), + endIndex: filteredData.length - 1, + }); + } else { + setBrushRange({ + startIndex: 0, + endIndex: aggregatedData.length - 1, + }); + } + }, [resolution, filteredData.length]); + + const displayData = brushRange + ? filteredData.slice(brushRange.startIndex, brushRange.endIndex + 1) + : filteredData; + + 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 { + // 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 && ( +
+
+ { + 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" + /> + +
+ +
+
+ + {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 ( + + ); + })} +
+
+ )} +
+ + {onTimeFilterChange && !disableControls && ( + + + + + + { + // Update dates, preserving existing times if dates already exist + if (range?.from) { + const newDate = new Date(range.from); + if (tempStartTime) { + // Preserve time from existing tempStartTime + newDate.setHours(tempStartTime.getHours(), tempStartTime.getMinutes(), 0, 0); + } else { + // Default to 00:00 if no existing time + newDate.setHours(0, 0, 0, 0); + } + setTempStartTime(newDate); + } else { + setTempStartTime(undefined); + } + + if (range?.to) { + const newDate = new Date(range.to); + if (tempEndTime) { + // Preserve time from existing tempEndTime + newDate.setHours(tempEndTime.getHours(), tempEndTime.getMinutes(), 0, 0); + } else { + // Default to 23:59 if no existing time + newDate.setHours(23, 59, 0, 0); + } + setTempEndTime(newDate); + } else { + setTempEndTime(undefined); + } + }} + initialFocus + /> +
+
+ + { + const [hours, minutes] = e.target.value.split(":").map(Number); + if (tempStartTime) { + const updated = new Date(tempStartTime); + updated.setHours(hours, minutes, 0, 0); + setTempStartTime(updated); + } else { + // If no date selected, create a new date with today's date + const today = new Date(); + today.setHours(hours, minutes, 0, 0); + setTempStartTime(today); + } + }} + className="text-xs sm:text-sm" + disabled={!tempStartTime} + /> +
+
+ + { + const [hours, minutes] = e.target.value.split(":").map(Number); + if (tempEndTime) { + const updated = new Date(tempEndTime); + updated.setHours(hours, minutes, 0, 0); + setTempEndTime(updated); + } else { + // If no date selected, create a new date with today's date + const today = new Date(); + today.setHours(hours, minutes, 0, 0); + setTempEndTime(today); + } + }} + className="text-xs sm:text-sm" + disabled={!tempEndTime} + /> +
+
+ {(tempStartTime || tempEndTime || startTime || endTime) && ( + + )} + +
+
+
+
+ )} + {onColSpanChange && ( + + )} + {onRemove && !disableControls && ( + + )} +
+
+ )} +
+ + + {/* 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"; + const isEnabled = isResolutionEnabled[p]; + const isSelected = resolution === p; + + return ( + + ); + })} +
+
+ + {/* Chart Area */} +
+ {/* Watermark */} +
+
+ + Builder Hub +
+
+
+ {renderChart()} + + {/* Brush Slider */} + {filteredData.length > 0 && visibleSeries.length > 0 && ( +
+ + + { + if ( + e.startIndex !== undefined && + e.endIndex !== undefined + ) { + setBrushRange({ + startIndex: e.startIndex, + endIndex: e.endIndex, + }); + } + }} + travellerWidth={8} + tickFormatter={formatXAxis} + > + + + + + + +
+ )} +
+
+
+
+ ); +} diff --git a/content/blog/durango-avalanche-warp-messaging.mdx b/content/blog/durango-avalanche-warp-messaging.mdx index ed218c9a1b6..a30940792de 100644 --- a/content/blog/durango-avalanche-warp-messaging.mdx +++ b/content/blog/durango-avalanche-warp-messaging.mdx @@ -7,8 +7,6 @@ topics: [Network Updates, Durango Upgrade, Avalanche Warp Messaging, EVM, Intero comments: true --- -Durango: Avalanche Warp Messaging Comes to the EVM - The publishing of the pre-release code for a proposed upgrade to the Avalanche Network, codenamed Durango --- 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/migrations/20251118064030_add_view_count_to_playground/migration.sql b/prisma/migrations/20251118064030_add_view_count_to_playground/migration.sql new file mode 100644 index 00000000000..70af7a5b21f --- /dev/null +++ b/prisma/migrations/20251118064030_add_view_count_to_playground/migration.sql @@ -0,0 +1,11 @@ +-- AlterTable +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'Playground' AND column_name = 'view_count' + ) THEN + ALTER TABLE "Playground" ADD COLUMN "view_count" INTEGER NOT NULL DEFAULT 0; + END IF; +END $$; + diff --git a/prisma/migrations/20251121000000_rename_playground_tables/migration.sql b/prisma/migrations/20251121000000_rename_playground_tables/migration.sql new file mode 100644 index 00000000000..e7868212765 --- /dev/null +++ b/prisma/migrations/20251121000000_rename_playground_tables/migration.sql @@ -0,0 +1,20 @@ +-- Rename the tables +ALTER TABLE "Playground" RENAME TO "StatsPlayground"; +ALTER TABLE "PlaygroundFavorite" RENAME TO "StatsPlaygroundFavorite"; + +-- Rename Primary Keys +ALTER TABLE "StatsPlayground" RENAME CONSTRAINT "Playground_pkey" TO "StatsPlayground_pkey"; +ALTER TABLE "StatsPlaygroundFavorite" RENAME CONSTRAINT "PlaygroundFavorite_pkey" TO "StatsPlaygroundFavorite_pkey"; + +-- Rename Indexes +ALTER INDEX "Playground_user_id_idx" RENAME TO "StatsPlayground_user_id_idx"; +ALTER INDEX "Playground_is_public_idx" RENAME TO "StatsPlayground_is_public_idx"; +ALTER INDEX "Playground_created_at_idx" RENAME TO "StatsPlayground_created_at_idx"; +ALTER INDEX "PlaygroundFavorite_playground_id_idx" RENAME TO "StatsPlaygroundFavorite_playground_id_idx"; +ALTER INDEX "PlaygroundFavorite_user_id_idx" RENAME TO "StatsPlaygroundFavorite_user_id_idx"; +ALTER INDEX "PlaygroundFavorite_playground_id_user_id_key" RENAME TO "StatsPlaygroundFavorite_playground_id_user_id_key"; + +-- Rename Foreign Keys +ALTER TABLE "StatsPlayground" RENAME CONSTRAINT "Playground_user_id_fkey" TO "StatsPlayground_user_id_fkey"; +ALTER TABLE "StatsPlaygroundFavorite" RENAME CONSTRAINT "PlaygroundFavorite_playground_id_fkey" TO "StatsPlaygroundFavorite_playground_id_fkey"; +ALTER TABLE "StatsPlaygroundFavorite" RENAME CONSTRAINT "PlaygroundFavorite_user_id_fkey" TO "StatsPlaygroundFavorite_user_id_fkey"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 04fee70e7a3..f4d7d524280 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -58,6 +58,8 @@ model User { badges UserBadge[] managedTestnetNodes NodeRegistration[] consoleLog ConsoleLog[] + statsPlaygrounds StatsPlayground[] + statsPlaygroundFavorites StatsPlaygroundFavorite[] @relation("StatsPlaygroundFavorites") } model VerificationToken { @@ -220,4 +222,34 @@ model ConsoleLog { @@index([user_id]) @@index([created_at]) +} + +model StatsPlayground { + id String @id @default(uuid()) + user_id String + name String + is_public Boolean @default(false) + charts Json // Array of chart configurations + view_count Int @default(0) + 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 StatsPlaygroundFavorite[] + + @@index([user_id]) + @@index([is_public]) + @@index([created_at]) +} + +model StatsPlaygroundFavorite { + id String @id @default(uuid()) + playground_id String + user_id String + created_at DateTime @default(now()) @db.Timestamptz(3) + playground StatsPlayground @relation(fields: [playground_id], references: [id], onDelete: Cascade) + user User @relation("StatsPlaygroundFavorites", fields: [user_id], references: [id], onDelete: Cascade) + + @@unique([playground_id, user_id]) + @@index([playground_id]) + @@index([user_id]) } \ No newline at end of file