mirror of
https://github.com/logos-storage/metrics.git
synced 2026-01-03 22:13:09 +00:00
515 lines
20 KiB
JavaScript
515 lines
20 KiB
JavaScript
"use client";
|
|
|
|
import { useEffect, useState } from "react";
|
|
import { createClient } from "@supabase/supabase-js";
|
|
import { motion } from "framer-motion";
|
|
import { format, isToday, isYesterday } from "date-fns";
|
|
import Image from "next/image";
|
|
import {
|
|
Activity,
|
|
Users,
|
|
Network,
|
|
Database,
|
|
Clock,
|
|
AlertTriangle,
|
|
RotateCw,
|
|
Search,
|
|
ChevronLeft,
|
|
ChevronRight,
|
|
} from "lucide-react";
|
|
import {
|
|
LineChart,
|
|
Line,
|
|
XAxis,
|
|
YAxis,
|
|
CartesianGrid,
|
|
Tooltip,
|
|
ResponsiveContainer,
|
|
} from "recharts";
|
|
|
|
// Initialize Supabase client
|
|
const supabase = createClient(
|
|
process.env.NEXT_PUBLIC_SUPABASE_URL,
|
|
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY
|
|
);
|
|
|
|
const ITEMS_PER_PAGE = 5;
|
|
|
|
export default function Dashboard() {
|
|
const [metrics, setMetrics] = useState([]);
|
|
const [activeNodes, setActiveNodes] = useState([]);
|
|
const [timeframe, setTimeframe] = useState("7d");
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState(null);
|
|
const [componentLoading, setComponentLoading] = useState({
|
|
metrics: true,
|
|
nodes: true,
|
|
versions: true,
|
|
peers: true,
|
|
});
|
|
|
|
// Pagination and Search states
|
|
const [versionPage, setVersionPage] = useState(1);
|
|
const [peerPage, setPeerPage] = useState(1);
|
|
const [searchQuery, setSearchQuery] = useState("");
|
|
const [searchResults, setSearchResults] = useState([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
|
|
useEffect(() => {
|
|
fetchData();
|
|
}, [timeframe]);
|
|
|
|
// Update search results when query changes
|
|
useEffect(() => {
|
|
if (searchQuery.trim()) {
|
|
setIsSearching(true);
|
|
const results = activePeerIds.filter(peerId =>
|
|
peerId.toLowerCase().includes(searchQuery.toLowerCase())
|
|
);
|
|
setSearchResults(results);
|
|
setPeerPage(1);
|
|
setIsSearching(false);
|
|
} else {
|
|
setSearchResults([]);
|
|
}
|
|
}, [searchQuery]);
|
|
|
|
const fetchData = async () => {
|
|
setLoading(true);
|
|
setError(null);
|
|
setComponentLoading({
|
|
metrics: true,
|
|
nodes: true,
|
|
versions: true,
|
|
peers: true,
|
|
});
|
|
|
|
try {
|
|
// Fetch metrics
|
|
const { data: metricsData, error: metricsError } = await supabase
|
|
.from("metrics")
|
|
.select("*")
|
|
.order("date", { ascending: true })
|
|
.limit(timeframe === "7d" ? 7 : timeframe === "30d" ? 30 : 365);
|
|
|
|
if (metricsError) throw metricsError;
|
|
setMetrics(metricsData || []);
|
|
setComponentLoading(prev => ({ ...prev, metrics: false }));
|
|
|
|
// Fetch nodes
|
|
const { data: nodesData, error: nodesError } = await supabase
|
|
.from("node_records")
|
|
.select("*")
|
|
.order("timestamp", { ascending: false });
|
|
|
|
if (nodesError) throw nodesError;
|
|
setActiveNodes(nodesData || []);
|
|
setComponentLoading(prev => ({ ...prev, nodes: false, versions: false, peers: false }));
|
|
|
|
if (!metricsData?.length && !nodesData?.length) {
|
|
throw new Error("No data available");
|
|
}
|
|
} catch (error) {
|
|
console.error("Error fetching data:", error);
|
|
setError(error.message);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
// Pagination helpers
|
|
const getPaginatedData = (data, page, itemsPerPage = ITEMS_PER_PAGE) => {
|
|
const startIndex = (page - 1) * itemsPerPage;
|
|
return data.slice(startIndex, startIndex + itemsPerPage);
|
|
};
|
|
|
|
const getPageCount = (totalItems, itemsPerPage = ITEMS_PER_PAGE) => {
|
|
return Math.ceil(totalItems / itemsPerPage);
|
|
};
|
|
|
|
const PaginationControls = ({ currentPage, totalPages, onPageChange, className = "" }) => (
|
|
<div className={`flex items-center justify-between ${className}`}>
|
|
<button
|
|
onClick={() => onPageChange(currentPage - 1)}
|
|
disabled={currentPage <= 1}
|
|
className="p-1 text-neutral-400 hover:text-[#7afbaf] disabled:text-neutral-600
|
|
hover:bg-neutral-800/50 rounded transition-colors disabled:hover:bg-transparent"
|
|
>
|
|
<ChevronLeft className="w-5 h-5" />
|
|
</button>
|
|
<span className="text-sm text-neutral-400">
|
|
Page {currentPage} of {totalPages}
|
|
</span>
|
|
<button
|
|
onClick={() => onPageChange(currentPage + 1)}
|
|
disabled={currentPage >= totalPages}
|
|
className="p-1 text-neutral-400 hover:text-[#7afbaf] disabled:text-neutral-600
|
|
hover:bg-neutral-800/50 rounded transition-colors disabled:hover:bg-transparent"
|
|
>
|
|
<ChevronRight className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
);
|
|
|
|
// Component loading skeleton
|
|
const ComponentSkeleton = ({ className = "" }) => (
|
|
<div className={`animate-pulse space-y-4 ${className}`}>
|
|
<div className="h-6 bg-neutral-800/50 rounded-lg w-1/3" />
|
|
<div className="space-y-3">
|
|
<div className="h-10 bg-neutral-800/50 rounded-lg" />
|
|
<div className="h-10 bg-neutral-800/50 rounded-lg" />
|
|
<div className="h-10 bg-neutral-800/50 rounded-lg" />
|
|
</div>
|
|
</div>
|
|
);
|
|
|
|
const formatLastUpdated = (timestamp) => {
|
|
if (!timestamp) return "N/A";
|
|
const date = new Date(timestamp);
|
|
const time = format(date, "HH:mm");
|
|
let dateText;
|
|
|
|
if (isToday(date)) {
|
|
dateText = "Today";
|
|
} else if (isYesterday(date)) {
|
|
dateText = "Yesterday";
|
|
} else {
|
|
dateText = format(date, "dd.MM.yyyy");
|
|
}
|
|
|
|
return { time, dateText };
|
|
};
|
|
|
|
// Calculate statistics
|
|
const currentActiveNodes = activeNodes.length;
|
|
const averagePeerCount = activeNodes.length
|
|
? (activeNodes.reduce((acc, node) => acc + node.peer_count, 0) / activeNodes.length).toFixed(1)
|
|
: 0;
|
|
const activePeerIds = [...new Set(activeNodes.map((node) => node.peer_id))];
|
|
const totalNodes = metrics.reduce((acc, day) => acc + day.new_records_count, 0);
|
|
const versionDistribution = activeNodes.reduce((acc, node) => {
|
|
acc[node.version] = (acc[node.version] || 0) + 1;
|
|
return acc;
|
|
}, {});
|
|
const lastUpdated = formatLastUpdated(activeNodes[0]?.timestamp);
|
|
|
|
// Get paginated data
|
|
const versionEntries = Object.entries(versionDistribution);
|
|
const paginatedVersions = getPaginatedData(versionEntries, versionPage);
|
|
const displayPeerIds = searchQuery ? searchResults : activePeerIds;
|
|
const paginatedPeerIds = getPaginatedData(displayPeerIds, peerPage);
|
|
|
|
// Calculate total pages
|
|
const totalVersionPages = getPageCount(versionEntries.length);
|
|
const totalPeerPages = getPageCount(displayPeerIds.length);
|
|
|
|
return (
|
|
<div className="min-h-screen bg-black text-white overflow-x-hidden">
|
|
{/* Header */}
|
|
<motion.header
|
|
initial={{ opacity: 0, y: -20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
className="sticky top-0 z-50 backdrop-blur-xl bg-black/50 border-b border-neutral-800"
|
|
>
|
|
<div className="max-w-[2000px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<img src="/logo.svg" alt="Codex" className="w-10 h-10" />
|
|
<h1 className="text-lg sm:text-xl font-bold">Codex Metrics</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<select
|
|
value={timeframe}
|
|
onChange={(e) => setTimeframe(e.target.value)}
|
|
className="bg-neutral-900 border border-neutral-800 rounded-lg px-3 py-2 text-sm font-medium
|
|
hover:border-neutral-700 focus:border-[#7afbaf] focus:ring-1 focus:ring-[#7afbaf]
|
|
transition-colors cursor-pointer outline-none"
|
|
>
|
|
<option value="7d">Last 7 Days</option>
|
|
<option value="30d">Last 30 Days</option>
|
|
<option value="1y">Last Year</option>
|
|
</select>
|
|
<button
|
|
onClick={fetchData}
|
|
disabled={loading}
|
|
className="p-2 text-neutral-400 hover:text-[#7afbaf] disabled:text-neutral-600
|
|
bg-neutral-900 border border-neutral-800 rounded-lg
|
|
hover:border-neutral-700 disabled:border-neutral-800 disabled:hover:border-neutral-800
|
|
focus:border-[#7afbaf] focus:ring-1 focus:ring-[#7afbaf]
|
|
transition-colors cursor-pointer disabled:cursor-not-allowed outline-none"
|
|
>
|
|
<RotateCw className={`w-5 h-5 ${loading ? 'animate-spin' : ''}`} />
|
|
<span className="sr-only">Refresh data</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.header>
|
|
|
|
<main className="max-w-[2000px] mx-auto px-4 sm:px-6 py-4 sm:py-6">
|
|
{error ? (
|
|
<ErrorState message={error} />
|
|
) : (
|
|
<div className="space-y-4 sm:space-y-6">
|
|
{/* Top Section: Stats + Graph */}
|
|
<div className="grid gap-4 sm:gap-6 lg:grid-cols-2">
|
|
{/* Left Column - Stats Cards */}
|
|
<div className="grid grid-cols-2 lg:grid-cols-1 lg:grid-rows-4 gap-4 lg:h-[450px]">
|
|
{[
|
|
{
|
|
title: "Active Nodes",
|
|
value: currentActiveNodes,
|
|
Icon: Users,
|
|
delay: 0,
|
|
isLoading: componentLoading.nodes,
|
|
},
|
|
{
|
|
title: "Average Peer Count",
|
|
value: averagePeerCount,
|
|
Icon: Network,
|
|
delay: 0.1,
|
|
isLoading: componentLoading.nodes,
|
|
},
|
|
{
|
|
title: "Total Nodes",
|
|
value: totalNodes,
|
|
Icon: Database,
|
|
delay: 0.2,
|
|
isLoading: componentLoading.metrics,
|
|
},
|
|
{
|
|
title: "Last Updated",
|
|
value: lastUpdated,
|
|
Icon: Clock,
|
|
delay: 0.3,
|
|
isLoading: componentLoading.nodes,
|
|
},
|
|
].map((stat) => (
|
|
<motion.div
|
|
key={stat.title}
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: stat.delay }}
|
|
className="bg-neutral-900 p-4 sm:p-5 rounded-xl hover:bg-neutral-900/80
|
|
transition-colors border border-neutral-800 hover:border-neutral-700
|
|
flex flex-col justify-between h-full"
|
|
>
|
|
<h3 className="text-neutral-400 text-sm font-medium flex items-center gap-2 mb-3">
|
|
<stat.Icon className="w-4 h-4 opacity-60" />
|
|
{stat.title}
|
|
</h3>
|
|
<div className="mt-auto">
|
|
{stat.isLoading ? (
|
|
<div className="h-8 bg-neutral-800/50 rounded animate-pulse" />
|
|
) : stat.title === "Last Updated" ? (
|
|
<div className="flex items-baseline gap-2">
|
|
<span className="text-lg sm:text-xl lg:text-2xl font-bold text-[#7afbaf] tracking-tight">
|
|
{lastUpdated.time}
|
|
</span>
|
|
<span className="text-sm font-medium text-[#7afbaf] opacity-70">
|
|
{lastUpdated.dateText}
|
|
</span>
|
|
</div>
|
|
) : (
|
|
<p className="text-lg sm:text-xl lg:text-2xl font-bold text-[#7afbaf] tracking-tight">
|
|
{stat.value}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Right Column - Chart */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.4 }}
|
|
className="bg-neutral-900 p-4 sm:p-6 rounded-xl h-[350px] lg:h-[450px] border border-neutral-800
|
|
hover:border-neutral-700 transition-colors"
|
|
>
|
|
<h3 className="text-neutral-400 mb-4 font-medium flex items-center gap-2">
|
|
<Activity className="w-5 h-5 opacity-60" />
|
|
Active Nodes Over Time
|
|
</h3>
|
|
{componentLoading.metrics ? (
|
|
<div className="h-[calc(100%-2rem)] flex items-center justify-center">
|
|
<ComponentSkeleton />
|
|
</div>
|
|
) : metrics.length === 0 ? (
|
|
<div className="h-[calc(100%-2rem)] flex items-center justify-center">
|
|
<p className="text-neutral-400">No data available for the selected timeframe</p>
|
|
</div>
|
|
) : (
|
|
<div className="h-[calc(100%-2rem)]">
|
|
<ResponsiveContainer width="100%" height="100%">
|
|
<LineChart data={metrics}>
|
|
<CartesianGrid
|
|
strokeDasharray="3 3"
|
|
stroke="#333"
|
|
vertical={false}
|
|
/>
|
|
<XAxis
|
|
dataKey="date"
|
|
stroke="#666"
|
|
tickFormatter={(date) => format(new Date(date), "MMM d")}
|
|
fontSize={12}
|
|
tickMargin={10}
|
|
/>
|
|
<YAxis
|
|
stroke="#666"
|
|
fontSize={12}
|
|
tickMargin={10}
|
|
axisLine={false}
|
|
/>
|
|
<Tooltip
|
|
contentStyle={{
|
|
backgroundColor: "#1a1a1a",
|
|
border: "1px solid #333",
|
|
borderRadius: "8px",
|
|
fontFamily: "Inter",
|
|
fontSize: "12px",
|
|
padding: "12px",
|
|
}}
|
|
cursor={{ stroke: "#666" }}
|
|
/>
|
|
<Line
|
|
type="monotone"
|
|
dataKey="new_records_count"
|
|
stroke="#7afbaf"
|
|
strokeWidth={2}
|
|
dot={false}
|
|
activeDot={{ r: 6, fill: "#7afbaf" }}
|
|
/>
|
|
</LineChart>
|
|
</ResponsiveContainer>
|
|
</div>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
|
|
{/* Bottom Section: Version Distribution + Active Peers */}
|
|
<div className="grid gap-4 sm:gap-6 lg:grid-cols-2">
|
|
{/* Version Distribution */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.5 }}
|
|
className="bg-neutral-900 p-4 sm:p-6 rounded-xl border border-neutral-800
|
|
hover:border-neutral-700 transition-colors h-[300px] lg:h-[350px] flex flex-col"
|
|
>
|
|
<h3 className="text-neutral-400 mb-4 sm:mb-6 font-medium flex items-center gap-2">
|
|
<Database className="w-5 h-5 opacity-60" />
|
|
Version Distribution
|
|
</h3>
|
|
{componentLoading.versions ? (
|
|
<ComponentSkeleton />
|
|
) : Object.keys(versionDistribution).length === 0 ? (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<p className="text-neutral-400">No version data available</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-4 overflow-y-auto flex-1 pr-2 scrollbar-thin
|
|
scrollbar-thumb-neutral-700 scrollbar-track-neutral-800">
|
|
{paginatedVersions.map(([version, count]) => (
|
|
<div key={version}>
|
|
<div className="flex justify-between mb-2">
|
|
<span className="font-medium text-sm sm:text-base">{version}</span>
|
|
<span className="font-medium text-sm sm:text-base text-[#7afbaf]">
|
|
{count}
|
|
</span>
|
|
</div>
|
|
<div className="w-full bg-neutral-800 rounded-full h-2 overflow-hidden">
|
|
<motion.div
|
|
initial={{ width: 0 }}
|
|
animate={{ width: `${(count / currentActiveNodes) * 100}%` }}
|
|
transition={{ duration: 0.5, ease: "easeOut" }}
|
|
className="bg-[#7afbaf] h-2 rounded-full"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
<PaginationControls
|
|
currentPage={versionPage}
|
|
totalPages={totalVersionPages}
|
|
onPageChange={setVersionPage}
|
|
className="mt-4 pt-4 border-t border-neutral-800"
|
|
/>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
|
|
{/* Active Peer IDs List */}
|
|
<motion.div
|
|
initial={{ opacity: 0, y: 20 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
transition={{ delay: 0.6 }}
|
|
className="bg-neutral-900 p-4 sm:p-6 rounded-xl border border-neutral-800
|
|
hover:border-neutral-700 transition-colors h-[300px] lg:h-[350px] flex flex-col"
|
|
>
|
|
<div className="flex flex-col gap-4 sm:gap-6">
|
|
<h3 className="text-neutral-400 font-medium flex items-center gap-2">
|
|
<Network className="w-5 h-5 opacity-60" />
|
|
Active Peer IDs
|
|
</h3>
|
|
<div className="relative">
|
|
<input
|
|
type="text"
|
|
placeholder="Search peer IDs..."
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full bg-neutral-800 border border-neutral-700 rounded-lg px-4 py-2
|
|
text-sm placeholder-neutral-500 focus:border-[#7afbaf] focus:ring-1
|
|
focus:ring-[#7afbaf] transition-colors outline-none"
|
|
/>
|
|
<Search className="w-4 h-4 text-neutral-500 absolute right-3 top-1/2 -translate-y-1/2" />
|
|
</div>
|
|
</div>
|
|
{componentLoading.peers ? (
|
|
<ComponentSkeleton className="mt-4" />
|
|
) : activePeerIds.length === 0 ? (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<p className="text-neutral-400">No active peers available</p>
|
|
</div>
|
|
) : isSearching ? (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<RotateCw className="w-6 h-6 text-neutral-400 animate-spin" />
|
|
</div>
|
|
) : searchQuery && searchResults.length === 0 ? (
|
|
<div className="flex items-center justify-center flex-1">
|
|
<p className="text-neutral-400">No matching peer IDs found</p>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="space-y-2 overflow-y-auto flex-1 pr-2 mt-4 scrollbar-thin
|
|
scrollbar-thumb-neutral-700 scrollbar-track-neutral-800">
|
|
{paginatedPeerIds.map((peerId, index) => (
|
|
<motion.div
|
|
key={peerId}
|
|
initial={{ opacity: 0, x: -20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
transition={{ delay: 0.1 * (index % 5) }}
|
|
className="bg-neutral-800 p-3 rounded-lg text-xs sm:text-sm font-medium
|
|
break-all hover:bg-neutral-700/50 transition-colors"
|
|
>
|
|
{peerId}
|
|
</motion.div>
|
|
))}
|
|
</div>
|
|
<PaginationControls
|
|
currentPage={peerPage}
|
|
totalPages={totalPeerPages}
|
|
onPageChange={setPeerPage}
|
|
className="mt-4 pt-4 border-t border-neutral-800"
|
|
/>
|
|
</>
|
|
)}
|
|
</motion.div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</main>
|
|
</div>
|
|
);
|
|
}
|