mirror of
https://github.com/logos-storage/metrics.git
synced 2026-01-04 06:23:07 +00:00
enhanced ui with working metrics
This commit is contained in:
parent
10d0daf57f
commit
1146db13dc
21
components.json
Normal file
21
components.json
Normal file
@ -0,0 +1,21 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": false,
|
||||
"tsx": false,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.mjs",
|
||||
"css": "styles/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": false,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"iconLibrary": "lucide"
|
||||
}
|
||||
6
lib/utils.js
Normal file
6
lib/utils.js
Normal file
@ -0,0 +1,6 @@
|
||||
import { clsx } from "clsx";
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
2264
package-lock.json
generated
2264
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
30
package.json
30
package.json
@ -9,15 +9,33 @@
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"next": "15.1.1"
|
||||
"@radix-ui/react-avatar": "^1.1.2",
|
||||
"@radix-ui/react-dialog": "^1.1.4",
|
||||
"@radix-ui/react-popover": "^1.1.4",
|
||||
"@radix-ui/react-select": "^2.1.4",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@radix-ui/react-switch": "^1.1.2",
|
||||
"@supabase/supabase-js": "^2.47.8",
|
||||
"@tremor/react": "^3.18.6",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"dotted-map": "^2.2.3",
|
||||
"framer-motion": "^11.15.0",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.1",
|
||||
"react": "^18",
|
||||
"react-dom": "^18",
|
||||
"recharts": "^2.15.0",
|
||||
"tailwind-merge": "^2.5.5",
|
||||
"tailwindcss-animate": "^1.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1",
|
||||
"@eslint/eslintrc": "^3",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.1",
|
||||
"@eslint/eslintrc": "^3"
|
||||
"postcss": "^8",
|
||||
"tailwindcss": "^3.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
600
pages/index.js
600
pages/index.js
@ -1,114 +1,514 @@
|
||||
"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 { Geist, Geist_Mono } from "next/font/google";
|
||||
import {
|
||||
Activity,
|
||||
Users,
|
||||
Network,
|
||||
Database,
|
||||
Clock,
|
||||
AlertTriangle,
|
||||
RotateCw,
|
||||
Search,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
} from "recharts";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
// 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,
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
// 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,
|
||||
});
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div
|
||||
className={`${geistSans.variable} ${geistMono.variable} grid grid-rows-[20px_1fr_20px] items-center justify-items-center min-h-screen p-8 pb-20 gap-16 sm:p-20 font-[family-name:var(--font-geist-sans)]`}
|
||||
>
|
||||
<main className="flex flex-col gap-8 row-start-2 items-center sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={180}
|
||||
height={38}
|
||||
priority
|
||||
/>
|
||||
<ol className="list-inside list-decimal text-sm text-center sm:text-left font-[family-name:var(--font-geist-mono)]">
|
||||
<li className="mb-2">
|
||||
Get started by editing{" "}
|
||||
<code className="bg-black/[.05] dark:bg-white/[.06] px-1 py-0.5 rounded font-semibold">
|
||||
pages/index.js
|
||||
</code>
|
||||
.
|
||||
</li>
|
||||
<li>Save and see your changes instantly.</li>
|
||||
</ol>
|
||||
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);
|
||||
|
||||
<div className="flex gap-4 items-center flex-col sm:flex-row">
|
||||
<a
|
||||
className="rounded-full border border-solid border-transparent transition-colors flex items-center justify-center bg-foreground text-background gap-2 hover:bg-[#383838] dark:hover:bg-[#ccc] text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
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"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={20}
|
||||
height={20}
|
||||
/>
|
||||
Deploy now
|
||||
</a>
|
||||
<a
|
||||
className="rounded-full border border-solid border-black/[.08] dark:border-white/[.145] transition-colors flex items-center justify-center hover:bg-[#f2f2f2] dark:hover:bg-[#1a1a1a] hover:border-transparent text-sm sm:text-base h-10 sm:h-12 px-4 sm:px-5 sm:min-w-44"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
<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"
|
||||
>
|
||||
Read our docs
|
||||
</a>
|
||||
<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>
|
||||
<footer className="row-start-3 flex gap-6 flex-wrap items-center justify-center">
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/file.svg"
|
||||
alt="File icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Learn
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/window.svg"
|
||||
alt="Window icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Examples
|
||||
</a>
|
||||
<a
|
||||
className="flex items-center gap-2 hover:underline hover:underline-offset-4"
|
||||
href="https://nextjs.org?utm_source=create-next-app&utm_medium=default-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
aria-hidden
|
||||
src="/globe.svg"
|
||||
alt="Globe icon"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Go to nextjs.org →
|
||||
</a>
|
||||
</footer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
11
public/logo.svg
Normal file
11
public/logo.svg
Normal file
@ -0,0 +1,11 @@
|
||||
|
||||
<svg width="40" height="40" viewBox="0 0 40 40" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_291_2793)">
|
||||
<path fill-rule="evenodd" clip-rule="evenodd" d="M19.7001 32.7386C19.7705 32.7792 19.8502 32.8001 19.9306 32.8001C20.0111 32.8001 20.188 32.7226 20.188 32.7226L30.85 26.581C30.8555 26.5783 30.8632 26.5744 30.8706 26.5702C30.942 26.5296 31.0002 26.4707 31.0406 26.4008C31.0819 26.3303 31.1036 26.25 31.1036 26.1685C31.1036 26.1588 31.1032 26.1499 31.1028 26.1432V13.8597C31.1036 13.8486 31.1036 13.8387 31.1036 13.8337L31.1036 13.8324C31.1036 13.7507 31.0818 13.671 31.0413 13.6009C31.0008 13.5302 30.9421 13.4718 30.872 13.4313C30.8644 13.4269 30.8564 13.4225 30.8484 13.4186L20.1868 7.27759C20.179 7.27231 20.1714 7.2677 20.165 7.26392L20.1644 7.26357C20.0937 7.22258 20.0136 7.20154 19.9325 7.20154H19.9306C19.8495 7.20154 19.77 7.22298 19.701 7.26236C19.692 7.26736 19.6837 7.2726 19.6762 7.27766L9.01204 13.4202C9.00655 13.423 8.99886 13.4269 8.99144 13.4311C8.92049 13.4717 8.86173 13.5311 8.82123 13.6011C8.78067 13.6712 8.75879 13.751 8.75879 13.8328C8.75879 13.8425 8.75919 13.8514 8.75956 13.858V26.1419C8.75876 26.153 8.75878 26.1629 8.75879 26.1679L8.75879 26.1693C8.75879 26.2509 8.78058 26.3312 8.82193 26.4019C8.86238 26.4712 8.92065 26.5297 8.98909 26.5693L8.99047 26.5701L8.99187 26.5709C9.00023 26.5755 9.00781 26.5793 9.01357 26.5822L19.6768 32.7242C19.6818 32.7275 19.6906 32.7333 19.7001 32.7386ZM30.8023 26.4503C30.7969 26.4534 30.7908 26.4564 30.7847 26.4595C30.788 26.4578 30.7915 26.4561 30.7946 26.4545C30.7973 26.4531 30.7999 26.4517 30.8023 26.4503ZM30.9649 26.1472C30.9651 26.1495 30.9652 26.152 30.9653 26.1545C30.9655 26.159 30.9657 26.1637 30.9657 26.1685C30.9657 26.167 30.9657 26.1655 30.9656 26.164C30.9655 26.1581 30.9652 26.1524 30.9649 26.1472ZM20.3983 26.9772L24.3592 29.2549L20.3951 31.538L20.3983 26.9772ZM15.5034 29.2548L19.4645 26.977L19.4676 31.538L15.5034 29.2548ZM25.7528 23.8924L29.7137 26.1701L25.7497 28.4532L25.7528 23.8924ZM20.3984 25.3628V20.8084L24.3514 23.0856L20.3984 25.3628ZM15.0439 22.2784V17.724L18.9969 20.0012L15.0439 22.2784ZM20.3984 19.1932V14.6389L24.3514 16.916L20.3984 19.1932ZM19.4649 13.0247L15.5038 10.7468L19.468 8.46363L19.4649 13.0247ZM10.1491 26.1701L14.1131 28.4532L14.11 23.8922L10.1491 26.1701ZM24.8194 23.8928L24.8225 28.4527L20.8658 26.1705L24.8194 23.8928ZM15.0438 23.8928L15.0406 28.4527L18.9974 26.1705L15.0438 23.8928ZM26.2198 23.0856L30.178 20.8025V25.3687L26.2198 23.0856ZM9.68483 20.8025V25.3687L13.643 23.0856L9.68483 20.8025ZM15.5111 23.085L19.4644 25.3624V20.8077L15.5111 23.085ZM29.7125 20.0004L25.7529 17.7227V22.2782L29.7125 20.0004ZM10.1503 20.0008L14.1099 22.2786V17.7231L10.1503 20.0008ZM24.8189 17.7233V22.278L20.8656 20.0006L24.8189 17.7233ZM30.178 14.6329V19.1992L26.2198 16.916L30.178 14.6329ZM13.643 16.916L9.68483 19.1992V14.6329L13.643 16.916ZM19.4644 14.6385V19.1928L15.5114 16.9157L19.4644 14.6385ZM29.7141 13.8315L25.7497 11.5482L25.7528 16.1095L29.7141 13.8315ZM14.1135 11.5484L14.1104 16.1095L10.1491 13.8315L14.1135 11.5484ZM24.8225 11.549L24.8194 16.1089L20.8658 13.8312L24.8225 11.549ZM18.9974 13.8312L15.0438 16.1089L15.0406 11.549L18.9974 13.8312ZM24.3596 10.7467L20.3951 8.46362L20.3983 13.0247L24.3596 10.7467Z" fill="white"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_291_2793">
|
||||
<rect width="40" height="40" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 3.3 KiB |
@ -2,20 +2,54 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:ital,opsz,wght@0,14..32,100..900;1,14..32,100..900&display=swap');
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
@layer base {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--background: 0 0% 0%;
|
||||
--foreground: 0 0% 100%;
|
||||
--accent: 154 98% 73%;
|
||||
}
|
||||
|
||||
body {
|
||||
color: var(--foreground);
|
||||
background: var(--background);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: "Inter", sans-serif;
|
||||
font-optical-sizing: auto;
|
||||
font-style: normal;
|
||||
}
|
||||
}
|
||||
|
||||
.font-thin {
|
||||
font-weight: 100;
|
||||
}
|
||||
|
||||
.font-extralight {
|
||||
font-weight: 200;
|
||||
}
|
||||
|
||||
.font-light {
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
.font-normal {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.font-medium {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.font-semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.font-bold {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.font-extrabold {
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.font-black {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
@ -7,11 +7,13 @@ export default {
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
fontFamily: {
|
||||
sans: ["Inter", "sans-serif"],
|
||||
},
|
||||
colors: {
|
||||
background: "var(--background)",
|
||||
foreground: "var(--foreground)",
|
||||
},
|
||||
accent: "#7afbaf",
|
||||
},
|
||||
}
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user