Skip to content
Draft
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
# Required: Your L2 RPC endpoint
RPC_URL=http://localhost:8545

# Human-readable name for your chain, displayed in the explorer UI
CHAIN_NAME=My Chain

# Optional settings (defaults shown)
START_BLOCK=0
BATCH_SIZE=100
Expand Down
12 changes: 6 additions & 6 deletions backend/crates/atlas-api/src/handlers/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,12 @@ pub mod transactions;

use sqlx::PgPool;

/// Get transactions table row count efficiently.
/// Get a table's row count efficiently.
/// - For tables > 100k rows: uses PostgreSQL's approximate count (instant, ~99% accurate)
/// - For smaller tables: uses exact COUNT(*) (fast enough)
///
/// This avoids the slow COUNT(*) full table scan on large tables.
pub async fn get_table_count(pool: &PgPool) -> Result<i64, sqlx::Error> {
let table_name = "transactions";
pub async fn get_table_count(pool: &PgPool, table_name: &str) -> Result<i64, sqlx::Error> {

// Sum approximate reltuples across partitions if any, else use parent.
// This is instant and reasonably accurate for large tables.
Expand Down Expand Up @@ -53,9 +52,10 @@ pub async fn get_table_count(pool: &PgPool) -> Result<i64, sqlx::Error> {
Ok(approx)
} else {
// Exact count for small tables
let exact: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM transactions")
.fetch_one(pool)
.await?;
let exact: (i64,) =
sqlx::query_as(&format!("SELECT COUNT(*) FROM {table_name}"))
.fetch_one(pool)
.await?;
Ok(exact.0)
}
}
38 changes: 36 additions & 2 deletions backend/crates/atlas-api/src/handlers/status.rs
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,43 @@ use serde::Serialize;
use std::sync::Arc;

use crate::error::ApiResult;
use crate::handlers::get_table_count;
use crate::AppState;

#[derive(Serialize)]
pub struct HeightResponse {
pub block_height: i64,
pub indexed_at: String,
}

#[derive(Serialize)]
pub struct ChainStatus {
pub chain_id: u64,
pub chain_name: String,
pub block_height: i64,
pub total_transactions: i64,
pub total_addresses: i64,
pub indexed_at: String,
}

/// GET /api/status - Lightweight endpoint for current chain status
/// Returns in <1ms, optimized for frequent polling
/// GET /api/height - Lightweight endpoint for current block height.
/// Returns in <1ms, optimized for frequent polling.
pub async fn get_height(State(state): State<Arc<AppState>>) -> ApiResult<Json<HeightResponse>> {
let result: (String, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
"SELECT value, updated_at FROM indexer_state WHERE key = 'last_indexed_block'",
)
.fetch_one(&state.pool)
.await?;

let block_height: i64 = result.0.parse().unwrap_or(0);

Ok(Json(HeightResponse {
block_height,
indexed_at: result.1.to_rfc3339(),
}))
}

/// GET /api/status - Full chain status including chain ID, name, and counts.
pub async fn get_status(State(state): State<Arc<AppState>>) -> ApiResult<Json<ChainStatus>> {
let result: (String, chrono::DateTime<chrono::Utc>) = sqlx::query_as(
"SELECT value, updated_at FROM indexer_state WHERE key = 'last_indexed_block'",
Expand All @@ -22,8 +49,15 @@ pub async fn get_status(State(state): State<Arc<AppState>>) -> ApiResult<Json<Ch

let block_height: i64 = result.0.parse().unwrap_or(0);

let total_transactions = get_table_count(&state.pool, "transactions").await?;
let total_addresses = get_table_count(&state.pool, "addresses").await?;

Ok(Json(ChainStatus {
chain_id: state.chain_id,
chain_name: state.chain_name.clone(),
block_height,
total_transactions,
total_addresses,
indexed_at: result.1.to_rfc3339(),
}))
}
2 changes: 1 addition & 1 deletion backend/crates/atlas-api/src/handlers/transactions.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ pub async fn list_transactions(
Query(pagination): Query<Pagination>,
) -> ApiResult<Json<PaginatedResponse<Transaction>>> {
// Use optimized count (approximate for large tables, exact for small)
let total = get_table_count(&state.pool).await?;
let total = get_table_count(&state.pool, "transactions").await?;

let transactions: Vec<Transaction> = sqlx::query_as(
"SELECT hash, block_number, block_index, from_address, to_address, value, gas_price, gas_used, input_data, status, contract_created, timestamp
Expand Down
38 changes: 38 additions & 0 deletions backend/crates/atlas-api/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,40 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
mod error;
mod handlers;

async fn fetch_chain_id(rpc_url: &str) -> u64 {
let client = reqwest::Client::new();
let resp = client
.post(rpc_url)
.json(&serde_json::json!({
"jsonrpc": "2.0",
"method": "eth_chainId",
"params": [],
"id": 1
}))
.timeout(Duration::from_secs(5))
.send()
.await;

match resp {
Ok(r) => {
let json: serde_json::Value = r.json().await.unwrap_or_default();
let hex = json["result"].as_str().unwrap_or("0x0");
u64::from_str_radix(hex.trim_start_matches("0x"), 16).unwrap_or(0)
}
Err(e) => {
tracing::warn!("Failed to fetch chain ID from RPC: {}", e);
0
}
}
}

pub struct AppState {
pub pool: PgPool,
pub rpc_url: String,
pub solc_path: String,
pub admin_api_key: Option<String>,
pub chain_id: u64,
pub chain_name: String,
}

#[tokio::main]
Expand All @@ -40,12 +69,18 @@ async fn main() -> Result<()> {
let rpc_url = std::env::var("RPC_URL").expect("RPC_URL must be set");
let solc_path = std::env::var("SOLC_PATH").unwrap_or_else(|_| "solc".to_string());
let admin_api_key = std::env::var("ADMIN_API_KEY").ok();
let chain_name = std::env::var("CHAIN_NAME").unwrap_or_else(|_| "Unknown".to_string());
let host = std::env::var("API_HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
let port: u16 = std::env::var("API_PORT")
.unwrap_or_else(|_| "3000".to_string())
.parse()
.expect("Invalid API_PORT");

// Fetch chain ID once at startup — it never changes
tracing::info!("Fetching chain ID from RPC");
let chain_id = fetch_chain_id(&rpc_url).await;
tracing::info!("Chain ID: {}", chain_id);

// Create database pool
let pool = atlas_common::db::create_pool(&database_url, 20).await?;

Expand All @@ -58,6 +93,8 @@ async fn main() -> Result<()> {
rpc_url,
solc_path,
admin_api_key,
chain_id,
chain_name,
});

// Build router
Expand Down Expand Up @@ -208,6 +245,7 @@ async fn main() -> Result<()> {
// Search
.route("/api/search", get(handlers::search::search))
// Status
.route("/api/height", get(handlers::status::get_height))
.route("/api/status", get(handlers::status::get_status))
// Health
.route("/health", get(|| async { "OK" }))
Expand Down
10 changes: 6 additions & 4 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
WelcomePage,
SearchResultsPage,
AddressesPage,
StatusPage,
} from './pages';
import { ThemeProvider } from './context/ThemeContext';

Expand All @@ -34,10 +35,11 @@ export default function App() {
<Route path="addresses" element={<AddressesPage />} />
<Route path="tx/:hash" element={<TransactionDetailPage />} />
<Route path="address/:address" element={<AddressPage />} />
<Route path="nfts" element={<NFTsPage />} />
<Route path="nfts/:contract" element={<NFTContractPage />} />
<Route path="nfts/:contract/:tokenId" element={<NFTTokenPage />} />
<Route path="tokens" element={<TokensPage />} />
<Route path="nfts" element={<NFTsPage />} />
<Route path="nfts/:contract" element={<NFTContractPage />} />
<Route path="nfts/:contract/:tokenId" element={<NFTTokenPage />} />
<Route path="status" element={<StatusPage />} />
<Route path="tokens" element={<TokensPage />} />
<Route path="tokens/:address" element={<TokenDetailPage />} />
<Route path="*" element={<NotFoundPage />} />
</Route>
Expand Down
19 changes: 16 additions & 3 deletions frontend/src/api/status.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,25 @@
import client from './client';

export interface StatusResponse {
export interface HeightResponse {
block_height: number;
indexed_at: string; // ISO timestamp
}

export async function getStatus(): Promise<StatusResponse> {
const response = await client.get<StatusResponse>('/status');
export interface ChainStatusResponse {
chain_id: number;
chain_name: string;
block_height: number;
total_transactions: number;
total_addresses: number;
indexed_at: string; // ISO timestamp
}

export async function getStatus(): Promise<HeightResponse> {
const response = await client.get<HeightResponse>('/height');
return response.data;
}

export async function getChainStatus(): Promise<ChainStatusResponse> {
const response = await client.get<ChainStatusResponse>('/status');
return response.data;
}
6 changes: 6 additions & 0 deletions frontend/src/components/Layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,9 @@ export default function Layout() {
<NavLink to="/nfts" className={navLinkClass}>
NFTs
</NavLink>
<NavLink to="/status" className={navLinkClass}>
Status
</NavLink>
</nav>

{/* Right status: latest height + live pulse */}
Expand Down Expand Up @@ -206,6 +209,9 @@ export default function Layout() {
<NavLink to="/nfts" className={navLinkClass}>
NFTs
</NavLink>
<NavLink to="/status" className={navLinkClass}>
Status
</NavLink>
<button
type="button"
onClick={toggleTheme}
Expand Down
89 changes: 89 additions & 0 deletions frontend/src/pages/StatusPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { useEffect, useState } from 'react';
import { getChainStatus, type ChainStatusResponse } from '../api/status';
import { formatNumber } from '../utils';
import Loading from '../components/Loading';

export default function StatusPage() {
const [status, setStatus] = useState<ChainStatusResponse | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);

useEffect(() => {
let mounted = true;
const fetchStatus = async () => {
try {
setLoading(true);
setError(null);
const resp = await getChainStatus();
if (mounted) {
setStatus(resp);
}
} catch (err) {
if (mounted) {
setError(err instanceof Error ? err.message : 'Failed to load status');
}
} finally {
if (mounted) {
setLoading(false);
}
}
};

fetchStatus();
const id = setInterval(fetchStatus, 5000);
return () => {
mounted = false;
clearInterval(id);
};
}, []);

const lastIndexed = status?.indexed_at
? new Date(status.indexed_at).toLocaleString(undefined, {
timeStyle: 'medium',
dateStyle: 'medium',
})
: '—';

return (
<div>
<div className="flex items-center justify-between mb-6">
<h1 className="text-2xl font-bold text-fg">Status</h1>
</div>

<div className="card">
{loading && !status ? (
<div className="py-10">
<Loading text="Fetching status" />
</div>
) : error ? (
<div className="py-6">
<p className="text-accent-error text-sm">{error}</p>
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<StatusStat label="Chain ID" value={status ? status.chain_id.toString() : '—'} />
<StatusStat label="Chain Name" value={status?.chain_name || 'Unknown'} />
<StatusStat label="Block Height" value={status ? formatNumber(status.block_height) : '—'} />
<StatusStat label="Total Transactions" value={status ? formatNumber(status.total_transactions) : '—'} />
<StatusStat label="Total Addresses" value={status ? formatNumber(status.total_addresses) : '—'} />
<StatusStat label="Last Indexed" value={lastIndexed} />
</div>
)}
</div>
</div>
);
}

interface StatusStatProps {
label: string;
value: string;
}

function StatusStat({ label, value }: StatusStatProps) {
return (
<div className="bg-dark-700/60 border border-dark-600 rounded-xl p-4">
<p className="text-fg-subtle text-xs uppercase tracking-wide mb-1">{label}</p>
<p className="text-fg text-lg font-semibold break-words">{value}</p>
</div>
);
}
1 change: 1 addition & 0 deletions frontend/src/pages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,4 @@ export { default as NotFoundPage } from './NotFoundPage';
export { default as WelcomePage } from './WelcomePage';
export { default as SearchResultsPage } from './SearchResultsPage';
export { default as AddressesPage } from './AddressesPage';
export { default as StatusPage } from './StatusPage';
Loading