mirror of
https://github.com/luxfi/explorer.git
synced 2026-09-24 00:25:12 +00:00
Commits 3 outstanding change(s) that were sitting uncommitted. No build artifacts and no secrets in the changeset (both checked).
267 lines
9.5 KiB
Go
267 lines
9.5 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// StatsService serves the Blockscout stats-microservice API surface
|
|
// (/v1/counters, /v1/lines, /v1/lines/{id}, /v1/pages/*) for the default chain,
|
|
// backed directly by the live indexer SQLite DB.
|
|
//
|
|
// The Next.js explorer turns on config.features.stats when
|
|
// NEXT_PUBLIC_STATS_API_HOST is set (it points at this backend). Its /stats page
|
|
// then requests /v1/counters and /v1/lines; without them it shows two error
|
|
// banners ("Something went wrong" + "Some of the charts did not load because the
|
|
// server didn't respond"). The indexer's own /v1/indexer/{slug}/stats endpoint
|
|
// is a different shape and its chart endpoints are empty stubs, so we serve real
|
|
// aggregates here, computed from evm_blocks / evm_transactions / evm_addresses.
|
|
type StatsService struct {
|
|
dbPath string
|
|
mu sync.Mutex
|
|
db *sql.DB
|
|
}
|
|
|
|
// NewStatsService targets the default chain's indexer DB
|
|
// ({dataDir}/{slug}/query/indexer.db).
|
|
func NewStatsService(dbPath string) *StatsService {
|
|
return &StatsService{dbPath: dbPath}
|
|
}
|
|
|
|
// conn lazily opens a read-only handle. The indexer writes in WAL mode, so a
|
|
// read-only reader never blocks ingestion. The DB may not exist until the
|
|
// indexer has created it, so open is retried per-request until it succeeds.
|
|
func (s *StatsService) conn() (*sql.DB, error) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if s.db != nil {
|
|
return s.db, nil
|
|
}
|
|
db, err := sql.Open("sqlite3", "file:"+s.dbPath+"?mode=ro&_busy_timeout=3000")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
db.SetMaxOpenConns(2)
|
|
if err := db.Ping(); err != nil {
|
|
db.Close()
|
|
return nil, err
|
|
}
|
|
s.db = db
|
|
return db, nil
|
|
}
|
|
|
|
// Mount registers the stats-service routes on mux.
|
|
func (s *StatsService) Mount(mux *http.ServeMux) {
|
|
mux.HandleFunc("GET /v1/counters", s.handleCounters)
|
|
mux.HandleFunc("GET /v1/lines", s.handleLines)
|
|
mux.HandleFunc("GET /v1/lines/{id}", s.handleLine)
|
|
mux.HandleFunc("GET /v1/pages/main", s.handlePagesMain)
|
|
mux.HandleFunc("GET /v1/pages/transactions", s.handlePagesTransactions)
|
|
mux.HandleFunc("GET /v1/pages/contracts", s.handlePagesContracts)
|
|
}
|
|
|
|
// ---- Wire shapes (match @luxfi/stats-types) ----
|
|
|
|
type statsCounter struct {
|
|
ID string `json:"id"`
|
|
Value string `json:"value"`
|
|
Title string `json:"title"`
|
|
Units string `json:"units,omitempty"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type statsPoint struct {
|
|
Date string `json:"date"`
|
|
DateTo string `json:"date_to"`
|
|
Value string `json:"value"`
|
|
IsApproximate bool `json:"is_approximate"`
|
|
}
|
|
|
|
type statsLineInfo struct {
|
|
ID string `json:"id"`
|
|
Title string `json:"title"`
|
|
Description string `json:"description"`
|
|
Units string `json:"units,omitempty"`
|
|
Resolutions []string `json:"resolutions"`
|
|
}
|
|
|
|
type statsLineChart struct {
|
|
Chart []statsPoint `json:"chart"`
|
|
Info statsLineInfo `json:"info"`
|
|
}
|
|
|
|
func writeJSON(w http.ResponseWriter, code int, v any) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
w.WriteHeader(code)
|
|
json.NewEncoder(w).Encode(v)
|
|
}
|
|
|
|
// ---- Aggregate helpers ----
|
|
|
|
func (s *StatsService) count(db *sql.DB, table string) int64 {
|
|
var n int64
|
|
// #nosec G201 — table is a fixed internal identifier, never user input.
|
|
db.QueryRow("SELECT COUNT(*) FROM " + table).Scan(&n)
|
|
return n
|
|
}
|
|
|
|
// avgBlockTimeSec returns the lifetime average block interval in seconds,
|
|
// derived from the full timestamp span so it stays meaningful on a sparse,
|
|
// demand-driven chain (per-window deltas collapse to zero when idle).
|
|
func (s *StatsService) avgBlockTimeSec(db *sql.DB) float64 {
|
|
var count, minTS, maxTS int64
|
|
db.QueryRow("SELECT COUNT(*), COALESCE(MIN(timestamp),0), COALESCE(MAX(timestamp),0) FROM evm_blocks").
|
|
Scan(&count, &minTS, &maxTS)
|
|
if count < 2 || maxTS <= minTS {
|
|
return 0
|
|
}
|
|
return float64(maxTS-minTS) / float64(count-1)
|
|
}
|
|
|
|
// dailySeries buckets a COUNT by UTC day over the trailing `days` window.
|
|
// timestamp is a unix-epoch integer, so strftime needs the 'unixepoch' modifier.
|
|
func (s *StatsService) dailySeries(db *sql.DB, query string, days int) ([]statsPoint, error) {
|
|
since := time.Now().AddDate(0, 0, -days).Unix()
|
|
rows, err := db.Query(query, since)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
points := make([]statsPoint, 0, days)
|
|
for rows.Next() {
|
|
var day string
|
|
var value int64
|
|
if err := rows.Scan(&day, &value); err != nil {
|
|
return nil, err
|
|
}
|
|
points = append(points, statsPoint{Date: day, DateTo: day, Value: fmt.Sprintf("%d", value)})
|
|
}
|
|
return points, rows.Err()
|
|
}
|
|
|
|
// ---- Handlers ----
|
|
|
|
func (s *StatsService) handleCounters(w http.ResponseWriter, r *http.Request) {
|
|
db, err := s.conn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{"counters": []statsCounter{}})
|
|
return
|
|
}
|
|
blocks := s.count(db, "evm_blocks")
|
|
txns := s.count(db, "evm_transactions")
|
|
addrs := s.count(db, "evm_addresses")
|
|
avgSec := s.avgBlockTimeSec(db)
|
|
|
|
counters := []statsCounter{
|
|
{ID: "total_blocks", Value: fmt.Sprintf("%d", blocks), Title: "Total blocks", Description: "All blocks indexed on the chain"},
|
|
{ID: "total_txns", Value: fmt.Sprintf("%d", txns), Title: "Total transactions", Description: "All transactions indexed on the chain"},
|
|
{ID: "completed_txns", Value: fmt.Sprintf("%d", txns), Title: "Completed transactions", Description: "Transactions included in a block"},
|
|
{ID: "total_addresses", Value: fmt.Sprintf("%d", addrs), Title: "Total addresses", Description: "Distinct addresses seen on the chain"},
|
|
{ID: "average_block_time", Value: fmt.Sprintf("%.1f", avgSec), Title: "Average block time", Units: "s", Description: "Average interval between blocks"},
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"counters": counters})
|
|
}
|
|
|
|
// lineInfos is the catalogue of charts this backend can serve, each backed by a
|
|
// real SQLite aggregation.
|
|
var lineInfos = map[string]statsLineInfo{
|
|
"newBlocksPerDay": {
|
|
ID: "newBlocksPerDay", Title: "New blocks per day",
|
|
Description: "Number of blocks produced each day", Resolutions: []string{"DAY"},
|
|
},
|
|
"newTxnsPerDay": {
|
|
ID: "newTxnsPerDay", Title: "New transactions per day",
|
|
Description: "Number of transactions each day", Resolutions: []string{"DAY"},
|
|
},
|
|
}
|
|
|
|
func (s *StatsService) handleLines(w http.ResponseWriter, r *http.Request) {
|
|
sections := []map[string]any{
|
|
{
|
|
"id": "blocks", "title": "Blocks",
|
|
"charts": []statsLineInfo{lineInfos["newBlocksPerDay"]},
|
|
},
|
|
{
|
|
"id": "transactions", "title": "Transactions",
|
|
"charts": []statsLineInfo{lineInfos["newTxnsPerDay"]},
|
|
},
|
|
}
|
|
writeJSON(w, http.StatusOK, map[string]any{"sections": sections})
|
|
}
|
|
|
|
func (s *StatsService) handleLine(w http.ResponseWriter, r *http.Request) {
|
|
id := r.PathValue("id")
|
|
info, ok := lineInfos[id]
|
|
if !ok {
|
|
writeJSON(w, http.StatusNotFound, map[string]any{"error": "unknown chart"})
|
|
return
|
|
}
|
|
db, err := s.conn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, statsLineChart{Chart: []statsPoint{}, Info: info})
|
|
return
|
|
}
|
|
|
|
var query string
|
|
switch id {
|
|
case "newBlocksPerDay":
|
|
query = `SELECT strftime('%Y-%m-%d', timestamp, 'unixepoch') d, COUNT(*)
|
|
FROM evm_blocks WHERE timestamp > ? GROUP BY d ORDER BY d`
|
|
case "newTxnsPerDay":
|
|
query = `SELECT strftime('%Y-%m-%d', b.timestamp, 'unixepoch') d, COUNT(*)
|
|
FROM evm_transactions t JOIN evm_blocks b ON t.block_number = b.number
|
|
WHERE b.timestamp > ? GROUP BY d ORDER BY d`
|
|
}
|
|
points, err := s.dailySeries(db, query, 365)
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, statsLineChart{Chart: []statsPoint{}, Info: info})
|
|
return
|
|
}
|
|
writeJSON(w, http.StatusOK, statsLineChart{Chart: points, Info: info})
|
|
}
|
|
|
|
func (s *StatsService) handlePagesMain(w http.ResponseWriter, r *http.Request) {
|
|
db, err := s.conn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{})
|
|
return
|
|
}
|
|
blocks := s.count(db, "evm_blocks")
|
|
txns := s.count(db, "evm_transactions")
|
|
addrs := s.count(db, "evm_addresses")
|
|
avgSec := s.avgBlockTimeSec(db)
|
|
daily, _ := s.dailySeries(db, `SELECT strftime('%Y-%m-%d', b.timestamp, 'unixepoch') d, COUNT(*)
|
|
FROM evm_transactions t JOIN evm_blocks b ON t.block_number = b.number
|
|
WHERE b.timestamp > ? GROUP BY d ORDER BY d`, 30)
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"total_blocks": statsCounter{ID: "total_blocks", Value: fmt.Sprintf("%d", blocks), Title: "Total blocks", Description: "All blocks"},
|
|
"total_transactions": statsCounter{ID: "total_txns", Value: fmt.Sprintf("%d", txns), Title: "Total transactions", Description: "All transactions"},
|
|
"total_addresses": statsCounter{ID: "total_addresses", Value: fmt.Sprintf("%d", addrs), Title: "Total addresses", Description: "Distinct addresses"},
|
|
"average_block_time": statsCounter{ID: "average_block_time", Value: fmt.Sprintf("%.1f", avgSec), Title: "Average block time", Units: "s", Description: "Average interval between blocks"},
|
|
"daily_new_transactions": statsLineChart{Chart: daily, Info: lineInfos["newTxnsPerDay"]},
|
|
})
|
|
}
|
|
|
|
func (s *StatsService) handlePagesTransactions(w http.ResponseWriter, r *http.Request) {
|
|
db, err := s.conn()
|
|
if err != nil {
|
|
writeJSON(w, http.StatusOK, map[string]any{})
|
|
return
|
|
}
|
|
var txns24h int64
|
|
db.QueryRow(`SELECT COUNT(*) FROM evm_transactions t JOIN evm_blocks b ON t.block_number = b.number
|
|
WHERE b.timestamp > ?`, time.Now().AddDate(0, 0, -1).Unix()).Scan(&txns24h)
|
|
writeJSON(w, http.StatusOK, map[string]any{
|
|
"transactions_24h": statsCounter{ID: "new_txns_24h", Value: fmt.Sprintf("%d", txns24h), Title: "Transactions (24h)", Description: "Transactions in the last 24 hours"},
|
|
})
|
|
}
|
|
|
|
func (s *StatsService) handlePagesContracts(w http.ResponseWriter, r *http.Request) {
|
|
writeJSON(w, http.StatusOK, map[string]any{})
|
|
}
|