Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,18 @@ All notable user-facing changes to this project will be documented in this file.
- Direct Cloudinary image upload from Django admin for featured content (ce4c157)
- Responsive hero banner images for tablet and mobile (e5c01b5)

## 2026-04-01 — Per-Network Validator Leaderboards

### Added
- Network tabs (Asimov | Bradbury) on `/validators/leaderboard` page with lazy-loading per tab
- Deep-links from testnets overview cards to the matching leaderboard tab
- Validator status breakdown on testnets page: active, quarantined, banned, inactive counts per network
- URL query param support (`?network=asimov`) for direct-linking to a network tab

### Changed
- Testnets overview now shows total validator count from wallet API `network_stats` instead of leaderboard entry count
- Leaderboard API calls on testnets overview limited to top 5 (was fetching all entries)

## 2026-04-01 — Fix Overview Leaderboards

### Fixed
Expand Down
4 changes: 2 additions & 2 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

60 changes: 45 additions & 15 deletions frontend/src/components/GlobalDashboard.svelte
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
<script>
import { onMount } from "svelte";
import { push } from "svelte-spa-router";
import { format } from "date-fns";
import TopLeaderboard from "./TopLeaderboard.svelte";
import CategoryIcon from "./portal/CategoryIcon.svelte";
import { leaderboardAPI, validatorsAPI } from "../lib/api";
import { showError } from "../lib/toastStore";

// State management
let networkStats = $state({ asimov: { total: 0 }, bradbury: { total: 0 } });
let networkStats = $state({
asimov: { total: 0, active: 0, quarantined: 0, banned: 0, inactive: 0 },
bradbury: { total: 0, active: 0, quarantined: 0, banned: 0, inactive: 0 },
});
let networks = $state([]);
let asimovLeaderboard = $state([]);
let bradburyLeaderboard = $state([]);
Expand All @@ -25,26 +26,30 @@
bradburyLeaderboardRes,
waitlistLeaderboardRes,
networksRes,
walletsRes,
] = await Promise.all([
leaderboardAPI.getLeaderboardByType("validator", "asc", {
network: "asimov",
limit: 5,
}),
leaderboardAPI.getLeaderboardByType("validator", "asc", {
network: "bradbury",
limit: 5,
}),
leaderboardAPI.getWaitlistTop(5),
validatorsAPI.getNetworks().catch(() => ({ data: [] })),
validatorsAPI.getAllValidatorWallets().catch(() => ({ data: { network_stats: {} } })),
]);

// Process leaderboards — full list for count, top 5 for display
const asimovFull = Array.isArray(asimovLeaderboardRes.data)
// Process leaderboards — top 5 for display (limit:5 sent to API)
const asimovData = Array.isArray(asimovLeaderboardRes.data)
? asimovLeaderboardRes.data
: [];
const bradburyFull = Array.isArray(bradburyLeaderboardRes.data)
const bradburyData = Array.isArray(bradburyLeaderboardRes.data)
? bradburyLeaderboardRes.data
: [];
asimovLeaderboard = asimovFull.slice(0, 5).map((entry, i) => ({ ...entry, rank: i + 1 }));
bradburyLeaderboard = bradburyFull.slice(0, 5).map((entry, i) => ({ ...entry, rank: i + 1 }));
asimovLeaderboard = asimovData.map((entry, i) => ({ ...entry, rank: i + 1 }));
bradburyLeaderboard = bradburyData.map((entry, i) => ({ ...entry, rank: i + 1 }));
waitlistLeaderboard = Array.isArray(waitlistLeaderboardRes.data)
? waitlistLeaderboardRes.data
: [];
Expand All @@ -66,10 +71,11 @@
bradbury.explorer_url = "https://explorer.testnet-chain.genlayer.com/";
networks = [asimov, bradbury];

// Validator count per network from leaderboard entries (active on-chain validators)
// Validator counts from wallet API (all on-chain wallets, not just profiled ones)
const walletStats = walletsRes.data?.network_stats || {};
networkStats = {
asimov: { total: asimovFull.length },
bradbury: { total: bradburyFull.length },
asimov: walletStats.asimov || { total: 0, active: 0, quarantined: 0, banned: 0, inactive: 0 },
bradbury: walletStats.bradbury || { total: 0, active: 0, quarantined: 0, banned: 0, inactive: 0 },
};

loading = false;
Expand Down Expand Up @@ -240,7 +246,19 @@
style="letter-spacing: -0.96px;"
>{networkStats.asimov.total}</span
>
<span class="text-[13px] text-gray-500">Active Validators</span>
<span class="text-[13px] text-gray-500">Total Validators</span>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2">
<span class="text-[12px] text-green-600 font-medium">{networkStats.asimov.active} active</span>
{#if networkStats.asimov.quarantined > 0}
<span class="text-[12px] text-yellow-600">{networkStats.asimov.quarantined} quarantined</span>
{/if}
{#if networkStats.asimov.banned > 0}
<span class="text-[12px] text-red-600">{networkStats.asimov.banned} banned</span>
{/if}
{#if networkStats.asimov.inactive > 0}
<span class="text-[12px] text-gray-400">{networkStats.asimov.inactive} inactive</span>
{/if}
</div>
</div>

Expand All @@ -251,7 +269,7 @@
Top Asimov Validators
</h3>
<button
onclick={() => push("/validators/leaderboard")}
onclick={() => push("/validators/leaderboard?network=asimov")}
class="text-[12px] text-gray-500 hover:text-[#2563eb] transition-colors"
>Leaderboard →</button
>
Expand Down Expand Up @@ -368,7 +386,19 @@
style="letter-spacing: -0.96px;"
>{networkStats.bradbury.total}</span
>
<span class="text-[13px] text-gray-500">Active Validators</span>
<span class="text-[13px] text-gray-500">Total Validators</span>
</div>
<div class="flex flex-wrap gap-x-4 gap-y-1 mt-2">
<span class="text-[12px] text-green-600 font-medium">{networkStats.bradbury.active} active</span>
{#if networkStats.bradbury.quarantined > 0}
<span class="text-[12px] text-yellow-600">{networkStats.bradbury.quarantined} quarantined</span>
{/if}
{#if networkStats.bradbury.banned > 0}
<span class="text-[12px] text-red-600">{networkStats.bradbury.banned} banned</span>
{/if}
{#if networkStats.bradbury.inactive > 0}
<span class="text-[12px] text-gray-400">{networkStats.bradbury.inactive} inactive</span>
{/if}
</div>
</div>

Expand All @@ -379,7 +409,7 @@
Top Bradbury Validators
</h3>
<button
onclick={() => push("/validators/leaderboard")}
onclick={() => push("/validators/leaderboard?network=bradbury")}
class="text-[12px] text-gray-500 hover:text-[#0284c7] transition-colors"
>Leaderboard →</button
>
Expand Down
70 changes: 59 additions & 11 deletions frontend/src/routes/Leaderboard.svelte
Original file line number Diff line number Diff line change
@@ -1,10 +1,20 @@
<script>
import { onMount } from 'svelte';
import LeaderboardTable from '../components/LeaderboardTable.svelte';
import { leaderboardAPI } from '../lib/api';
import { replace } from 'svelte-spa-router';
import { currentCategory, categoryTheme } from '../stores/category.js';

const PAGE_SIZE = 50;
const VALID_NETWORKS = ['asimov', 'bradbury'];

// Eager init from URL hash to avoid $effect race condition on deep-link
let selectedNetwork = $state(
(() => {
const p = new URLSearchParams(window.location.hash.split('?')[1] || '');
const n = p.get('network');
return VALID_NETWORKS.includes(n) ? n : 'asimov';
})()
);

// State management
let leaderboard = $state([]);
Expand All @@ -25,7 +35,7 @@
})
);

// Fetch data based on category
// Fetch data based on category and network
async function fetchLeaderboard() {
try {
loading = true;
Expand All @@ -35,12 +45,19 @@
let response;
if ($currentCategory === 'global') {
response = await leaderboardAPI.getLeaderboard({ limit: PAGE_SIZE, offset: 0 });
} else if ($currentCategory === 'validator') {
response = await leaderboardAPI.getLeaderboardByType('validator', 'asc', { network: selectedNetwork, limit: PAGE_SIZE, offset: 0 });
} else {
response = await leaderboardAPI.getLeaderboardByType($currentCategory, 'asc', { limit: PAGE_SIZE, offset: 0 });
}

const data = response.data || [];
leaderboard = data;
// Re-rank client-side for per-network filtering (backend returns global ranks)
if ($currentCategory === 'validator') {
leaderboard = data.map((entry, i) => ({ ...entry, rank: i + 1 }));
} else {
leaderboard = data;
}
offset = data.length;
hasMore = data.length >= PAGE_SIZE;
} catch (err) {
Expand All @@ -57,12 +74,18 @@
let response;
if ($currentCategory === 'global') {
response = await leaderboardAPI.getLeaderboard({ limit: PAGE_SIZE, offset });
} else if ($currentCategory === 'validator') {
response = await leaderboardAPI.getLeaderboardByType('validator', 'asc', { network: selectedNetwork, limit: PAGE_SIZE, offset });
} else {
response = await leaderboardAPI.getLeaderboardByType($currentCategory, 'asc', { limit: PAGE_SIZE, offset });
}

const data = response.data || [];
leaderboard = [...leaderboard, ...data];
// Re-rank full array for validators (per-network ranks)
if ($currentCategory === 'validator') {
leaderboard = leaderboard.map((e, i) => ({ ...e, rank: i + 1 }));
}
offset += data.length;
hasMore = data.length >= PAGE_SIZE;
} catch (err) {
Expand All @@ -72,14 +95,16 @@
}
}

// Fetch on mount and when category changes
onMount(() => {
fetchLeaderboard();
});
function selectNetwork(network) {
selectedNetwork = network;
replace(`/validators/leaderboard?network=${network}`);
}

// Re-fetch when category changes
// Single effect: re-fetch when category or network changes
$effect(() => {
if ($currentCategory) {
const cat = $currentCategory;
const net = selectedNetwork;
if (cat) {
fetchLeaderboard();
}
});
Expand All @@ -101,6 +126,7 @@
$currentCategory === 'steward' ? 'stewards' : 'participants'}
</p>
</div>


{#if !loading && !error && leaderboard.length > 0}
<div class="w-full sm:w-64">
Expand All @@ -122,7 +148,25 @@
</div>
{/if}
</div>


{#if $currentCategory === 'validator'}
<div class="flex gap-2" role="tablist" aria-label="Network filter">
{#each VALID_NETWORKS as net}
<button
role="tab"
aria-selected={selectedNetwork === net}
class="px-4 py-1.5 rounded-full text-sm font-medium transition-colors
{selectedNetwork === net
? 'bg-blue-600 text-white'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200'}"
onclick={() => selectNetwork(net)}
>
{net.charAt(0).toUpperCase() + net.slice(1)}
</button>
{/each}
</div>
{/if}

{#if loading}
<div class="flex justify-center items-center p-12">
<div class="animate-spin rounded-full h-12 w-12 border-b-2 border-primary-600"></div>
Expand All @@ -147,6 +191,10 @@
<div class="p-6 text-center text-gray-500">
No participants found matching "{searchQuery}"
</div>
{:else if !searchQuery && leaderboard.length === 0 && $currentCategory === 'validator'}
<div class="p-8 text-center text-gray-500">
<p class="text-sm">No validators on {selectedNetwork.charAt(0).toUpperCase() + selectedNetwork.slice(1)} yet.</p>
</div>
{:else}
<div class="px-4 py-3 border-b border-gray-200">
<p class="text-sm text-gray-700">
Expand Down