Files
IPFS-portal/src/app/dashboard/page.tsx
T
maikrolf 1ddc89479c feat: initial IPFS Portal — Next.js frontend for remote IPFS node management
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.
2026-06-25 19:47:57 +02:00

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>
);
}