1ddc89479c
Multi-file upload with Quick Upload + Crypto Payment tabs. Dashboard with live IPFS metrics, Explorer, Peers, Pins, History, Settings pages. Docker deploy (standalone output), full test suite (16 Playwright tests). Payment integration via IPFSPortalPayment + MockUSDC contracts on zkSync.
254 lines
11 KiB
TypeScript
254 lines
11 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import { getPeers, listPins, checkHealth, getRepoStats, getBWStats } from '@/lib/api';
|
|
import type { RepoStats, BWStats } from '@/lib/api';
|
|
import PortalLayout from '@/app/layout-portal';
|
|
import Link from 'next/link';
|
|
import {
|
|
HardDrive,
|
|
Globe,
|
|
Wifi,
|
|
Database,
|
|
Server,
|
|
ArrowUpRight,
|
|
ArrowDownLeft,
|
|
BarChart3,
|
|
Fingerprint,
|
|
} from 'lucide-react';
|
|
|
|
interface PeerData { id: string; addr: string; latency: string }
|
|
interface PinData { cid: string; name: string; size: number }
|
|
|
|
export default function DashboardPage() {
|
|
const [peers, setPeers] = useState<PeerData[]>([]);
|
|
const [pins, setPins] = useState<PinData[]>([]);
|
|
const [health, setHealth] = useState<{ status: string; node: string } | null>(null);
|
|
const [repo, setRepo] = useState<RepoStats | null>(null);
|
|
const [bw, setBW] = useState<BWStats | null>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
useEffect(() => {
|
|
async function load() {
|
|
try {
|
|
const [h, p, pinList, r, b] = await Promise.all([
|
|
checkHealth().catch(() => null),
|
|
getPeers().catch(() => [] as PeerData[]),
|
|
listPins().catch(() => [] as PinData[]),
|
|
getRepoStats().catch(() => null),
|
|
getBWStats().catch(() => null),
|
|
]);
|
|
setHealth(h);
|
|
setPeers(p);
|
|
setPins(pinList);
|
|
setRepo(r);
|
|
setBW(b);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
}
|
|
load();
|
|
const iv = setInterval(load, 15000);
|
|
return () => clearInterval(iv);
|
|
}, []);
|
|
|
|
const repoSizeGb = repo ? (repo.repoSize / 1e9).toFixed(2) : '—';
|
|
const bwIn = bw ? (bw.totalIn / 1e6).toFixed(1) : '—';
|
|
const bwOut = bw ? (bw.totalOut / 1e6).toFixed(1) : '—';
|
|
|
|
const stats = [
|
|
{ label: 'Peers', value: peers.length, icon: Wifi, color: 'text-accent-cyan', bg: 'bg-accent-cyan/10' },
|
|
{ label: 'Pins', value: pins.length, icon: HardDrive, color: 'text-accent-purple', bg: 'bg-accent-purple/10' },
|
|
{ label: 'Node', value: health?.status ?? '—', icon: Server, color: 'text-accent-green', bg: 'bg-accent-green/10' },
|
|
{ label: 'Repo', value: `${repoSizeGb} GB`, icon: Database, color: 'text-accent-amber', bg: 'bg-accent-amber/10' },
|
|
];
|
|
|
|
return (
|
|
<PortalLayout>
|
|
{/* Page header */}
|
|
<div className="mb-8 flex items-center justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold text-white">Dashboard</h1>
|
|
<p className="text-sm text-surface-400 mt-1">IPFS node overview, storage, and bandwidth</p>
|
|
</div>
|
|
{loading && (
|
|
<div className="flex items-center gap-2 text-xs text-surface-500">
|
|
<div className="w-3 h-3 rounded-full border border-surface-600 border-t-transparent animate-spin" />
|
|
refreshing…
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Stat cards */}
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
|
{stats.map((s) => (
|
|
<div key={s.label} className="glass rounded-xl p-5 animate-fade-in">
|
|
<div className="flex items-center justify-between mb-3">
|
|
<div className={`p-2 rounded-lg ${s.bg}`}>
|
|
<s.icon className={`w-4 h-4 ${s.color}`} />
|
|
</div>
|
|
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500" />
|
|
</div>
|
|
<div className="text-2xl font-bold text-white">{s.value}</div>
|
|
<div className="text-xs text-surface-400 mt-1">{s.label}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Row 2: Peer + Bandwidth */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6 mb-6">
|
|
{/* Peers list */}
|
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center justify-between">
|
|
<span className="flex items-center gap-2"><Wifi className="w-4 h-4 text-accent-cyan" />Connected Peers</span>
|
|
<span className="text-xs text-surface-500">{peers.length} peer{peers.length !== 1 ? 's' : ''}</span>
|
|
</h2>
|
|
{peers.length === 0 ? (
|
|
<p className="text-sm text-surface-500">No peers connected</p>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
|
{peers.slice(0, 20).map((p) => (
|
|
<div key={p.id} className="flex items-center justify-between py-1.5 border-b border-surface-800/50 last:border-0">
|
|
<div className="truncate max-w-[180px] text-sm text-surface-300 font-mono text-[11px]">
|
|
{p.id.slice(0, 20)}…
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-[11px] text-surface-500">{p.latency}</span>
|
|
<span className="w-1.5 h-1.5 rounded-full bg-accent-green" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-3 pt-3 border-t border-surface-800">
|
|
<Link href="/peers" className="text-xs text-brand-400 hover:text-brand-300 transition-colors">
|
|
View all peers →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bandwidth */}
|
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
|
<BarChart3 className="w-4 h-4 text-accent-amber" />
|
|
Bandwidth
|
|
</h2>
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
|
|
<div className="flex items-center gap-2">
|
|
<ArrowDownLeft className="w-4 h-4 text-accent-green" />
|
|
<span className="text-xs text-surface-400">In</span>
|
|
</div>
|
|
<span className="text-sm font-semibold text-surface-200">{bwIn} MB</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-3 rounded-lg bg-surface-900/50 border border-surface-800">
|
|
<div className="flex items-center gap-2">
|
|
<ArrowUpRight className="w-4 h-4 text-accent-rose" />
|
|
<span className="text-xs text-surface-400">Out</span>
|
|
</div>
|
|
<span className="text-sm font-semibold text-surface-200">{bwOut} MB</span>
|
|
</div>
|
|
<div className="text-xs text-surface-500 text-center">Total since node start</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* IPNS quick */}
|
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
|
<Fingerprint className="w-4 h-4 text-accent-purple" />
|
|
IPNS Names
|
|
</h2>
|
|
<p className="text-xs text-surface-400 mb-4">
|
|
Publish CIDs to human-readable IPNS names, or resolve existing names.
|
|
</p>
|
|
<div className="flex flex-col gap-2">
|
|
<Link
|
|
href="/ipns"
|
|
className="flex items-center justify-between px-3 py-2.5 rounded-lg bg-surface-900/50 border border-surface-800 hover:border-surface-700 text-sm text-surface-300 hover:text-white transition-colors"
|
|
>
|
|
<span>Manage IPNS Keys</span>
|
|
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500" />
|
|
</Link>
|
|
<Link
|
|
href="/explorer"
|
|
className="flex items-center justify-between px-3 py-2.5 rounded-lg bg-surface-900/50 border border-surface-800 hover:border-surface-700 text-sm text-surface-300 hover:text-white transition-colors"
|
|
>
|
|
<span>Browse Explorer</span>
|
|
<ArrowUpRight className="w-3.5 h-3.5 text-surface-500" />
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Row 3: Pins + Repo details */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
|
{/* Pins */}
|
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center justify-between">
|
|
<span className="flex items-center gap-2"><HardDrive className="w-4 h-4 text-accent-purple" />Recent Pins</span>
|
|
<span className="text-xs text-surface-500">{pins.length} total</span>
|
|
</h2>
|
|
{pins.length === 0 ? (
|
|
<p className="text-sm text-surface-500">No pins yet — upload or add content to get started.</p>
|
|
) : (
|
|
<div className="space-y-1.5 max-h-56 overflow-y-auto">
|
|
{pins.slice(0, 8).map((pin) => (
|
|
<div key={pin.cid} className="flex items-center justify-between py-1.5 border-b border-surface-800/50 last:border-0">
|
|
<div className="truncate max-w-[240px] text-sm text-surface-300 font-mono text-[11px]">
|
|
{pin.name || pin.cid.slice(0, 24) + '…'}
|
|
</div>
|
|
<div className="text-[11px] text-surface-500">
|
|
{(pin.size / 1024).toFixed(1)} KB
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<div className="mt-3 pt-3 border-t border-surface-800 flex gap-3">
|
|
<Link href="/pins" className="text-xs text-brand-400 hover:text-brand-300 transition-colors">
|
|
Manage pins →
|
|
</Link>
|
|
<Link href="/upload" className="text-xs text-brand-400 hover:text-brand-300 transition-colors">
|
|
Upload new →
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Repo Details */}
|
|
<div className="glass rounded-xl p-5 animate-fade-in">
|
|
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
|
|
<Database className="w-4 h-4 text-accent-amber" />
|
|
Repository
|
|
</h2>
|
|
{repo ? (
|
|
<div className="space-y-3">
|
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
|
<span className="text-xs text-surface-400">Size</span>
|
|
<span className="text-xs text-surface-200 font-mono">{repoSizeGb} GB</span>
|
|
</div>
|
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
|
<span className="text-xs text-surface-400">Objects</span>
|
|
<span className="text-xs text-surface-200">{repo.numObjects.toLocaleString()}</span>
|
|
</div>
|
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
|
<span className="text-xs text-surface-400">Storage Max</span>
|
|
<span className="text-xs text-surface-200">{(repo.storageMax / 1e9).toFixed(1)} GB</span>
|
|
</div>
|
|
<div className="flex justify-between py-2 border-b border-surface-800/50">
|
|
<span className="text-xs text-surface-400">Version</span>
|
|
<span className="text-xs text-surface-200 font-mono">{repo.version || health?.node || '—'}</span>
|
|
</div>
|
|
<div className="flex justify-between py-2">
|
|
<span className="text-xs text-surface-400">Path</span>
|
|
<span className="text-xs text-surface-500 font-mono truncate max-w-[200px]" title={repo.repoPath}>{repo.repoPath}</span>
|
|
</div>
|
|
</div>
|
|
) : (
|
|
<p className="text-sm text-surface-500">Repo stats unavailable (Kubo may not expose /api/v0/repo/stat via gateway).</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</PortalLayout>
|
|
);
|
|
}
|