feat(explorer): dynamic feeds, wallet SSR alignment, and detail pagination
Align wallet SSR with report token-list, dedupe featured v1 tokens, refresh home and wallet snapshots on a 60s cadence, and drive vanilla SPA chain add/watch from API metadata. Add shared pagination/tabs for address, token, and transaction pages, extend token aggregation helpers, and harden stats API with tests and health checks. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/explorer/backend/api/freshness"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
type explorerStats struct {
|
||||
@@ -34,6 +35,14 @@ type explorerGasPrices struct {
|
||||
|
||||
type statsQueryFunc = freshness.QueryRowFunc
|
||||
|
||||
type statsErrorRow struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (r statsErrorRow) Scan(dest ...any) error {
|
||||
return r.err
|
||||
}
|
||||
|
||||
func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) {
|
||||
var value sql.NullFloat64
|
||||
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
|
||||
@@ -191,23 +200,72 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
func loadExplorerStatsFallback(ctx context.Context, chainID int, cause error) explorerStats {
|
||||
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
|
||||
now := time.Now().UTC()
|
||||
queryErr := fmt.Errorf("blockscout database unavailable")
|
||||
if cause != nil {
|
||||
queryErr = cause
|
||||
}
|
||||
queryRow := func(context.Context, string, ...any) pgx.Row {
|
||||
return statsErrorRow{err: queryErr}
|
||||
}
|
||||
|
||||
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
|
||||
ctx,
|
||||
chainID,
|
||||
queryRow,
|
||||
func(ctx context.Context) (*freshness.Reference, error) {
|
||||
return freshness.ProbeChainHead(ctx, rpcURL)
|
||||
},
|
||||
now,
|
||||
nil,
|
||||
nil,
|
||||
)
|
||||
if err != nil {
|
||||
if sampling.Issues == nil {
|
||||
sampling.Issues = map[string]string{}
|
||||
}
|
||||
sampling.Issues["fallback_freshness"] = err.Error()
|
||||
}
|
||||
if sampling.Issues == nil {
|
||||
sampling.Issues = map[string]string{}
|
||||
}
|
||||
if cause != nil {
|
||||
sampling.Issues["stats_database"] = cause.Error()
|
||||
}
|
||||
|
||||
stats := explorerStats{
|
||||
Freshness: snapshot,
|
||||
Completeness: completeness,
|
||||
Sampling: sampling,
|
||||
Diagnostics: diagnostics,
|
||||
}
|
||||
if snapshot.ChainHead.BlockNumber != nil {
|
||||
stats.LatestBlock = *snapshot.ChainHead.BlockNumber
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
// handleStats handles GET /api/v2/stats
|
||||
func (s *Server) handleStats(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != http.MethodGet {
|
||||
writeMethodNotAllowed(w)
|
||||
return
|
||||
}
|
||||
if !s.requireDB(w) {
|
||||
return
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
||||
defer cancel()
|
||||
|
||||
stats, err := loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "service_unavailable", "explorer stats are temporarily unavailable")
|
||||
return
|
||||
var stats explorerStats
|
||||
if s.db == nil {
|
||||
stats = loadExplorerStatsFallback(ctx, s.chainID, fmt.Errorf("database pool is not configured"))
|
||||
} else {
|
||||
var err error
|
||||
stats, err = loadExplorerStats(ctx, s.chainID, s.db.QueryRow)
|
||||
if err != nil {
|
||||
stats = loadExplorerStatsFallback(ctx, s.chainID, err)
|
||||
}
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
|
||||
@@ -136,3 +136,33 @@ func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "query total transactions")
|
||||
}
|
||||
|
||||
func TestLoadExplorerStatsFallbackUsesRPCHead(t *testing.T) {
|
||||
rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Method string `json:"method"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(r.Body).Decode(&req))
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
switch req.Method {
|
||||
case "eth_blockNumber":
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x4d2"}`))
|
||||
case "eth_getBlockByNumber":
|
||||
ts := time.Now().Add(-3 * time.Second).Unix()
|
||||
_, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`))
|
||||
default:
|
||||
http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest)
|
||||
}
|
||||
}))
|
||||
defer rpc.Close()
|
||||
t.Setenv("RPC_URL", rpc.URL)
|
||||
|
||||
stats := loadExplorerStatsFallback(context.Background(), 138, errors.New("database down"))
|
||||
|
||||
require.Equal(t, int64(1234), stats.LatestBlock)
|
||||
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, int64(1234), *stats.Freshness.ChainHead.BlockNumber)
|
||||
require.Equal(t, freshness.CompletenessUnavailable, stats.Completeness.TransactionsFeed)
|
||||
require.Contains(t, stats.Sampling.Issues, "stats_database")
|
||||
require.Contains(t, stats.Sampling.Issues["latest_indexed_block"], "database down")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user