Skip to content
Merged
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
22 changes: 15 additions & 7 deletions apps/scan/app/[locale]/HomeContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { useTranslations } from "next-intl";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import { Link } from "@/i18n/navigation";
import { Blocks, ArrowUpDown, Search, Clock, Loader2 } from "lucide-react";
import { Blocks, ArrowUpDown, Search, Clock, Loader2, AlertTriangle } from "lucide-react";
import { toast } from "sonner";
import { RailBadge, classifyRail, type Rail } from "@/components/common/RailBadge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
Expand Down Expand Up @@ -90,7 +90,7 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
// most often (sent SRX → didn't show up under EVM scan, etc.) so they
// get top billing in the pill row.
const [railFilter, setRailFilter] = useState<"all" | Rail>("all");
const { data: stats, loading: statsLoading, refetch: refetchStats } = useStats(network, initial.stats);
const { data: stats, loading: statsLoading, error: statsError, refetch: refetchStats, retry: retryStats } = useStats(network, initial.stats);
const { data: blocks, loading: blocksLoading, refetch: refetchBlocks } = useBlocks(network, 10, initial.blocks);
const { data: txs, loading: txsLoading, refetch: refetchTxs } = useTransactions(network, 10, initial.txs);
// Live block height via WebSocket. newHeads (proposed) + sentrix_finalized
Expand Down Expand Up @@ -261,13 +261,13 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
fixed header (top-16 = h-16 of header). */}
<StickyStatsBar />
{(isChainIdle || chainUnreachable) && (
<div className="border-b border-[var(--orange)]/30 bg-[color-mix(in_oklab,var(--orange)_8%,transparent)]">
<div className="max-w-7xl mx-auto px-4 lg:px-6 py-2 flex items-center gap-3 text-[11px]">
<span className="w-1.5 h-1.5 rounded-full bg-[var(--orange)] animate-pulse-live" />
<span className="font-mono uppercase tracking-[.15em] text-[var(--orange)]">
<div role="alert" className="border-y-2 border-[var(--orange)]/60 bg-[color-mix(in_oklab,var(--orange)_16%,transparent)]">
<div className="max-w-7xl mx-auto px-4 lg:px-6 py-3 flex items-center gap-3 text-[13px]">
<AlertTriangle className="h-4 w-4 text-[var(--orange)] shrink-0" />
<span className="font-mono uppercase tracking-[.15em] text-[var(--orange)] font-semibold shrink-0">
{chainUnreachable ? "RPC offline" : `${network === "testnet" ? "Testnet" : "Chain"} paused`}
</span>
<span className="font-mono text-[var(--tx-m)]">
<span className="font-mono text-[var(--foreground)]">
{chainUnreachable
? "Couldn't reach the chain RPC — retrying. Operators are aware."
: latestBlockAgeSec !== null
Expand Down Expand Up @@ -382,13 +382,17 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
label={t("stats.active_validators")}
value={stats ? String(stats.active_validators) : "—"}
loading={statsLoading}
error={statsError}
onRetry={retryStats}
accent="var(--gold)"
subline={t("stats.subline_active_bft")}
/>
<StatCard
label={t("stats.tokens_deployed")}
value={stats ? String(stats.deployed_tokens) : "—"}
loading={statsLoading}
error={statsError}
onRetry={retryStats}
accent="var(--gold-l)"
subline={t("stats.subline_src20_contracts")}
/>
Expand All @@ -397,13 +401,17 @@ export function HomeContent({ initial }: { initial: HomeBundle }) {
value={stats ? formatBurnedSrx(stats.total_burned_srx) : "—"}
title={stats ? `${stats.total_burned_srx.toLocaleString(undefined, { maximumFractionDigits: 8 })} SRX` : undefined}
loading={statsLoading}
error={statsError}
onRetry={retryStats}
accent="var(--red)"
subline={t("stats.subline_fee_burn")}
/>
<StatCard
label={t("stats.block_reward")}
value={stats ? `${stats.next_block_reward_srx} SRX` : "—"}
loading={statsLoading}
error={statsError}
onRetry={retryStats}
accent="var(--gold)"
subline={t("stats.subline_claimable")}
/>
Expand Down
23 changes: 21 additions & 2 deletions apps/scan/app/[locale]/accounts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
"use client";

import { useEffect, useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { useSearchParams, useRouter, usePathname } from "next/navigation";
import { Users } from "lucide-react";
import { Users, AlertTriangle, RotateCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Address } from "@/components/common/Address";
Expand All @@ -20,6 +21,7 @@ const PAGE_SIZE = 25;
// registry (`lib/labels.tsx`) so premine wallets, validator hosts, and
// SentrixSafe surface their human names automatically.
export default function AccountsPage() {
const tc = useTranslations("common");
const { network } = useNetwork();
useNetworkFromQuery();
const searchParams = useSearchParams();
Expand All @@ -41,7 +43,7 @@ export default function AccountsPage() {
setPageState(fresh);
}, [searchParams]);

const { data, loading } = useAccountsTop(network, 100);
const { data, loading, error, retry } = useAccountsTop(network, 100);
const totalPages = useMemo(
() => Math.max(1, Math.ceil((data?.length ?? 0) / PAGE_SIZE)),
[data],
Expand Down Expand Up @@ -80,6 +82,23 @@ export default function AccountsPage() {
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
) : error ? (
<EmptyState
tone="warn"
icon={AlertTriangle}
title={tc("failed_to_load")}
hint={tc("failed_to_load_hint")}
action={
<button
type="button"
onClick={retry}
className="inline-flex items-center gap-1.5 text-sm font-mono text-[var(--orange)] hover:opacity-80 transition-opacity"
>
<RotateCw className="h-3.5 w-3.5" />
{tc("retry")}
</button>
}
/>
) : paged.length > 0 ? (
<>
<div className="overflow-x-auto">
Expand Down
7 changes: 1 addition & 6 deletions apps/scan/app/[locale]/analytics/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { PageHeader } from "@/components/common/PageHeader";
import { StatCard } from "@/components/common/StatCard";
import { EmptyState } from "@/components/common/EmptyState";
import { useNetwork, useNetworkFromQuery } from "@/lib/network-context";
import { useStats, useChainPerformance, useValidators } from "@/lib/hooks";
import { formatNumber, formatSRX } from "@/lib/format";
Expand Down Expand Up @@ -94,11 +93,7 @@ export default function AnalyticsPage() {

{/* Charts */}
{!perf && !daily ? (
<Card>
<CardContent>
<EmptyState icon={Activity} title="Loading analytics…" hint="Gathering daily activity and performance data." />
</CardContent>
</Card>
<Skeleton className="h-80 w-full" />
) : (
<AnalyticsCharts perf={perf} daily={daily} validatorShare={validatorShare} />
)}
Expand Down
31 changes: 25 additions & 6 deletions apps/scan/app/[locale]/validators/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,14 @@
import { useMemo, useState } from "react";
import { useTranslations } from "next-intl";
import { Link } from "@/i18n/navigation";
import { Users, CheckCircle, XCircle, AlertTriangle, ArrowUpDown } from "lucide-react";
import { Users, CheckCircle, XCircle, AlertTriangle, ArrowUpDown, RotateCw } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Address } from "@/components/common/Address";
import { Pagination } from "@/components/common/Pagination";
import { PageHeader } from "@/components/common/PageHeader";
import { StatCard } from "@/components/common/StatCard";
import { EmptyState } from "@/components/common/EmptyState";
import { useNetwork, useNetworkFromQuery } from "@/lib/network-context";
import { useValidators } from "@/lib/hooks";
import { formatNumber } from "@/lib/format";
Expand All @@ -27,10 +28,11 @@ function StatusIcon({ status }: { status?: string }) {

export default function ValidatorsPage() {
const t = useTranslations("validators");
const tc = useTranslations("common");
const { network } = useNetwork();
// Deeplink network switch.
useNetworkFromQuery();
const { data: validators, loading } = useValidators(network);
const { data: validators, loading, error, retry } = useValidators(network);
const [sortKey, setSortKey] = useState<SortKey>("none");
const [sortDir, setSortDir] = useState<"asc" | "desc">("desc");
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
Expand Down Expand Up @@ -111,10 +113,10 @@ export default function ValidatorsPage() {

{/* Summary */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
<StatCard label={t("total_staked")} value={`${formatNumber(summary.totalStake)} SRX`} accent="var(--gold)" />
<StatCard label={t("active")} value={String(summary.active)} accent="var(--green)" />
<StatCard label={t("inactive")} value={String(summary.inactive)} accent="var(--red)" />
<StatCard label={t("jailed")} value={String(summary.jailed)} accent="var(--orange)" />
<StatCard label={t("total_staked")} value={`${formatNumber(summary.totalStake)} SRX`} accent="var(--gold)" loading={loading && !validators} error={error} onRetry={retry} />
<StatCard label={t("active")} value={String(summary.active)} accent="var(--green)" loading={loading && !validators} error={error} onRetry={retry} />
<StatCard label={t("inactive")} value={String(summary.inactive)} accent="var(--red)" loading={loading && !validators} error={error} onRetry={retry} />
<StatCard label={t("jailed")} value={String(summary.jailed)} accent="var(--orange)" loading={loading && !validators} error={error} onRetry={retry} />
</div>

<Card>
Expand Down Expand Up @@ -145,6 +147,23 @@ export default function ValidatorsPage() {
<div className="p-4 space-y-2">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : error ? (
<EmptyState
tone="warn"
icon={AlertTriangle}
title={tc("failed_to_load")}
hint={tc("failed_to_load_hint")}
action={
<button
type="button"
onClick={retry}
className="inline-flex items-center gap-1.5 text-sm font-mono text-[var(--orange)] hover:opacity-80 transition-opacity"
>
<RotateCw className="h-3.5 w-3.5" />
{tc("retry")}
</button>
}
/>
) : paged.length > 0 ? (
<>
{/* Desktop table */}
Expand Down
30 changes: 25 additions & 5 deletions apps/scan/components/common/StatCard.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
"use client";

import type { ReactNode } from "react";
import { TrendingDown, TrendingUp } from "lucide-react";
import { useTranslations } from "next-intl";
import { TrendingDown, TrendingUp, RotateCw } from "lucide-react";
import { Skeleton } from "@/components/ui/skeleton";

// Split a formatted value (e.g. "14.2K", "3.1s", "12 tx", "14,109 SRX") into a number part
Expand All @@ -14,6 +17,11 @@ interface StatCardProps {
label: ReactNode;
value: string;
loading?: boolean;
/** Fetch failed and there's no data to show. Renders a distinct error+retry state,
* not the same dash the card shows for a genuinely empty value. */
error?: boolean;
/** Re-run the failed fetch. When set, the error state shows a retry control. */
onRetry?: () => void;
/** CSS color (e.g. `var(--gold)`, `var(--green)`) applied to the trailing unit and hover glow. */
accent?: string;
/** Title tooltip on the value (useful when long values truncate). */
Expand Down Expand Up @@ -78,7 +86,8 @@ function Sparkline({ data, color }: { data: number[]; color: string }) {
// Playfair serif number, tinted em-unit, animated gold corner lines on hover. One primitive
// so every page (home + detail summary rows) reads from the same vocabulary instead of
// shadcn's grey `text-lg font-semibold font-mono`.
export function StatCard({ label, value, loading = false, accent = "var(--gold)", title, spark, delta, subline }: StatCardProps) {
export function StatCard({ label, value, loading = false, error = false, onRetry, accent = "var(--gold)", title, spark, delta, subline }: StatCardProps) {
const tc = useTranslations("common");
const { num, unit } = splitValue(value);
const showDelta = delta != null && Number.isFinite(delta) && Math.abs(delta) >= 0.05;
const deltaUp = (delta ?? 0) >= 0;
Expand All @@ -105,7 +114,7 @@ export function StatCard({ label, value, loading = false, accent = "var(--gold)"
<div className="font-mono text-[10px] text-[var(--tx-d)] tracking-[.12em] sm:tracking-[.22em] uppercase group-hover:text-[var(--tx-m)] transition-colors truncate flex-1">
{label}
</div>
{showDelta && !loading && (
{showDelta && !loading && !error && (
<span
className="inline-flex items-center gap-0.5 font-mono text-[10px] tracking-wide rounded-md px-1.5 py-0.5"
style={{
Expand All @@ -126,6 +135,17 @@ export function StatCard({ label, value, loading = false, accent = "var(--gold)"
>
{loading ? (
<Skeleton className="h-9 w-24" />
) : error ? (
<button
type="button"
onClick={onRetry}
disabled={!onRetry}
title={tc("retry")}
className="inline-flex items-center gap-1.5 font-mono text-[15px] leading-none text-[var(--red)] hover:opacity-80 transition-opacity disabled:cursor-default"
>
{tc("failed")}
<RotateCw className="h-3.5 w-3.5" />
</button>
) : (
<>
<span>{num}</span>
Expand All @@ -140,12 +160,12 @@ export function StatCard({ label, value, loading = false, accent = "var(--gold)"
</>
)}
</div>
{subline && !loading && (
{subline && !loading && !error && (
<div className="mt-1.5 text-[11px] font-mono text-[var(--tx-d)] truncate">
{subline}
</div>
)}
{spark && spark.length > 1 && !loading && (
{spark && spark.length > 1 && !loading && !error && (
<div className="mt-3 -mx-1">
<Sparkline data={spark} color={accent} />
</div>
Expand Down
11 changes: 8 additions & 3 deletions apps/scan/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -588,7 +588,9 @@ export async function fetchValidators(network: NetworkId) {
apiFetch<{ validators: RawValidator[] } | RawValidator[]>(network, "/staking/validators"),
apiFetch<{ validators: RawValidator[] } | RawValidator[]>(network, "/validators"),
]);
if (!stakingRes) return [];
// Primary endpoint failed: return null so the hook flags an error state rather than
// rendering an empty validator set. A successful empty response still returns [].
if (stakingRes === null) return null;
const stakingList = Array.isArray(stakingRes) ? stakingRes : (stakingRes.validators ?? []);
const namedList = namedRes
? (Array.isArray(namedRes) ? namedRes : (namedRes.validators ?? []))
Expand Down Expand Up @@ -1365,11 +1367,14 @@ export async function fetchEventLogs(
}

// ── /accounts/top (real richlist with tx_count) ─────────────────────────────
export async function fetchAccountsTop(network: NetworkId, limit = 100): Promise<TopHolder[]> {
export async function fetchAccountsTop(network: NetworkId, limit = 100): Promise<TopHolder[] | null> {
const res = await apiFetch<{
accounts: Array<{ address: string; balance_srx: number; percentage: number; tx_count?: number; name?: string | null }>;
}>(network, `/accounts/top?limit=${limit}`);
if (!res?.accounts) return [];
// Keep a failed fetch (null) separate from a successful empty list ([]). Collapsing
// both to [] made an outage look like a genuinely empty richlist.
if (res === null) return null;
if (!res.accounts) return [];
return res.accounts.map((a, i) => ({
rank: i + 1,
address: a.address,
Expand Down
3 changes: 3 additions & 0 deletions apps/scan/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@
},
"common": {
"loading": "Loading...",
"failed": "Failed",
"failed_to_load": "Failed to load",
"failed_to_load_hint": "Couldn't fetch data from the server. Try again.",
"retry": "Retry",
"go_home": "Go home",
"try_again": "Try again",
Expand Down
3 changes: 3 additions & 0 deletions apps/scan/messages/id.json
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,9 @@
},
"common": {
"loading": "Memuat...",
"failed": "Gagal",
"failed_to_load": "Gagal memuat data",
"failed_to_load_hint": "Tidak bisa mengambil data dari server. Coba lagi.",
"retry": "Coba Lagi",
"go_home": "Ke Beranda",
"try_again": "Coba Lagi",
Expand Down
Loading