Initial SvelteKit block explorer for Triangles blockchain
Full-featured explorer consuming the daemon REST API: - Home dashboard with chain stats, latest blocks, staking info - Block list with pagination and block detail pages - Transaction detail with inputs/outputs tables - Address lookup with balance, UTXOs, and tx history - Staking dashboard with difficulty chart (Chart.js) - Network info with peer table - Mempool viewer - API documentation page for all 30+ REST endpoints - Search by block hash, height, txid, or address - Dark theme with Tailwind CSS v4 - SSR via adapter-node for SEO Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
||||
TRIANGLES_API_URL=http://127.0.0.1:19112
|
||||
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.svelte-kit
|
||||
build
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
Generated
+2495
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,27 @@
|
||||
{
|
||||
"name": "triangles-explorer",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-node": "^5.5.4",
|
||||
"@sveltejs/kit": "^2.21.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tailwindcss/vite": "^4.0.0",
|
||||
"svelte": "^5.20.0",
|
||||
"svelte-check": "^4.1.0",
|
||||
"tailwindcss": "^4.0.0",
|
||||
"tslib": "^2.8.1",
|
||||
"typescript": "^5.7.0",
|
||||
"vite": "^6.2.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"chart.js": "^4.4.0"
|
||||
}
|
||||
}
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
@theme {
|
||||
--color-tri-bg: #0f1117;
|
||||
--color-tri-surface: #1a1d27;
|
||||
--color-tri-border: #2a2d3a;
|
||||
--color-tri-accent: #6366f1;
|
||||
--color-tri-accent-light: #818cf8;
|
||||
--color-tri-green: #22c55e;
|
||||
--color-tri-red: #ef4444;
|
||||
--color-tri-yellow: #eab308;
|
||||
--color-tri-text: #e2e8f0;
|
||||
--color-tri-muted: #94a3b8;
|
||||
}
|
||||
Vendored
+13
@@ -0,0 +1,13 @@
|
||||
// See https://svelte.dev/docs/kit/types#app
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.ico" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,70 @@
|
||||
import { env } from '$env/dynamic/private';
|
||||
import type {
|
||||
ChainInfo, Block, BlockHeader, Transaction, Difficulty,
|
||||
StakingInfo, MiningInfo, NetworkInfo, PeerInfo,
|
||||
AddressBalance, AddressUtxo, SupplyInfo, ValidationResult
|
||||
} from './types';
|
||||
|
||||
const BASE_URL = env.TRIANGLES_API_URL || 'http://127.0.0.1:19112';
|
||||
|
||||
async function get<T>(path: string): Promise<T> {
|
||||
const res = await fetch(`${BASE_URL}/rest/${path}`);
|
||||
if (!res.ok) {
|
||||
const body = await res.text();
|
||||
throw new Error(`API error ${res.status}: ${body}`);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// Chain info
|
||||
export const getChainInfo = () => get<ChainInfo>('chaininfo');
|
||||
|
||||
// Blocks
|
||||
export const getBlock = (hash: string) => get<Block>(`block/${hash}`);
|
||||
export const getBlockHeader = (hash: string) => get<BlockHeader>(`blockheader/${hash}`);
|
||||
export const getBlockByHeight = (n: number) => get<Block>(`blockbyheight/${n}`);
|
||||
export const getBlockHashByHeight = (n: number) => get<{ blockhash: string }>(`blockhashbyheight/${n}`);
|
||||
|
||||
// Transactions
|
||||
export const getTransaction = (txid: string) => get<Transaction>(`tx/${txid}`);
|
||||
|
||||
// Mempool
|
||||
export const getMempool = () => get<string[]>('mempool');
|
||||
|
||||
// Network
|
||||
export const getDifficulty = () => get<Difficulty>('difficulty');
|
||||
export const getSupply = () => get<SupplyInfo>('supply');
|
||||
export const getStaking = () => get<StakingInfo>('staking');
|
||||
export const getMining = () => get<MiningInfo>('mining');
|
||||
export const getSubsidy = () => get<number>('subsidy');
|
||||
export const getEstimateFee = () => get<number>('estimatefee');
|
||||
export const getCheckpoint = () => get<Record<string, unknown>>('checkpoint');
|
||||
export const getNetwork = () => get<NetworkInfo>('network');
|
||||
export const getPeers = () => get<PeerInfo[]>('peers');
|
||||
|
||||
// Address (requires -addressindex=1)
|
||||
export const getAddressBalance = (addr: string) => get<AddressBalance>(`address/${addr}/balance`);
|
||||
export const getAddressUtxos = (addr: string) => get<AddressUtxo[]>(`address/${addr}/utxos`);
|
||||
export const getAddressTxids = (addr: string, start?: number, end?: number) => {
|
||||
let path = `address/${addr}/txids`;
|
||||
const params: string[] = [];
|
||||
if (start !== undefined) params.push(`start=${start}`);
|
||||
if (end !== undefined) params.push(`end=${end}`);
|
||||
if (params.length) path += '?' + params.join('&');
|
||||
return get<string[]>(path);
|
||||
};
|
||||
|
||||
// Validation
|
||||
export const validateAddress = (addr: string) => get<ValidationResult>(`validate/${addr}`);
|
||||
|
||||
// Helper: get latest N blocks
|
||||
export async function getLatestBlocks(count: number): Promise<Block[]> {
|
||||
const chain = await getChainInfo();
|
||||
const blocks: Block[] = [];
|
||||
for (let i = 0; i < count && chain.blocks - i >= 0; i++) {
|
||||
try {
|
||||
blocks.push(await getBlockByHeight(chain.blocks - i));
|
||||
} catch { break; }
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<script lang="ts">
|
||||
import type { Block } from '$lib/types';
|
||||
import { truncateHash, timeAgo, blockType, formatDifficulty } from '$lib/utils';
|
||||
|
||||
let { blocks }: { blocks: Block[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||
<th class="text-left py-3 px-3">Height</th>
|
||||
<th class="text-left py-3 px-3">Hash</th>
|
||||
<th class="text-left py-3 px-3">Age</th>
|
||||
<th class="text-center py-3 px-3">Txs</th>
|
||||
<th class="text-center py-3 px-3">Type</th>
|
||||
<th class="text-right py-3 px-3">Difficulty</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each blocks as block}
|
||||
<tr class="border-b border-tri-border/50 hover:bg-tri-surface/50 transition-colors">
|
||||
<td class="py-2.5 px-3">
|
||||
<a href="/block/{block.hash}" class="text-tri-accent hover:text-tri-accent-light">
|
||||
{block.height}
|
||||
</a>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 font-mono text-xs">
|
||||
<a href="/block/{block.hash}" class="text-tri-text hover:text-tri-accent-light">
|
||||
{truncateHash(block.hash, 10)}
|
||||
</a>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-tri-muted">{timeAgo(block.time)}</td>
|
||||
<td class="py-2.5 px-3 text-center">{block.tx.length}</td>
|
||||
<td class="py-2.5 px-3 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium {block.flags.includes('proof-of-stake') ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-yellow/20 text-tri-yellow'}">
|
||||
{blockType(block.flags)}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-right font-mono text-xs">{formatDifficulty(block.difficulty)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
let { page, hasNext, basePath }: { page: number; hasNext: boolean; basePath: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="flex items-center justify-between mt-6">
|
||||
{#if page > 1}
|
||||
<a
|
||||
href="{basePath}?page={page - 1}"
|
||||
class="bg-tri-surface border border-tri-border text-tri-text px-4 py-2 rounded-lg text-sm hover:border-tri-accent transition-colors"
|
||||
>
|
||||
Previous
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
|
||||
<span class="text-tri-muted text-sm">Page {page}</span>
|
||||
|
||||
{#if hasNext}
|
||||
<a
|
||||
href="{basePath}?page={page + 1}"
|
||||
class="bg-tri-surface border border-tri-border text-tri-text px-4 py-2 rounded-lg text-sm hover:border-tri-accent transition-colors"
|
||||
>
|
||||
Next
|
||||
</a>
|
||||
{:else}
|
||||
<div></div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,35 @@
|
||||
<script lang="ts">
|
||||
import type { PeerInfo } from '$lib/types';
|
||||
import { timeAgo } from '$lib/utils';
|
||||
|
||||
let { peers }: { peers: PeerInfo[] } = $props();
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||
<th class="text-left py-3 px-3">Address</th>
|
||||
<th class="text-left py-3 px-3">Version</th>
|
||||
<th class="text-center py-3 px-3">Direction</th>
|
||||
<th class="text-right py-3 px-3">Height</th>
|
||||
<th class="text-right py-3 px-3">Connected</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each peers as peer}
|
||||
<tr class="border-b border-tri-border/50 hover:bg-tri-surface/50 transition-colors">
|
||||
<td class="py-2.5 px-3 font-mono text-xs">{peer.addr}</td>
|
||||
<td class="py-2.5 px-3 text-xs text-tri-muted">{peer.subver}</td>
|
||||
<td class="py-2.5 px-3 text-center">
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-medium {peer.inbound ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-accent/20 text-tri-accent-light'}">
|
||||
{peer.inbound ? 'In' : 'Out'}
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-right">{peer.startingheight}</td>
|
||||
<td class="py-2.5 px-3 text-right text-tri-muted">{timeAgo(peer.conntime)}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,37 @@
|
||||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import { isBlockHash, isNumeric, isAddress } from '$lib/utils';
|
||||
|
||||
let query = $state('');
|
||||
|
||||
function handleSearch() {
|
||||
const q = query.trim();
|
||||
if (!q) return;
|
||||
|
||||
if (isNumeric(q)) {
|
||||
goto(`/blocks/${q}`);
|
||||
} else if (isBlockHash(q)) {
|
||||
goto(`/search?q=${q}`);
|
||||
} else if (isAddress(q)) {
|
||||
goto(`/address/${q}`);
|
||||
} else {
|
||||
goto(`/search?q=${q}`);
|
||||
}
|
||||
query = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<form onsubmit={handleSearch} class="flex w-full max-w-xl">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={query}
|
||||
placeholder="Search block hash, height, txid, or address..."
|
||||
class="flex-1 bg-tri-surface border border-tri-border rounded-l-lg px-4 py-2 text-sm text-tri-text placeholder-tri-muted focus:outline-none focus:border-tri-accent"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="bg-tri-accent hover:bg-tri-accent-light text-white px-4 py-2 rounded-r-lg text-sm font-medium transition-colors"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</form>
|
||||
@@ -0,0 +1,11 @@
|
||||
<script lang="ts">
|
||||
let { label, value, sub = '' }: { label: string; value: string; sub?: string } = $props();
|
||||
</script>
|
||||
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg p-4">
|
||||
<div class="text-tri-muted text-xs uppercase tracking-wider mb-1">{label}</div>
|
||||
<div class="text-tri-text text-lg font-semibold">{value}</div>
|
||||
{#if sub}
|
||||
<div class="text-tri-muted text-xs mt-1">{sub}</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,29 @@
|
||||
<script lang="ts">
|
||||
import { truncateHash } from '$lib/utils';
|
||||
|
||||
let { txids, limit = 0 }: { txids: string[]; limit?: number } = $props();
|
||||
const displayed = limit > 0 ? txids.slice(0, limit) : txids;
|
||||
</script>
|
||||
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||
<th class="text-left py-3 px-3">#</th>
|
||||
<th class="text-left py-3 px-3">Transaction ID</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each displayed as txid, i}
|
||||
<tr class="border-b border-tri-border/50 hover:bg-tri-surface/50 transition-colors">
|
||||
<td class="py-2.5 px-3 text-tri-muted">{i + 1}</td>
|
||||
<td class="py-2.5 px-3 font-mono text-xs">
|
||||
<a href="/tx/{txid}" class="text-tri-accent hover:text-tri-accent-light">
|
||||
{truncateHash(txid, 16)}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
@@ -0,0 +1,165 @@
|
||||
export interface ChainInfo {
|
||||
chain: string;
|
||||
blocks: number;
|
||||
bestblockhash: string;
|
||||
difficulty: {
|
||||
'proof-of-work': number;
|
||||
'proof-of-stake': number;
|
||||
};
|
||||
moneysupply: number;
|
||||
}
|
||||
|
||||
export interface Block {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
size: number;
|
||||
height: number;
|
||||
version: number;
|
||||
merkleroot: string;
|
||||
mint: number;
|
||||
time: number;
|
||||
nonce: number;
|
||||
bits: string;
|
||||
difficulty: number;
|
||||
blocktrust: string;
|
||||
chaintrust: string;
|
||||
previousblockhash?: string;
|
||||
nextblockhash?: string;
|
||||
flags: string;
|
||||
proofhash: string;
|
||||
entropybit: number;
|
||||
modifier: string;
|
||||
tx: string[];
|
||||
}
|
||||
|
||||
export interface BlockHeader {
|
||||
hash: string;
|
||||
confirmations: number;
|
||||
height: number;
|
||||
version: number;
|
||||
merkleroot: string;
|
||||
time: number;
|
||||
nonce: number;
|
||||
bits: string;
|
||||
difficulty: number;
|
||||
flags: string;
|
||||
previousblockhash?: string;
|
||||
nextblockhash?: string;
|
||||
}
|
||||
|
||||
export interface Transaction {
|
||||
txid: string;
|
||||
version: number;
|
||||
time: number;
|
||||
locktime: number;
|
||||
vin: TxInput[];
|
||||
vout: TxOutput[];
|
||||
blockhash?: string;
|
||||
confirmations?: number;
|
||||
blocktime?: number;
|
||||
}
|
||||
|
||||
export interface TxInput {
|
||||
txid?: string;
|
||||
vout?: number;
|
||||
scriptSig?: {
|
||||
asm: string;
|
||||
hex: string;
|
||||
};
|
||||
coinbase?: string;
|
||||
sequence: number;
|
||||
}
|
||||
|
||||
export interface TxOutput {
|
||||
value: number;
|
||||
n: number;
|
||||
scriptPubKey: {
|
||||
asm: string;
|
||||
hex: string;
|
||||
reqSigs?: number;
|
||||
type: string;
|
||||
addresses?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export interface Difficulty {
|
||||
'proof-of-work': number;
|
||||
'proof-of-stake': number;
|
||||
'search-interval': number;
|
||||
}
|
||||
|
||||
export interface StakingInfo {
|
||||
enabled: boolean;
|
||||
staking: boolean;
|
||||
errors: string;
|
||||
currentblocksize: number;
|
||||
currentblocktx: number;
|
||||
difficulty: number;
|
||||
'search-interval': number;
|
||||
weight: number;
|
||||
netstakeweight: number;
|
||||
expectedtime: number;
|
||||
}
|
||||
|
||||
export interface MiningInfo {
|
||||
blocks: number;
|
||||
currentblocksize: number;
|
||||
currentblocktx: number;
|
||||
difficulty: number;
|
||||
blockvalue: number;
|
||||
netmhashps: number;
|
||||
netstakeweight: number;
|
||||
errors: string;
|
||||
pooledtx: number;
|
||||
stakedifficulty: number;
|
||||
stakeinterest: number;
|
||||
testnet: boolean;
|
||||
}
|
||||
|
||||
export interface NetworkInfo {
|
||||
version: number;
|
||||
protocolversion: number;
|
||||
connections: number;
|
||||
proxy: string;
|
||||
testnet: boolean;
|
||||
errors: string;
|
||||
}
|
||||
|
||||
export interface PeerInfo {
|
||||
addr: string;
|
||||
services: string;
|
||||
lastsend: number;
|
||||
lastrecv: number;
|
||||
conntime: number;
|
||||
version: number;
|
||||
subver: string;
|
||||
inbound: boolean;
|
||||
startingheight: number;
|
||||
banscore: number;
|
||||
}
|
||||
|
||||
export interface AddressBalance {
|
||||
balance: number;
|
||||
received: number;
|
||||
}
|
||||
|
||||
export interface AddressUtxo {
|
||||
address: string;
|
||||
txid: string;
|
||||
outputIndex: number;
|
||||
satoshis: number;
|
||||
height: number;
|
||||
}
|
||||
|
||||
export interface SupplyInfo {
|
||||
height: number;
|
||||
bestblock: string;
|
||||
total_amount: number;
|
||||
}
|
||||
|
||||
export interface ValidationResult {
|
||||
isvalid: boolean;
|
||||
address?: string;
|
||||
ismine?: boolean;
|
||||
account?: string;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
export function timeAgo(timestamp: number): string {
|
||||
const seconds = Math.floor(Date.now() / 1000 - timestamp);
|
||||
if (seconds < 60) return `${seconds}s ago`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes}m ago`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) return `${hours}h ago`;
|
||||
const days = Math.floor(hours / 24);
|
||||
return `${days}d ago`;
|
||||
}
|
||||
|
||||
export function truncateHash(hash: string, chars = 8): string {
|
||||
if (hash.length <= chars * 2 + 3) return hash;
|
||||
return `${hash.slice(0, chars)}...${hash.slice(-chars)}`;
|
||||
}
|
||||
|
||||
export function formatAmount(amount: number): string {
|
||||
return amount.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 8 });
|
||||
}
|
||||
|
||||
export function formatDifficulty(diff: number): string {
|
||||
if (diff >= 1e12) return (diff / 1e12).toFixed(2) + 'T';
|
||||
if (diff >= 1e9) return (diff / 1e9).toFixed(2) + 'G';
|
||||
if (diff >= 1e6) return (diff / 1e6).toFixed(2) + 'M';
|
||||
if (diff >= 1e3) return (diff / 1e3).toFixed(2) + 'K';
|
||||
return diff.toFixed(4);
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
return n.toLocaleString();
|
||||
}
|
||||
|
||||
export function formatTimestamp(ts: number): string {
|
||||
return new Date(ts * 1000).toLocaleString();
|
||||
}
|
||||
|
||||
export function isBlockHash(input: string): boolean {
|
||||
return /^[0-9a-fA-F]{64}$/.test(input);
|
||||
}
|
||||
|
||||
export function isNumeric(input: string): boolean {
|
||||
return /^\d+$/.test(input);
|
||||
}
|
||||
|
||||
export function isAddress(input: string): boolean {
|
||||
return /^[A-Za-z1-9]{25,34}$/.test(input);
|
||||
}
|
||||
|
||||
export function blockType(flags: string): string {
|
||||
if (flags.includes('proof-of-stake')) return 'PoS';
|
||||
return 'PoW';
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Error {page.status} - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="flex flex-col items-center justify-center py-20">
|
||||
<div class="text-6xl font-bold text-tri-accent mb-4">{page.status}</div>
|
||||
<div class="text-tri-text text-xl mb-2">
|
||||
{#if page.status === 404}
|
||||
Not Found
|
||||
{:else}
|
||||
Something went wrong
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-tri-muted text-sm mb-6">{page.error?.message || 'An unexpected error occurred'}</p>
|
||||
<a href="/" class="bg-tri-accent hover:bg-tri-accent-light text-white px-6 py-2 rounded-lg text-sm transition-colors">
|
||||
Back to Home
|
||||
</a>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getChainInfo, getNetwork } from '$lib/api';
|
||||
import type { LayoutServerLoad } from './$types';
|
||||
|
||||
export const load: LayoutServerLoad = async () => {
|
||||
try {
|
||||
const [chain, network] = await Promise.all([getChainInfo(), getNetwork()]);
|
||||
return { chain, network };
|
||||
} catch {
|
||||
return { chain: null, network: null };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,72 @@
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
import SearchBar from '$lib/components/SearchBar.svelte';
|
||||
import { formatNumber, formatAmount, formatDifficulty } from '$lib/utils';
|
||||
|
||||
let { data, children } = $props();
|
||||
|
||||
const navLinks = [
|
||||
{ href: '/', label: 'Home' },
|
||||
{ href: '/blocks', label: 'Blocks' },
|
||||
{ href: '/staking', label: 'Staking' },
|
||||
{ href: '/network', label: 'Network' },
|
||||
{ href: '/mempool', label: 'Mempool' },
|
||||
{ href: '/api', label: 'API' }
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Triangles Block Explorer</title>
|
||||
<meta name="description" content="Block explorer for the Triangles (TRI) cryptocurrency" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="min-h-screen bg-tri-bg text-tri-text flex flex-col">
|
||||
<!-- Header -->
|
||||
<header class="border-b border-tri-border bg-tri-surface/50 backdrop-blur-sm sticky top-0 z-50">
|
||||
<div class="max-w-7xl mx-auto px-4 py-3">
|
||||
<div class="flex items-center justify-between gap-4 flex-wrap">
|
||||
<a href="/" class="text-xl font-bold text-white flex items-center gap-2 shrink-0">
|
||||
<span class="text-tri-accent">▲</span> Triangles Explorer
|
||||
</a>
|
||||
<SearchBar />
|
||||
</div>
|
||||
<nav class="flex gap-1 mt-2 overflow-x-auto">
|
||||
{#each navLinks as link}
|
||||
<a
|
||||
href={link.href}
|
||||
class="px-3 py-1.5 text-sm text-tri-muted hover:text-white hover:bg-tri-border/50 rounded transition-colors whitespace-nowrap"
|
||||
>
|
||||
{link.label}
|
||||
</a>
|
||||
{/each}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main -->
|
||||
<main class="flex-1 max-w-7xl mx-auto w-full px-4 py-6">
|
||||
{@render children()}
|
||||
</main>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="border-t border-tri-border bg-tri-surface/30 py-4">
|
||||
<div class="max-w-7xl mx-auto px-4">
|
||||
{#if data.chain}
|
||||
<div class="flex flex-wrap gap-6 text-xs text-tri-muted justify-center">
|
||||
<span>Height: <strong class="text-tri-text">{formatNumber(data.chain.blocks)}</strong></span>
|
||||
<span>Supply: <strong class="text-tri-text">{formatAmount(data.chain.moneysupply)} TRI</strong></span>
|
||||
<span>PoS Diff: <strong class="text-tri-text">{formatDifficulty(data.chain.difficulty['proof-of-stake'])}</strong></span>
|
||||
{#if data.network}
|
||||
<span>Peers: <strong class="text-tri-text">{data.network.connections}</strong></span>
|
||||
{/if}
|
||||
<span>Chain: <strong class="text-tri-text">{data.chain.chain}</strong></span>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="text-center text-xs text-tri-muted">Daemon unavailable</div>
|
||||
{/if}
|
||||
<div class="text-center text-xs text-tri-muted mt-2">
|
||||
Triangles Block Explorer — <a href="https://cryptographic-triangles.org" class="text-tri-accent hover:underline">cryptographic-triangles.org</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -0,0 +1,16 @@
|
||||
import { getChainInfo, getLatestBlocks, getStaking, getMining } from '$lib/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
const [chain, blocks, staking, mining] = await Promise.all([
|
||||
getChainInfo(),
|
||||
getLatestBlocks(10),
|
||||
getStaking(),
|
||||
getMining()
|
||||
]);
|
||||
return { chain, blocks, staking, mining };
|
||||
} catch (e) {
|
||||
return { chain: null, blocks: [], staking: null, mining: null, error: String(e) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
<script lang="ts">
|
||||
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||
import BlockTable from '$lib/components/BlockTable.svelte';
|
||||
import { formatNumber, formatAmount, formatDifficulty, truncateHash } from '$lib/utils';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Triangles Block Explorer - Dashboard</title>
|
||||
</svelte:head>
|
||||
|
||||
{#if data.error}
|
||||
<div class="bg-tri-red/10 border border-tri-red/30 rounded-lg p-4 mb-6 text-tri-red text-sm">
|
||||
Unable to connect to daemon: {data.error}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if data.chain}
|
||||
<!-- Stats Grid -->
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatsCard label="Block Height" value={formatNumber(data.chain.blocks)} />
|
||||
<StatsCard
|
||||
label="Supply"
|
||||
value="{formatAmount(data.chain.moneysupply)} TRI"
|
||||
sub="of 120,000 max"
|
||||
/>
|
||||
<StatsCard
|
||||
label="PoS Difficulty"
|
||||
value={formatDifficulty(data.chain.difficulty['proof-of-stake'])}
|
||||
/>
|
||||
<StatsCard
|
||||
label="Best Block"
|
||||
value={truncateHash(data.chain.bestblockhash, 8)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Staking + Mining stats -->
|
||||
{#if data.staking || data.mining}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
{#if data.staking}
|
||||
<StatsCard
|
||||
label="Staking"
|
||||
value={data.staking.staking ? 'Active' : 'Inactive'}
|
||||
sub="Network weight: {formatNumber(data.staking.netstakeweight)}"
|
||||
/>
|
||||
{/if}
|
||||
{#if data.mining}
|
||||
<StatsCard
|
||||
label="Stake Interest"
|
||||
value="{data.mining.stakeinterest}%"
|
||||
sub="Annual rate"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Network Hash"
|
||||
value="{data.mining.netmhashps.toFixed(2)} MH/s"
|
||||
/>
|
||||
<StatsCard
|
||||
label="Pooled Txs"
|
||||
value={formatNumber(data.mining.pooledtx)}
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Latest Blocks -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
<div class="flex items-center justify-between px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Latest Blocks</h2>
|
||||
<a href="/blocks" class="text-tri-accent text-sm hover:text-tri-accent-light">View all →</a>
|
||||
</div>
|
||||
{#if data.blocks.length > 0}
|
||||
<BlockTable blocks={data.blocks} />
|
||||
{:else}
|
||||
<div class="p-8 text-center text-tri-muted">No blocks available</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getAddressBalance, getAddressUtxos, getAddressTxids, validateAddress } from '$lib/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
try {
|
||||
const [validation, balance, utxos, txids] = await Promise.all([
|
||||
validateAddress(params.addr),
|
||||
getAddressBalance(params.addr),
|
||||
getAddressUtxos(params.addr),
|
||||
getAddressTxids(params.addr)
|
||||
]);
|
||||
|
||||
if (!validation.isvalid) {
|
||||
error(400, 'Invalid address');
|
||||
}
|
||||
|
||||
return { address: params.addr, balance, utxos, txids };
|
||||
} catch (e: unknown) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
if (msg.includes('Address index not enabled')) {
|
||||
return { address: params.addr, balance: null, utxos: [], txids: [], indexError: true };
|
||||
}
|
||||
error(404, 'Address not found');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,84 @@
|
||||
<script lang="ts">
|
||||
import TxTable from '$lib/components/TxTable.svelte';
|
||||
import { formatAmount, truncateHash } from '$lib/utils';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Address {data.address} - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Address</h1>
|
||||
<p class="text-tri-accent text-sm font-mono mt-1 break-all">{data.address}</p>
|
||||
</div>
|
||||
|
||||
{#if data.indexError}
|
||||
<div class="bg-tri-yellow/10 border border-tri-yellow/30 rounded-lg p-4 text-tri-yellow text-sm">
|
||||
Address index is not enabled on this node. Start the daemon with <code class="bg-tri-surface px-1 rounded">-addressindex=1</code> to enable address lookups.
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Balance -->
|
||||
{#if data.balance}
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg p-6 mb-6">
|
||||
<div class="text-tri-muted text-xs uppercase tracking-wider mb-1">Balance</div>
|
||||
<div class="text-3xl font-bold text-white">
|
||||
{formatAmount(data.balance.balance / 100000000)} <span class="text-tri-muted text-lg">TRI</span>
|
||||
</div>
|
||||
<div class="text-tri-muted text-sm mt-1">
|
||||
Total received: {formatAmount(data.balance.received / 100000000)} TRI
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- UTXOs -->
|
||||
{#if data.utxos.length > 0}
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Unspent Outputs ({data.utxos.length})</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||
<th class="text-left py-3 px-3">Txid</th>
|
||||
<th class="text-center py-3 px-3">Index</th>
|
||||
<th class="text-right py-3 px-3">Amount</th>
|
||||
<th class="text-right py-3 px-3">Height</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each data.utxos as utxo}
|
||||
<tr class="border-b border-tri-border/50">
|
||||
<td class="py-2.5 px-3 font-mono text-xs">
|
||||
<a href="/tx/{utxo.txid}" class="text-tri-accent hover:text-tri-accent-light">{truncateHash(utxo.txid, 10)}</a>
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-center">{utxo.outputIndex}</td>
|
||||
<td class="py-2.5 px-3 text-right font-mono text-tri-green">{formatAmount(utxo.satoshis / 100000000)} TRI</td>
|
||||
<td class="py-2.5 px-3 text-right">{utxo.height}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Transaction History -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Transactions ({data.txids.length})</h2>
|
||||
</div>
|
||||
{#if data.txids.length > 0}
|
||||
<TxTable txids={data.txids} limit={50} />
|
||||
{#if data.txids.length > 50}
|
||||
<div class="px-4 py-3 text-center text-tri-muted text-sm border-t border-tri-border">
|
||||
Showing 50 of {data.txids.length} transactions
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="p-8 text-center text-tri-muted">No transactions found</div>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,120 @@
|
||||
<script lang="ts">
|
||||
const endpoints = [
|
||||
{ method: 'GET', path: '/rest/chaininfo', desc: 'Chain state: height, best block hash, supply, difficulty' },
|
||||
{ method: 'GET', path: '/rest/block/{hash}', desc: 'Block details by hash. Append .hex for raw hex.' },
|
||||
{ method: 'GET', path: '/rest/blockheader/{hash}', desc: 'Block header (lightweight, no tx list)' },
|
||||
{ method: 'GET', path: '/rest/blockbyheight/{n}', desc: 'Block details by height' },
|
||||
{ method: 'GET', path: '/rest/blockhashbyheight/{n}', desc: 'Get block hash at a given height' },
|
||||
{ method: 'GET', path: '/rest/tx/{txid}', desc: 'Transaction details. Append .hex for raw hex.' },
|
||||
{ method: 'GET', path: '/rest/mempool', desc: 'List of unconfirmed transaction IDs' },
|
||||
{ method: 'GET', path: '/rest/difficulty', desc: 'Current PoW and PoS difficulty' },
|
||||
{ method: 'GET', path: '/rest/supply', desc: 'UTXO set statistics and total supply' },
|
||||
{ method: 'GET', path: '/rest/staking', desc: 'Staking network stats (weight, difficulty, expected time)' },
|
||||
{ method: 'GET', path: '/rest/mining', desc: 'Mining info (hashrate, block value, stake interest)' },
|
||||
{ method: 'GET', path: '/rest/subsidy', desc: 'Current block subsidy/reward' },
|
||||
{ method: 'GET', path: '/rest/estimatefee', desc: 'Estimated fee per kilobyte' },
|
||||
{ method: 'GET', path: '/rest/checkpoint', desc: 'Sync checkpoint information' },
|
||||
{ method: 'GET', path: '/rest/network', desc: 'Network info (version, connections, proxy)' },
|
||||
{ method: 'GET', path: '/rest/peers', desc: 'List of connected peers with details' },
|
||||
{ method: 'GET', path: '/rest/validate/{addr}', desc: 'Validate a Triangles address' },
|
||||
{ method: 'GET', path: '/rest/address/{addr}/balance', desc: 'Address balance (requires -addressindex=1)' },
|
||||
{ method: 'GET', path: '/rest/address/{addr}/utxos', desc: 'Address unspent outputs (requires -addressindex=1)' },
|
||||
{ method: 'GET', path: '/rest/address/{addr}/txids', desc: 'Address transaction history. Supports ?start=N&end=N (requires -addressindex=1)' },
|
||||
{ method: 'POST', path: '/rest/tx/decode', desc: 'Decode a raw transaction. Body: {"hex":"..."}' },
|
||||
{ method: 'POST', path: '/rest/tx/send', desc: 'Broadcast a raw transaction. Body: {"hex":"..."}' },
|
||||
];
|
||||
|
||||
const walletEndpoints = [
|
||||
{ method: 'GET', path: '/rest/wallet/info', desc: 'Wallet summary (balance, tx count, keypool)' },
|
||||
{ method: 'GET', path: '/rest/wallet/balance', desc: 'Wallet balance' },
|
||||
{ method: 'GET', path: '/rest/wallet/transactions', desc: 'Recent wallet transactions. Supports ?count=N&skip=N' },
|
||||
{ method: 'GET', path: '/rest/wallet/transaction/{txid}', desc: 'Wallet transaction detail' },
|
||||
{ method: 'GET', path: '/rest/wallet/unspent', desc: 'Wallet unspent outputs. Supports ?minconf=N&maxconf=N' },
|
||||
{ method: 'GET', path: '/rest/wallet/addresses', desc: 'Wallet address groupings' },
|
||||
{ method: 'GET', path: '/rest/wallet/staking', desc: 'Staking info for this wallet' },
|
||||
{ method: 'POST', path: '/rest/wallet/address/new', desc: 'Generate a new receiving address' },
|
||||
{ method: 'POST', path: '/rest/wallet/send', desc: 'Send coins. Body: {"address":"...", "amount":N}' },
|
||||
{ method: 'POST', path: '/rest/wallet/sendmany', desc: 'Send to multiple addresses. Body: {"recipients":{"addr":amount}}' },
|
||||
{ method: 'POST', path: '/rest/wallet/unlock', desc: 'Unlock wallet. Body: {"passphrase":"...", "timeout":N}' },
|
||||
{ method: 'POST', path: '/rest/wallet/lock', desc: 'Lock the wallet' },
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>API Documentation - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-2">REST API Documentation</h1>
|
||||
<p class="text-tri-muted text-sm mb-8">
|
||||
The Triangles daemon exposes a REST API on the RPC port (default 19112) when started with <code class="bg-tri-surface px-1 rounded">-rest=1</code>.
|
||||
All responses are JSON. Public endpoints require no authentication.
|
||||
</p>
|
||||
|
||||
<!-- Public Endpoints -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg mb-8 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Public Endpoints</h2>
|
||||
<p class="text-tri-muted text-xs mt-1">No authentication required. Rate limited.</p>
|
||||
</div>
|
||||
<div class="divide-y divide-tri-border/50">
|
||||
{#each endpoints as ep}
|
||||
<div class="px-4 py-3 hover:bg-tri-bg/30 transition-colors">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-bold {ep.method === 'GET' ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-accent/20 text-tri-accent-light'}">
|
||||
{ep.method}
|
||||
</span>
|
||||
<code class="text-tri-text text-sm">{ep.path}</code>
|
||||
</div>
|
||||
<p class="text-tri-muted text-xs ml-14">{ep.desc}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Wallet Endpoints -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Wallet Endpoints</h2>
|
||||
<p class="text-tri-muted text-xs mt-1">Require HTTP Basic Auth (RPC credentials) or Bearer token (<code>-restapikey</code>).</p>
|
||||
</div>
|
||||
<div class="divide-y divide-tri-border/50">
|
||||
{#each walletEndpoints as ep}
|
||||
<div class="px-4 py-3 hover:bg-tri-bg/30 transition-colors">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<span class="inline-block px-2 py-0.5 rounded text-xs font-bold {ep.method === 'GET' ? 'bg-tri-green/20 text-tri-green' : 'bg-tri-accent/20 text-tri-accent-light'}">
|
||||
{ep.method}
|
||||
</span>
|
||||
<code class="text-tri-text text-sm">{ep.path}</code>
|
||||
</div>
|
||||
<p class="text-tri-muted text-xs ml-14">{ep.desc}</p>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Configuration -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg mt-8 p-6">
|
||||
<h2 class="text-white font-semibold mb-4">Configuration</h2>
|
||||
<div class="space-y-3 text-sm">
|
||||
<div>
|
||||
<code class="text-tri-accent">-rest=1</code>
|
||||
<span class="text-tri-muted ml-2">Enable the REST API</span>
|
||||
</div>
|
||||
<div>
|
||||
<code class="text-tri-accent">-addressindex=1</code>
|
||||
<span class="text-tri-muted ml-2">Enable address index (required for /address/* endpoints)</span>
|
||||
</div>
|
||||
<div>
|
||||
<code class="text-tri-accent">-restcorsorigin=*</code>
|
||||
<span class="text-tri-muted ml-2">Set CORS allowed origin (default: *)</span>
|
||||
</div>
|
||||
<div>
|
||||
<code class="text-tri-accent">-restapikey=<token></code>
|
||||
<span class="text-tri-muted ml-2">Bearer token for wallet endpoint authentication</span>
|
||||
</div>
|
||||
<div>
|
||||
<code class="text-tri-accent">-restratelimit=30</code>
|
||||
<span class="text-tri-muted ml-2">Rate limit per IP per second (default: 30, 0=disabled)</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getBlock } from '$lib/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
try {
|
||||
const block = await getBlock(params.hash);
|
||||
return { block };
|
||||
} catch {
|
||||
error(404, 'Block not found');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,65 @@
|
||||
<script lang="ts">
|
||||
import TxTable from '$lib/components/TxTable.svelte';
|
||||
import { formatTimestamp, formatDifficulty, blockType, truncateHash } from '$lib/utils';
|
||||
|
||||
let { data } = $props();
|
||||
const block = data.block;
|
||||
|
||||
const fields: [string, string][] = [
|
||||
['Height', String(block.height)],
|
||||
['Confirmations', String(block.confirmations)],
|
||||
['Timestamp', formatTimestamp(block.time)],
|
||||
['Type', blockType(block.flags)],
|
||||
['Difficulty', formatDifficulty(block.difficulty)],
|
||||
['Transactions', String(block.tx.length)],
|
||||
['Merkle Root', truncateHash(block.merkleroot, 16)],
|
||||
['Nonce', String(block.nonce)],
|
||||
['Bits', block.bits],
|
||||
['Size', block.size ? `${block.size} bytes` : 'N/A'],
|
||||
['Mint', String(block.mint)],
|
||||
['Flags', block.flags]
|
||||
];
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Block {block.height} - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Navigation -->
|
||||
<div class="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold text-white">Block {block.height}</h1>
|
||||
<p class="text-tri-muted text-xs font-mono mt-1 break-all">{block.hash}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 shrink-0">
|
||||
{#if block.previousblockhash}
|
||||
<a href="/block/{block.previousblockhash}" class="bg-tri-surface border border-tri-border text-tri-text px-3 py-1.5 rounded text-sm hover:border-tri-accent transition-colors">← Prev</a>
|
||||
{/if}
|
||||
{#if block.nextblockhash}
|
||||
<a href="/block/{block.nextblockhash}" class="bg-tri-surface border border-tri-border text-tri-text px-3 py-1.5 rounded text-sm hover:border-tri-accent transition-colors">Next →</a>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Block Info -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Block Details</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-px bg-tri-border">
|
||||
{#each fields as [label, value]}
|
||||
<div class="bg-tri-surface px-4 py-3">
|
||||
<span class="text-tri-muted text-xs uppercase tracking-wider">{label}</span>
|
||||
<div class="text-tri-text text-sm mt-0.5 font-mono break-all">{value}</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Transactions -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Transactions ({block.tx.length})</h2>
|
||||
</div>
|
||||
<TxTable txids={block.tx} />
|
||||
</div>
|
||||
@@ -0,0 +1,26 @@
|
||||
import { getChainInfo, getBlockByHeight } from '$lib/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { Block } from '$lib/types';
|
||||
|
||||
const PAGE_SIZE = 20;
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const page = parseInt(url.searchParams.get('page') || '1');
|
||||
|
||||
try {
|
||||
const chain = await getChainInfo();
|
||||
const startHeight = chain.blocks - (page - 1) * PAGE_SIZE;
|
||||
|
||||
const blocks: Block[] = [];
|
||||
for (let i = 0; i < PAGE_SIZE && startHeight - i >= 0; i++) {
|
||||
try {
|
||||
blocks.push(await getBlockByHeight(startHeight - i));
|
||||
} catch { break; }
|
||||
}
|
||||
|
||||
const hasNext = startHeight - PAGE_SIZE >= 0;
|
||||
return { blocks, page, hasNext, totalBlocks: chain.blocks };
|
||||
} catch (e) {
|
||||
return { blocks: [], page, hasNext: false, totalBlocks: 0, error: String(e) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,26 @@
|
||||
<script lang="ts">
|
||||
import BlockTable from '$lib/components/BlockTable.svelte';
|
||||
import Pagination from '$lib/components/Pagination.svelte';
|
||||
import { formatNumber } from '$lib/utils';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Blocks - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Blocks</h1>
|
||||
<p class="text-tri-muted text-sm mt-1">Total: {formatNumber(data.totalBlocks)} blocks</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
{#if data.blocks.length > 0}
|
||||
<BlockTable blocks={data.blocks} />
|
||||
{:else}
|
||||
<div class="p-8 text-center text-tri-muted">No blocks available</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<Pagination page={data.page} hasNext={data.hasNext} basePath="/blocks" />
|
||||
@@ -0,0 +1,9 @@
|
||||
import { getBlockByHeight } from '$lib/api';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
const height = parseInt(params.height);
|
||||
const block = await getBlockByHeight(height);
|
||||
redirect(301, `/block/${block.hash}`);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<p class="text-tri-muted">Redirecting...</p>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getMempool } from '$lib/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
const txids = await getMempool();
|
||||
return { txids };
|
||||
} catch (e) {
|
||||
return { txids: [], error: String(e) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
<script lang="ts">
|
||||
import TxTable from '$lib/components/TxTable.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Mempool - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Mempool</h1>
|
||||
<p class="text-tri-muted text-sm mt-1">{data.txids.length} unconfirmed transaction{data.txids.length !== 1 ? 's' : ''}</p>
|
||||
</div>
|
||||
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
{#if data.txids.length > 0}
|
||||
<TxTable txids={data.txids} />
|
||||
{:else}
|
||||
<div class="p-8 text-center text-tri-muted">Mempool is empty</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
import { getNetwork, getPeers } from '$lib/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
const [network, peers] = await Promise.all([getNetwork(), getPeers()]);
|
||||
return { network, peers };
|
||||
} catch (e) {
|
||||
return { network: null, peers: [], error: String(e) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||
import PeerTable from '$lib/components/PeerTable.svelte';
|
||||
|
||||
let { data } = $props();
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Network - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Network</h1>
|
||||
|
||||
{#if data.network}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatsCard label="Connections" value={String(data.network.connections)} />
|
||||
<StatsCard label="Protocol Version" value={String(data.network.protocolversion)} />
|
||||
<StatsCard label="Client Version" value={String(data.network.version)} />
|
||||
<StatsCard label="Network" value={data.network.testnet ? 'Testnet' : 'Mainnet'} />
|
||||
</div>
|
||||
|
||||
{#if data.network.errors}
|
||||
<div class="bg-tri-yellow/10 border border-tri-yellow/30 rounded-lg p-4 mb-6 text-tri-yellow text-sm">
|
||||
{data.network.errors}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
<!-- Peers -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Connected Peers ({data.peers.length})</h2>
|
||||
</div>
|
||||
{#if data.peers.length > 0}
|
||||
<PeerTable peers={data.peers} />
|
||||
{:else}
|
||||
<div class="p-8 text-center text-tri-muted">No peers connected</div>
|
||||
{/if}
|
||||
</div>
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getBlock, getTransaction, getBlockHashByHeight } from '$lib/api';
|
||||
import { redirect, error } from '@sveltejs/kit';
|
||||
import { isNumeric, isBlockHash } from '$lib/utils';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ url }) => {
|
||||
const q = url.searchParams.get('q')?.trim();
|
||||
if (!q) error(400, 'No search query provided');
|
||||
|
||||
// Numeric: treat as block height
|
||||
if (isNumeric(q)) {
|
||||
try {
|
||||
const { blockhash } = await getBlockHashByHeight(parseInt(q));
|
||||
redirect(302, `/block/${blockhash}`);
|
||||
} catch {
|
||||
error(404, `Block at height ${q} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
// 64-char hex: try block hash, then txid
|
||||
if (isBlockHash(q)) {
|
||||
try {
|
||||
await getBlock(q);
|
||||
redirect(302, `/block/${q}`);
|
||||
} catch {
|
||||
// Not a block, try as txid
|
||||
}
|
||||
try {
|
||||
await getTransaction(q);
|
||||
redirect(302, `/tx/${q}`);
|
||||
} catch {
|
||||
error(404, `No block or transaction found for ${q}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Otherwise: try as address
|
||||
redirect(302, `/address/${q}`);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
<p class="text-tri-muted">Searching...</p>
|
||||
@@ -0,0 +1,28 @@
|
||||
import { getStaking, getMining, getSubsidy, getDifficulty, getChainInfo, getBlockHeader, getBlockHashByHeight } from '$lib/api';
|
||||
import type { PageServerLoad } from './$types';
|
||||
import type { BlockHeader } from '$lib/types';
|
||||
|
||||
export const load: PageServerLoad = async () => {
|
||||
try {
|
||||
const [staking, mining, subsidy, difficulty, chain] = await Promise.all([
|
||||
getStaking(),
|
||||
getMining(),
|
||||
getSubsidy(),
|
||||
getDifficulty(),
|
||||
getChainInfo()
|
||||
]);
|
||||
|
||||
// Fetch last 50 block headers for difficulty chart
|
||||
const headers: BlockHeader[] = [];
|
||||
for (let i = 0; i < 50 && chain.blocks - i >= 0; i++) {
|
||||
try {
|
||||
const { blockhash } = await getBlockHashByHeight(chain.blocks - i);
|
||||
headers.push(await getBlockHeader(blockhash));
|
||||
} catch { break; }
|
||||
}
|
||||
|
||||
return { staking, mining, subsidy, difficulty, headers: headers.reverse() };
|
||||
} catch (e) {
|
||||
return { staking: null, mining: null, subsidy: null, difficulty: null, headers: [], error: String(e) };
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,73 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import StatsCard from '$lib/components/StatsCard.svelte';
|
||||
import { formatNumber, formatDifficulty } from '$lib/utils';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
let { data } = $props();
|
||||
let chartCanvas: HTMLCanvasElement;
|
||||
|
||||
onMount(() => {
|
||||
if (data.headers.length > 0) {
|
||||
Chart.register(...registerables);
|
||||
new Chart(chartCanvas, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: data.headers.map(h => h.height),
|
||||
datasets: [{
|
||||
label: 'PoS Difficulty',
|
||||
data: data.headers.filter(h => h.flags.includes('proof-of-stake')).map(h => h.difficulty),
|
||||
borderColor: '#22c55e',
|
||||
backgroundColor: 'rgba(34, 197, 94, 0.1)',
|
||||
fill: true,
|
||||
tension: 0.3,
|
||||
pointRadius: 0
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
plugins: { legend: { labels: { color: '#94a3b8' } } },
|
||||
scales: {
|
||||
x: { ticks: { color: '#94a3b8', maxTicksLimit: 10 }, grid: { color: '#2a2d3a' } },
|
||||
y: { ticks: { color: '#94a3b8' }, grid: { color: '#2a2d3a' } }
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Staking - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<h1 class="text-2xl font-bold text-white mb-6">Staking Dashboard</h1>
|
||||
|
||||
{#if data.staking && data.mining}
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
|
||||
<StatsCard label="Staking Active" value={data.staking.staking ? 'Yes' : 'No'} />
|
||||
<StatsCard label="PoS Difficulty" value={formatDifficulty(data.staking.difficulty)} />
|
||||
<StatsCard label="Network Stake Weight" value={formatNumber(data.staking.netstakeweight)} />
|
||||
<StatsCard label="Stake Interest" value="{data.mining.stakeinterest}% annual" />
|
||||
<StatsCard label="Block Subsidy" value={data.subsidy != null ? String(data.subsidy) : 'N/A'} />
|
||||
<StatsCard label="Net Hash Rate" value="{data.mining.netmhashps.toFixed(2)} MH/s" />
|
||||
<StatsCard label="PoW Difficulty" value={data.difficulty ? formatDifficulty(data.difficulty['proof-of-work']) : 'N/A'} />
|
||||
<StatsCard
|
||||
label="Expected Time"
|
||||
value={data.staking.expectedtime > 0 ? `${Math.floor(data.staking.expectedtime / 3600)}h ${Math.floor((data.staking.expectedtime % 3600) / 60)}m` : 'N/A'}
|
||||
sub="Until next stake"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Chart -->
|
||||
{#if data.headers.length > 0}
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg p-4">
|
||||
<h2 class="text-white font-semibold mb-4">PoS Difficulty (Last 50 blocks)</h2>
|
||||
<canvas bind:this={chartCanvas}></canvas>
|
||||
</div>
|
||||
{/if}
|
||||
{:else}
|
||||
<div class="bg-tri-red/10 border border-tri-red/30 rounded-lg p-4 text-tri-red text-sm">
|
||||
Unable to load staking data. Is the daemon running?
|
||||
</div>
|
||||
{/if}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { getTransaction } from '$lib/api';
|
||||
import { error } from '@sveltejs/kit';
|
||||
import type { PageServerLoad } from './$types';
|
||||
|
||||
export const load: PageServerLoad = async ({ params }) => {
|
||||
try {
|
||||
const tx = await getTransaction(params.txid);
|
||||
return { tx };
|
||||
} catch {
|
||||
error(404, 'Transaction not found');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,118 @@
|
||||
<script lang="ts">
|
||||
import { formatTimestamp, formatAmount, truncateHash } from '$lib/utils';
|
||||
|
||||
let { data } = $props();
|
||||
const tx = data.tx;
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Tx {truncateHash(tx.txid, 8)} - Triangles Explorer</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold text-white">Transaction</h1>
|
||||
<p class="text-tri-muted text-xs font-mono mt-1 break-all">{tx.txid}</p>
|
||||
</div>
|
||||
|
||||
<!-- Tx Info -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Details</h2>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-px bg-tri-border">
|
||||
{#if tx.blockhash}
|
||||
<div class="bg-tri-surface px-4 py-3">
|
||||
<span class="text-tri-muted text-xs uppercase tracking-wider">Block</span>
|
||||
<div class="text-sm mt-0.5">
|
||||
<a href="/block/{tx.blockhash}" class="text-tri-accent hover:text-tri-accent-light font-mono">{truncateHash(tx.blockhash, 12)}</a>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
<div class="bg-tri-surface px-4 py-3">
|
||||
<span class="text-tri-muted text-xs uppercase tracking-wider">Confirmations</span>
|
||||
<div class="text-tri-text text-sm mt-0.5">{tx.confirmations ?? 'Unconfirmed'}</div>
|
||||
</div>
|
||||
<div class="bg-tri-surface px-4 py-3">
|
||||
<span class="text-tri-muted text-xs uppercase tracking-wider">Timestamp</span>
|
||||
<div class="text-tri-text text-sm mt-0.5 font-mono">{formatTimestamp(tx.time)}</div>
|
||||
</div>
|
||||
<div class="bg-tri-surface px-4 py-3">
|
||||
<span class="text-tri-muted text-xs uppercase tracking-wider">Version</span>
|
||||
<div class="text-tri-text text-sm mt-0.5">{tx.version}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Inputs -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg mb-6 overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Inputs ({tx.vin.length})</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||
<th class="text-left py-3 px-3">#</th>
|
||||
<th class="text-left py-3 px-3">Source</th>
|
||||
<th class="text-left py-3 px-3">Script</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tx.vin as vin, i}
|
||||
<tr class="border-b border-tri-border/50">
|
||||
<td class="py-2.5 px-3 text-tri-muted">{i}</td>
|
||||
<td class="py-2.5 px-3 font-mono text-xs">
|
||||
{#if vin.coinbase}
|
||||
<span class="text-tri-green">Coinbase / Staking reward</span>
|
||||
{:else if vin.txid}
|
||||
<a href="/tx/{vin.txid}" class="text-tri-accent hover:text-tri-accent-light">
|
||||
{truncateHash(vin.txid, 10)}:{vin.vout}
|
||||
</a>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2.5 px-3 font-mono text-xs text-tri-muted max-w-xs truncate">
|
||||
{vin.scriptSig?.asm || vin.coinbase || ''}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Outputs -->
|
||||
<div class="bg-tri-surface border border-tri-border rounded-lg overflow-hidden">
|
||||
<div class="px-4 py-3 border-b border-tri-border">
|
||||
<h2 class="text-white font-semibold">Outputs ({tx.vout.length})</h2>
|
||||
</div>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead>
|
||||
<tr class="text-tri-muted text-xs uppercase tracking-wider border-b border-tri-border">
|
||||
<th class="text-left py-3 px-3">#</th>
|
||||
<th class="text-left py-3 px-3">Address</th>
|
||||
<th class="text-right py-3 px-3">Amount</th>
|
||||
<th class="text-left py-3 px-3">Type</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each tx.vout as vout}
|
||||
<tr class="border-b border-tri-border/50">
|
||||
<td class="py-2.5 px-3 text-tri-muted">{vout.n}</td>
|
||||
<td class="py-2.5 px-3 font-mono text-xs">
|
||||
{#if vout.scriptPubKey.addresses}
|
||||
{#each vout.scriptPubKey.addresses as addr}
|
||||
<a href="/address/{addr}" class="text-tri-accent hover:text-tri-accent-light">{addr}</a>
|
||||
{/each}
|
||||
{:else}
|
||||
<span class="text-tri-muted">N/A</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-2.5 px-3 text-right font-mono text-tri-green">{formatAmount(vout.value)} TRI</td>
|
||||
<td class="py-2.5 px-3 text-xs text-tri-muted">{vout.scriptPubKey.type}</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,12 @@
|
||||
import adapter from '@sveltejs/adapter-node';
|
||||
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||
|
||||
/** @type {import('@sveltejs/kit').Config} */
|
||||
const config = {
|
||||
preprocess: vitePreprocess(),
|
||||
kit: {
|
||||
adapter: adapter()
|
||||
}
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"extends": "./.svelte-kit/tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"skipLibCheck": true,
|
||||
"sourceMap": true,
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { sveltekit } from '@sveltejs/kit/vite';
|
||||
import tailwindcss from '@tailwindcss/vite';
|
||||
import { defineConfig } from 'vite';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [tailwindcss(), sveltekit()]
|
||||
});
|
||||
Reference in New Issue
Block a user