Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.

Made-with: Cursor
This commit is contained in:
defiQUG
2026-04-12 06:33:54 -07:00
parent f46bd213ba
commit ee71f098ab
63 changed files with 5163 additions and 826 deletions

View File

@@ -27,9 +27,9 @@ If the script doesn't work, see `START_HERE.md` for step-by-step manual commands
- **Production (canonical target):** the current **Next.js standalone frontend** in `frontend/src/`, built from `frontend/` with `npm run build` and deployed to VMID 5000 as a Node service behind nginx.
- **Canonical deploy script:** `./scripts/deploy-next-frontend-to-vmid5000.sh`
- **Canonical nginx wiring:** keep `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`; proxy `/` and `/_next/` to the frontend service using `deployment/common/nginx-next-frontend-proxy.conf`.
- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility, but it is no longer the preferred deployment target.
- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility/reference, but it is not a supported primary deployment target.
- **Architecture command center:** `frontend/public/chain138-command-center.html` — tabbed Mermaid topology (Chain 138 hub, network, stack, flows, cross-chain, cW Mainnet, off-chain, integrations). Linked from the SPA **More → Explore → Visual Command Center**.
- **Legacy static deploy:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `index.html` and assets to `/var/www/html/`)
- **Legacy static deploy scripts:** `./scripts/deploy-frontend-to-vmid5000.sh` and `./scripts/deploy.sh` now fail fast with a deprecation message and point to the canonical Next.js deploy path.
- **Frontend review & tasks:** [frontend/FRONTEND_REVIEW.md](frontend/FRONTEND_REVIEW.md), [frontend/FRONTEND_TASKS_AND_REVIEW.md](frontend/FRONTEND_TASKS_AND_REVIEW.md)
## Documentation

View File

@@ -31,7 +31,7 @@ If scripts don't work, follow `COMPLETE_DEPLOYMENT.md` for step-by-step manual e
- **`docs/README.md`** - Documentation overview and index
- **`docs/EXPLORER_API_ACCESS.md`** - API access, 502 fix, frontend deploy
- **Frontend deploy only:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `frontend/public/index.html` to VMID 5000)
- **Frontend deploy only:** `./scripts/deploy-next-frontend-to-vmid5000.sh` (builds and deploys the current Next standalone frontend to VMID 5000)
- `COMPLETE_DEPLOYMENT.md` - Complete step-by-step guide
- `DEPLOYMENT_FINAL_STATUS.md` - Deployment status report
- `RUN_ALL.md` - Quick reference
@@ -61,4 +61,3 @@ tail -f backend/logs/api-server.log
```
**All deployment steps are ready to execute!**

View File

@@ -0,0 +1,398 @@
package freshness
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
)
type QueryRowFunc func(ctx context.Context, sql string, args ...any) pgx.Row
type Confidence string
const (
ConfidenceHigh Confidence = "high"
ConfidenceMedium Confidence = "medium"
ConfidenceLow Confidence = "low"
ConfidenceUnknown Confidence = "unknown"
)
type Completeness string
const (
CompletenessComplete Completeness = "complete"
CompletenessPartial Completeness = "partial"
CompletenessStale Completeness = "stale"
CompletenessUnavailable Completeness = "unavailable"
)
type Source string
const (
SourceReported Source = "reported"
SourceDerived Source = "derived"
SourceSampled Source = "sampled"
SourceUnavailable Source = "unavailable"
)
type Provenance string
const (
ProvenanceRPC Provenance = "rpc"
ProvenanceExplorerIndex Provenance = "explorer_index"
ProvenanceTxIndex Provenance = "tx_index"
ProvenanceMissionFeed Provenance = "mission_control_feed"
ProvenanceComposite Provenance = "composite"
)
type Reference struct {
BlockNumber *int64 `json:"block_number"`
Timestamp *string `json:"timestamp"`
AgeSeconds *int64 `json:"age_seconds"`
Hash *string `json:"hash,omitempty"`
DistanceFromHead *int64 `json:"distance_from_head,omitempty"`
Source Source `json:"source"`
Confidence Confidence `json:"confidence"`
Provenance Provenance `json:"provenance"`
Completeness Completeness `json:"completeness,omitempty"`
}
type Snapshot struct {
ChainHead Reference `json:"chain_head"`
LatestIndexedBlock Reference `json:"latest_indexed_block"`
LatestIndexedTransaction Reference `json:"latest_indexed_transaction"`
LatestNonEmptyBlock Reference `json:"latest_non_empty_block"`
}
type SummaryCompleteness struct {
TransactionsFeed Completeness `json:"transactions_feed"`
BlocksFeed Completeness `json:"blocks_feed"`
GasMetrics Completeness `json:"gas_metrics"`
UtilizationMetric Completeness `json:"utilization_metrics"`
}
type Sampling struct {
StatsGeneratedAt *string `json:"stats_generated_at"`
RPCProbeAt *string `json:"rpc_probe_at"`
StatsWindowSec *int64 `json:"stats_window_seconds,omitempty"`
Issues map[string]string `json:"issues,omitempty"`
}
type HeadProbeFunc func(ctx context.Context) (*Reference, error)
func ptrInt64(value int64) *int64 { return &value }
func ptrString(value string) *string { return &value }
func unknownReference(provenance Provenance) Reference {
return Reference{
Source: SourceUnavailable,
Confidence: ConfidenceUnknown,
Provenance: provenance,
Completeness: CompletenessUnavailable,
}
}
func timePointer(value time.Time) *string {
if value.IsZero() {
return nil
}
formatted := value.UTC().Format(time.RFC3339)
return &formatted
}
func computeAge(timestamp *string, now time.Time) *int64 {
if timestamp == nil || *timestamp == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, *timestamp)
if err != nil {
return nil
}
age := int64(now.Sub(parsed).Seconds())
if age < 0 {
age = 0
}
return &age
}
func classifyIndexedVisibility(age *int64) Completeness {
if age == nil {
return CompletenessUnavailable
}
switch {
case *age <= 15*60:
return CompletenessComplete
case *age <= 3*60*60:
return CompletenessPartial
default:
return CompletenessStale
}
}
func classifyBlockFeed(chainHead *int64, indexedHead *int64) Completeness {
if chainHead == nil || indexedHead == nil {
return CompletenessUnavailable
}
distance := *chainHead - *indexedHead
if distance < 0 {
distance = 0
}
switch {
case distance <= 2:
return CompletenessComplete
case distance <= 32:
return CompletenessPartial
default:
return CompletenessStale
}
}
func classifyMetricPresence[T comparable](value *T) Completeness {
if value == nil {
return CompletenessUnavailable
}
return CompletenessComplete
}
func BuildSnapshot(
ctx context.Context,
chainID int,
queryRow QueryRowFunc,
probeHead HeadProbeFunc,
now time.Time,
averageGasPrice *float64,
utilization *float64,
) (Snapshot, SummaryCompleteness, Sampling, error) {
snapshot := Snapshot{
ChainHead: unknownReference(ProvenanceRPC),
LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex),
LatestIndexedTransaction: unknownReference(ProvenanceTxIndex),
LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex),
}
issues := map[string]string{}
if probeHead != nil {
if head, err := probeHead(ctx); err == nil && head != nil {
snapshot.ChainHead = *head
} else if err != nil {
issues["chain_head"] = err.Error()
}
}
var latestIndexedBlockNumber int64
var latestIndexedBlockTime time.Time
if err := queryRow(ctx,
`SELECT number, timestamp
FROM blocks
ORDER BY number DESC
LIMIT 1`,
).Scan(&latestIndexedBlockNumber, &latestIndexedBlockTime); err == nil {
timestamp := timePointer(latestIndexedBlockTime)
snapshot.LatestIndexedBlock = Reference{
BlockNumber: ptrInt64(latestIndexedBlockNumber),
Timestamp: timestamp,
AgeSeconds: computeAge(timestamp, now),
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceExplorerIndex,
Completeness: CompletenessComplete,
}
} else {
issues["latest_indexed_block"] = err.Error()
}
var latestTxHash string
var latestTxBlock int64
var latestTxCreatedAt time.Time
if err := queryRow(ctx,
`SELECT concat('0x', encode(hash, 'hex')), block_number::bigint, COALESCE(block_timestamp, inserted_at)
FROM transactions
WHERE block_number IS NOT NULL
ORDER BY block_number DESC, "index" DESC
LIMIT 1`,
).Scan(&latestTxHash, &latestTxBlock, &latestTxCreatedAt); err == nil {
timestamp := timePointer(latestTxCreatedAt)
snapshot.LatestIndexedTransaction = Reference{
BlockNumber: ptrInt64(latestTxBlock),
Timestamp: timestamp,
AgeSeconds: computeAge(timestamp, now),
Hash: ptrString(latestTxHash),
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceTxIndex,
Completeness: classifyIndexedVisibility(computeAge(timestamp, now)),
}
} else {
issues["latest_indexed_transaction"] = err.Error()
}
var latestNonEmptyBlockNumber int64
var latestNonEmptyBlockTime time.Time
if err := queryRow(ctx,
`SELECT b.number, b.timestamp
FROM blocks b
WHERE EXISTS (
SELECT 1
FROM transactions t
WHERE t.block_number = b.number
)
ORDER BY b.number DESC
LIMIT 1`,
).Scan(&latestNonEmptyBlockNumber, &latestNonEmptyBlockTime); err == nil {
timestamp := timePointer(latestNonEmptyBlockTime)
ref := Reference{
BlockNumber: ptrInt64(latestNonEmptyBlockNumber),
Timestamp: timestamp,
AgeSeconds: computeAge(timestamp, now),
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceTxIndex,
Completeness: classifyIndexedVisibility(computeAge(timestamp, now)),
}
if snapshot.ChainHead.BlockNumber != nil {
distance := *snapshot.ChainHead.BlockNumber - latestNonEmptyBlockNumber
if distance < 0 {
distance = 0
}
ref.DistanceFromHead = ptrInt64(distance)
}
snapshot.LatestNonEmptyBlock = ref
} else {
issues["latest_non_empty_block"] = err.Error()
}
statsGeneratedAt := now.UTC().Format(time.RFC3339)
sampling := Sampling{
StatsGeneratedAt: ptrString(statsGeneratedAt),
StatsWindowSec: ptrInt64(300),
}
if len(issues) > 0 {
sampling.Issues = issues
}
if snapshot.ChainHead.Timestamp != nil {
sampling.RPCProbeAt = snapshot.ChainHead.Timestamp
}
completeness := SummaryCompleteness{
TransactionsFeed: snapshot.LatestIndexedTransaction.Completeness,
BlocksFeed: classifyBlockFeed(snapshot.ChainHead.BlockNumber, snapshot.LatestIndexedBlock.BlockNumber),
GasMetrics: classifyMetricPresence(averageGasPrice),
UtilizationMetric: classifyMetricPresence(utilization),
}
return snapshot, completeness, sampling, nil
}
func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) {
rpcURL = strings.TrimSpace(rpcURL)
if rpcURL == "" {
return nil, fmt.Errorf("empty rpc url")
}
blockNumberRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_blockNumber", []interface{}{})
if err != nil {
return nil, err
}
var blockNumberHex string
if err := json.Unmarshal(blockNumberRaw, &blockNumberHex); err != nil {
return nil, err
}
blockNumber, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(blockNumberHex), "0x"), 16, 64)
if err != nil {
return nil, err
}
blockRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_getBlockByNumber", []interface{}{"latest", false})
if err != nil {
return nil, err
}
var latestBlock struct {
Timestamp string `json:"timestamp"`
}
if err := json.Unmarshal(blockRaw, &latestBlock); err != nil {
return nil, err
}
blockTimeHex := strings.TrimSpace(latestBlock.Timestamp)
if blockTimeHex == "" {
return nil, fmt.Errorf("missing block timestamp")
}
blockTimestamp, err := strconv.ParseInt(strings.TrimPrefix(blockTimeHex, "0x"), 16, 64)
if err != nil {
return nil, err
}
ts := time.Unix(blockTimestamp, 0).UTC()
timestamp := ts.Format(time.RFC3339)
now := time.Now().UTC()
age := int64(now.Sub(ts).Seconds())
if age < 0 {
age = 0
}
return &Reference{
BlockNumber: ptrInt64(blockNumber),
Timestamp: ptrString(timestamp),
AgeSeconds: ptrInt64(age),
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceRPC,
Completeness: CompletenessComplete,
}, nil
}
func postJSONRPC(ctx context.Context, rpcURL string, method string, params []interface{}) (json.RawMessage, int64, error) {
body, err := json.Marshal(map[string]interface{}{
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params,
})
if err != nil {
return nil, 0, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body))
if err != nil {
return nil, 0, err
}
req.Header.Set("Content-Type", "application/json")
client := &http.Client{Timeout: 6 * time.Second}
start := time.Now()
resp, err := client.Do(req)
latency := time.Since(start).Milliseconds()
if err != nil {
return nil, latency, err
}
defer resp.Body.Close()
payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return nil, latency, err
}
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
return nil, latency, fmt.Errorf("http %d", resp.StatusCode)
}
var out struct {
Result json.RawMessage `json:"result"`
Error *struct {
Message string `json:"message"`
} `json:"error"`
}
if err := json.Unmarshal(payload, &out); err != nil {
return nil, latency, err
}
if out.Error != nil && out.Error.Message != "" {
return nil, latency, fmt.Errorf("rpc error: %s", out.Error.Message)
}
return out.Result, latency, nil
}

View File

@@ -0,0 +1,192 @@
package freshness
import (
"context"
"testing"
"time"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
type fakeRow struct {
scan func(dest ...any) error
}
func (r fakeRow) Scan(dest ...any) error {
return r.scan(dest...)
}
func TestBuildSnapshotHealthyState(t *testing.T) {
now := time.Date(2026, 4, 10, 22, 10, 16, 0, time.UTC)
call := 0
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
call++
switch call {
case 1:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*int64) = 200
*dest[1].(*time.Time) = now.Add(-2 * time.Second)
return nil
}}
case 2:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*string) = "0xabc"
*dest[1].(*int64) = 198
*dest[2].(*time.Time) = now.Add(-5 * time.Second)
return nil
}}
case 3:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*int64) = 198
*dest[1].(*time.Time) = now.Add(-5 * time.Second)
return nil
}}
default:
t.Fatalf("unexpected call %d", call)
return nil
}
}
probe := func(context.Context) (*Reference, error) {
ts := now.Add(-1 * time.Second).Format(time.RFC3339)
age := int64(1)
block := int64(200)
return &Reference{
BlockNumber: &block,
Timestamp: &ts,
AgeSeconds: &age,
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceRPC,
Completeness: CompletenessComplete,
}, nil
}
snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
require.NoError(t, err)
require.Equal(t, int64(200), *snapshot.ChainHead.BlockNumber)
require.Equal(t, int64(198), *snapshot.LatestIndexedTransaction.BlockNumber)
require.Equal(t, int64(2), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
require.NotNil(t, sampling.StatsGeneratedAt)
}
func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
now := time.Date(2026, 4, 11, 0, 10, 16, 0, time.UTC)
call := 0
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
call++
switch call {
case 1:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*int64) = 3875999
*dest[1].(*time.Time) = now.Add(-3 * time.Second)
return nil
}}
case 2:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*string) = "0xstale"
*dest[1].(*int64) = 3860660
*dest[2].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
return nil
}}
case 3:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*int64) = 3860660
*dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
return nil
}}
default:
t.Fatalf("unexpected call %d", call)
return nil
}
}
probe := func(context.Context) (*Reference, error) {
ts := now.Add(-1 * time.Second).Format(time.RFC3339)
age := int64(1)
block := int64(3876000)
return &Reference{
BlockNumber: &block,
Timestamp: &ts,
AgeSeconds: &age,
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceRPC,
Completeness: CompletenessComplete,
}, nil
}
snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
require.NoError(t, err)
require.Equal(t, int64(15340), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
require.Equal(t, CompletenessStale, completeness.TransactionsFeed)
require.Equal(t, CompletenessComplete, completeness.BlocksFeed)
}
func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
now := time.Date(2026, 4, 10, 23, 10, 16, 0, time.UTC)
call := 0
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
call++
switch call {
case 1:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*int64) = 3875000
*dest[1].(*time.Time) = now.Add(-1 * time.Second)
return nil
}}
case 2:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*string) = "0xquiet"
*dest[1].(*int64) = 3874902
*dest[2].(*time.Time) = now.Add(-512 * time.Second)
return nil
}}
case 3:
return fakeRow{scan: func(dest ...any) error {
*dest[0].(*int64) = 3874902
*dest[1].(*time.Time) = now.Add(-512 * time.Second)
return nil
}}
default:
t.Fatalf("unexpected call %d", call)
return nil
}
}
probe := func(context.Context) (*Reference, error) {
ts := now.Add(-1 * time.Second).Format(time.RFC3339)
age := int64(1)
block := int64(3875000)
return &Reference{
BlockNumber: &block,
Timestamp: &ts,
AgeSeconds: &age,
Source: SourceReported,
Confidence: ConfidenceHigh,
Provenance: ProvenanceRPC,
Completeness: CompletenessComplete,
}, nil
}
snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
require.NoError(t, err)
require.Equal(t, int64(98), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
}
func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
return fakeRow{scan: func(dest ...any) error {
return pgx.ErrNoRows
}}
}
snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil)
require.NoError(t, err)
require.Nil(t, snapshot.ChainHead.BlockNumber)
require.Equal(t, CompletenessUnavailable, completeness.TransactionsFeed)
require.NotNil(t, sampling.StatsGeneratedAt)
}

View File

@@ -48,6 +48,46 @@ func tokenAggregationBase() string {
return ""
}
func looksLikeGenericUpstreamErrorPayload(body []byte) bool {
if len(bytes.TrimSpace(body)) == 0 {
return false
}
var payload map[string]any
if err := json.Unmarshal(body, &payload); err != nil {
return false
}
errValue, ok := payload["error"].(string)
if !ok || strings.TrimSpace(errValue) == "" {
return false
}
if _, ok := payload["pools"]; ok {
return false
}
if _, ok := payload["tokens"]; ok {
return false
}
if _, ok := payload["data"]; ok {
return false
}
if _, ok := payload["chains"]; ok {
return false
}
if _, ok := payload["tree"]; ok {
return false
}
if _, ok := payload["quote"]; ok {
return false
}
if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "healthy") {
return false
}
return true
}
func blockscoutInternalBase() string {
u := strings.TrimSpace(os.Getenv("BLOCKSCOUT_INTERNAL_URL"))
if u == "" {
@@ -156,6 +196,15 @@ func (s *Server) handleMissionControlLiquidityTokenPath(w http.ResponseWriter, r
if ctype == "" {
ctype = "application/json"
}
isGenericSuccessError := resp.StatusCode >= 200 && resp.StatusCode < 300 && looksLikeGenericUpstreamErrorPayload(body)
if isGenericSuccessError {
atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1)
log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_status=%d generic_error_envelope=true", strings.ToLower(addr), chain, resp.StatusCode)
w.Header().Set("Content-Type", ctype)
w.WriteHeader(http.StatusBadGateway)
_, _ = w.Write(body)
return
}
if resp.StatusCode == http.StatusOK {
liquidityPoolsCache.Store(cacheKey, liquidityCacheEntry{
body: body,

View File

@@ -98,6 +98,37 @@ func TestHandleMissionControlLiquidityTokenPathBypassesCacheWhenRequested(t *tes
require.Equal(t, 2, hitCount, "refresh=1 should force a fresh upstream read")
}
func TestHandleMissionControlLiquidityTokenPathTreatsGenericSuccessErrorEnvelopeAsBadGateway(t *testing.T) {
resetMissionControlTestGlobals()
var hitCount int
upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hitCount++
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"error":"Internal server error"}`))
}))
defer upstream.Close()
t.Setenv("TOKEN_AGGREGATION_BASE_URL", upstream.URL)
t.Setenv("CHAIN_ID", "138")
s := NewServer(nil, 138)
path := "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools"
w1 := httptest.NewRecorder()
s.handleMissionControlLiquidityTokenPath(w1, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusBadGateway, w1.Code)
require.Equal(t, "miss", w1.Header().Get("X-Mission-Control-Cache"))
require.JSONEq(t, `{"error":"Internal server error"}`, w1.Body.String())
w2 := httptest.NewRecorder()
s.handleMissionControlLiquidityTokenPath(w2, httptest.NewRequest(http.MethodGet, path, nil))
require.Equal(t, http.StatusBadGateway, w2.Code)
require.Equal(t, "miss", w2.Header().Get("X-Mission-Control-Cache"))
require.Equal(t, 2, hitCount, "generic error envelopes must not be cached as success")
}
func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) {
resetMissionControlTestGlobals()

View File

@@ -2,62 +2,190 @@ package rest
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/explorer/backend/api/freshness"
)
type explorerStats struct {
TotalBlocks int64 `json:"total_blocks"`
TotalTransactions int64 `json:"total_transactions"`
TotalAddresses int64 `json:"total_addresses"`
LatestBlock int64 `json:"latest_block"`
TotalBlocks int64 `json:"total_blocks"`
TotalTransactions int64 `json:"total_transactions"`
TotalAddresses int64 `json:"total_addresses"`
LatestBlock int64 `json:"latest_block"`
AverageBlockTime *float64 `json:"average_block_time,omitempty"`
GasPrices *explorerGasPrices `json:"gas_prices,omitempty"`
NetworkUtilizationPercentage *float64 `json:"network_utilization_percentage,omitempty"`
TransactionsToday *int64 `json:"transactions_today,omitempty"`
Freshness freshness.Snapshot `json:"freshness"`
Completeness freshness.SummaryCompleteness `json:"completeness"`
Sampling freshness.Sampling `json:"sampling"`
}
type statsQueryFunc func(ctx context.Context, sql string, args ...any) pgx.Row
type explorerGasPrices struct {
Average *float64 `json:"average,omitempty"`
}
type statsQueryFunc = freshness.QueryRowFunc
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 {
return nil, err
}
if !value.Valid {
return nil, nil
}
return &value.Float64, nil
}
func queryNullableInt64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*int64, error) {
var value sql.NullInt64
if err := queryRow(ctx, query, args...).Scan(&value); err != nil {
return nil, err
}
if !value.Valid {
return nil, nil
}
return &value.Int64, nil
}
func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc) (explorerStats, error) {
var stats explorerStats
_ = chainID
if err := queryRow(ctx,
`SELECT COUNT(*) FROM blocks WHERE chain_id = $1`,
chainID,
`SELECT COUNT(*) FROM blocks`,
).Scan(&stats.TotalBlocks); err != nil {
return explorerStats{}, fmt.Errorf("query total blocks: %w", err)
}
if err := queryRow(ctx,
`SELECT COUNT(*) FROM transactions WHERE chain_id = $1`,
chainID,
`SELECT COUNT(*) FROM transactions WHERE block_hash IS NOT NULL`,
).Scan(&stats.TotalTransactions); err != nil {
return explorerStats{}, fmt.Errorf("query total transactions: %w", err)
}
if err := queryRow(ctx,
`SELECT COUNT(*) FROM (
SELECT from_address AS address
SELECT from_address_hash AS address
FROM transactions
WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> ''
WHERE from_address_hash IS NOT NULL
UNION
SELECT to_address AS address
SELECT to_address_hash AS address
FROM transactions
WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> ''
WHERE to_address_hash IS NOT NULL
) unique_addresses`,
chainID,
).Scan(&stats.TotalAddresses); err != nil {
return explorerStats{}, fmt.Errorf("query total addresses: %w", err)
}
if err := queryRow(ctx,
`SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`,
chainID,
`SELECT COALESCE(MAX(number), 0) FROM blocks`,
).Scan(&stats.LatestBlock); err != nil {
return explorerStats{}, fmt.Errorf("query latest block: %w", err)
}
statsIssues := map[string]string{}
averageBlockTime, err := queryNullableFloat64(ctx, queryRow,
`SELECT CASE
WHEN COUNT(*) >= 2
THEN (EXTRACT(EPOCH FROM (MAX(timestamp) - MIN(timestamp))) * 1000.0) / NULLIF(COUNT(*) - 1, 0)
ELSE NULL
END
FROM (
SELECT timestamp
FROM blocks
ORDER BY number DESC
LIMIT 100
) recent_blocks`,
)
if err != nil {
statsIssues["average_block_time"] = err.Error()
} else {
stats.AverageBlockTime = averageBlockTime
}
averageGasPrice, err := queryNullableFloat64(ctx, queryRow,
`SELECT AVG(gas_price_wei)::double precision / 1000000000.0
FROM (
SELECT gas_price AS gas_price_wei
FROM transactions
WHERE block_hash IS NOT NULL
AND gas_price IS NOT NULL
ORDER BY block_number DESC, "index" DESC
LIMIT 1000
) recent_transactions`,
)
if err != nil {
statsIssues["average_gas_price"] = err.Error()
} else if averageGasPrice != nil {
stats.GasPrices = &explorerGasPrices{Average: averageGasPrice}
}
networkUtilization, err := queryNullableFloat64(ctx, queryRow,
`SELECT AVG((gas_used::double precision / NULLIF(gas_limit, 0)) * 100.0)
FROM (
SELECT gas_used, gas_limit
FROM blocks
WHERE gas_limit IS NOT NULL
AND gas_limit > 0
ORDER BY number DESC
LIMIT 100
) recent_blocks`,
)
if err != nil {
statsIssues["network_utilization_percentage"] = err.Error()
} else {
stats.NetworkUtilizationPercentage = networkUtilization
}
transactionsToday, err := queryNullableInt64(ctx, queryRow,
`SELECT COUNT(*)::bigint
FROM transactions t
JOIN blocks b
ON b.number = t.block_number
WHERE b.timestamp >= NOW() - INTERVAL '24 hours'`,
)
if err != nil {
statsIssues["transactions_today"] = err.Error()
} else {
stats.TransactionsToday = transactionsToday
}
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
snapshot, completeness, sampling, err := freshness.BuildSnapshot(
ctx,
chainID,
queryRow,
func(ctx context.Context) (*freshness.Reference, error) {
return freshness.ProbeChainHead(ctx, rpcURL)
},
time.Now().UTC(),
averageGasPrice,
networkUtilization,
)
if err != nil {
return explorerStats{}, fmt.Errorf("build freshness snapshot: %w", err)
}
if len(statsIssues) > 0 {
if sampling.Issues == nil {
sampling.Issues = map[string]string{}
}
for key, value := range statsIssues {
sampling.Issues[key] = value
}
}
stats.Freshness = snapshot
stats.Completeness = completeness
stats.Sampling = sampling
return stats, nil
}

View File

@@ -2,10 +2,17 @@ package rest
import (
"context"
"database/sql"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strconv"
"strings"
"testing"
"time"
"github.com/explorer/backend/api/freshness"
"github.com/jackc/pgx/v5"
"github.com/stretchr/testify/require"
)
@@ -19,23 +26,56 @@ func (r fakeStatsRow) Scan(dest ...any) error {
}
func TestLoadExplorerStatsReturnsValues(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":"0x2c"}`))
case "eth_getBlockByNumber":
ts := time.Now().Add(-2 * 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)
var call int
queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row {
call++
return fakeStatsRow{
scan: func(dest ...any) error {
target, ok := dest[0].(*int64)
require.True(t, ok)
switch call {
case 1:
*target = 11
*dest[0].(*int64) = 11
case 2:
*target = 22
*dest[0].(*int64) = 22
case 3:
*target = 33
*dest[0].(*int64) = 33
case 4:
*target = 44
*dest[0].(*int64) = 44
case 5:
*dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 2000, Valid: true}
case 6:
*dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 1.25, Valid: true}
case 7:
*dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 37.5, Valid: true}
case 8:
*dest[0].(*sql.NullInt64) = sql.NullInt64{Int64: 18, Valid: true}
case 9:
*dest[0].(*int64) = 44
*dest[1].(*time.Time) = time.Now().Add(-2 * time.Second)
case 10:
*dest[0].(*string) = "0xabc"
*dest[1].(*int64) = 40
*dest[2].(*time.Time) = time.Now().Add(-5 * time.Second)
case 11:
*dest[0].(*int64) = 40
*dest[1].(*time.Time) = time.Now().Add(-5 * time.Second)
default:
t.Fatalf("unexpected query call %d", call)
}
@@ -50,9 +90,25 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
require.Equal(t, int64(22), stats.TotalTransactions)
require.Equal(t, int64(33), stats.TotalAddresses)
require.Equal(t, int64(44), stats.LatestBlock)
require.NotNil(t, stats.AverageBlockTime)
require.Equal(t, 2000.0, *stats.AverageBlockTime)
require.NotNil(t, stats.GasPrices)
require.NotNil(t, stats.GasPrices.Average)
require.Equal(t, 1.25, *stats.GasPrices.Average)
require.NotNil(t, stats.NetworkUtilizationPercentage)
require.Equal(t, 37.5, *stats.NetworkUtilizationPercentage)
require.NotNil(t, stats.TransactionsToday)
require.Equal(t, int64(18), *stats.TransactionsToday)
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber)
require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead)
require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source))
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics)
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric)
}
func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) {
t.Setenv("RPC_URL", "")
queryRow := func(_ context.Context, query string, _ ...any) pgx.Row {
return fakeStatsRow{
scan: func(dest ...any) error {

View File

@@ -1,10 +1,13 @@
package rest
import (
"context"
"net/http"
"os"
"strings"
"time"
"github.com/explorer/backend/api/freshness"
"github.com/explorer/backend/api/middleware"
"github.com/explorer/backend/api/track1"
"github.com/explorer/backend/api/track2"
@@ -47,7 +50,27 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
}
rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter)
track1Server := track1.NewServer(rpcGateway)
track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
if s.db == nil {
return nil, nil, nil, nil
}
now := time.Now().UTC()
snapshot, completeness, sampling, err := freshness.BuildSnapshot(
ctx,
s.chainID,
s.db.QueryRow,
func(ctx context.Context) (*freshness.Reference, error) {
return freshness.ProbeChainHead(ctx, rpcURL)
},
now,
nil,
nil,
)
if err != nil {
return nil, nil, nil, err
}
return &snapshot, &completeness, &sampling, nil
})
// Track 1 routes (public, optional auth)
mux.HandleFunc("/api/v1/track1/blocks/latest", track1Server.HandleLatestBlocks)

View File

@@ -5,6 +5,8 @@ import (
"os"
"strings"
"time"
"github.com/explorer/backend/api/freshness"
)
func relaySnapshotStatus(relay map[string]interface{}) string {
@@ -129,6 +131,81 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
if ov := readOptionalVerifyJSON(); ov != nil {
data["operator_verify"] = ov
}
if s.freshnessLoader != nil {
if snapshot, completeness, sampling, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
subsystems := map[string]interface{}{
"rpc_head": map[string]interface{}{
"status": chainStatusFromProbe(p138),
"updated_at": valueOrNil(snapshot.ChainHead.Timestamp),
"age_seconds": valueOrNil(snapshot.ChainHead.AgeSeconds),
"source": snapshot.ChainHead.Source,
"confidence": snapshot.ChainHead.Confidence,
"provenance": snapshot.ChainHead.Provenance,
"completeness": snapshot.ChainHead.Completeness,
},
"tx_index": map[string]interface{}{
"status": completenessStatus(completeness.TransactionsFeed),
"updated_at": valueOrNil(snapshot.LatestIndexedTransaction.Timestamp),
"age_seconds": valueOrNil(snapshot.LatestIndexedTransaction.AgeSeconds),
"source": snapshot.LatestIndexedTransaction.Source,
"confidence": snapshot.LatestIndexedTransaction.Confidence,
"provenance": snapshot.LatestIndexedTransaction.Provenance,
"completeness": completeness.TransactionsFeed,
},
"stats_summary": map[string]interface{}{
"status": completenessStatus(completeness.BlocksFeed),
"updated_at": valueOrNil(sampling.StatsGeneratedAt),
"age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt),
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceMedium,
"provenance": freshness.ProvenanceComposite,
"completeness": completeness.BlocksFeed,
},
}
if len(sampling.Issues) > 0 {
subsystems["freshness_queries"] = map[string]interface{}{
"status": "degraded",
"updated_at": valueOrNil(sampling.StatsGeneratedAt),
"age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt),
"source": freshness.SourceDerived,
"confidence": freshness.ConfidenceMedium,
"provenance": freshness.ProvenanceComposite,
"completeness": freshness.CompletenessPartial,
"issues": sampling.Issues,
}
}
modeKind := "live"
modeReason := any(nil)
modeScope := any(nil)
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
modeKind = "snapshot"
modeReason = "live_homepage_stream_not_attached"
modeScope = "relay_monitoring_homepage_card_only"
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
"status": overall,
"updated_at": now,
"age_seconds": int64(0),
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceHigh,
"provenance": freshness.ProvenanceMissionFeed,
"completeness": freshness.CompletenessComplete,
}
}
data["freshness"] = snapshot
data["subsystems"] = subsystems
data["sampling"] = sampling
data["mode"] = map[string]interface{}{
"kind": modeKind,
"updated_at": now,
"age_seconds": int64(0),
"reason": modeReason,
"scope": modeScope,
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceHigh,
"provenance": freshness.ProvenanceMissionFeed,
}
}
}
if relays := FetchCCIPRelayHealths(ctx); relays != nil {
data["ccip_relays"] = relays
if ccip := primaryRelayHealth(relays); ccip != nil {
@@ -142,5 +219,58 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
}
}
}
if mode, ok := data["mode"].(map[string]interface{}); ok {
if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 {
mode["kind"] = "snapshot"
mode["reason"] = "live_homepage_stream_not_attached"
mode["scope"] = "relay_monitoring_homepage_card_only"
if subsystems, ok := data["subsystems"].(map[string]interface{}); ok {
subsystems["bridge_relay_monitoring"] = map[string]interface{}{
"status": data["status"],
"updated_at": now,
"age_seconds": int64(0),
"source": freshness.SourceReported,
"confidence": freshness.ConfidenceHigh,
"provenance": freshness.ProvenanceMissionFeed,
"completeness": freshness.CompletenessComplete,
}
}
}
}
return data
}
func valueOrNil[T any](value *T) any {
if value == nil {
return nil
}
return *value
}
func ageSinceRFC3339(value *string) any {
if value == nil || *value == "" {
return nil
}
parsed, err := time.Parse(time.RFC3339, *value)
if err != nil {
return nil
}
age := int64(time.Since(parsed).Seconds())
if age < 0 {
age = 0
}
return age
}
func completenessStatus(value freshness.Completeness) string {
switch value {
case freshness.CompletenessComplete:
return "operational"
case freshness.CompletenessPartial:
return "partial"
case freshness.CompletenessStale:
return "stale"
default:
return "unavailable"
}
}

View File

@@ -11,6 +11,7 @@ import (
"testing"
"time"
"github.com/explorer/backend/api/freshness"
"github.com/stretchr/testify/require"
)
@@ -145,7 +146,50 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
t.Setenv("CCIP_RELAY_HEALTH_URLS", "")
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
s := &Server{}
s := &Server{
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
now := time.Now().UTC().Format(time.RFC3339)
head := int64(16)
txBlock := int64(12)
distance := int64(4)
return &freshness.Snapshot{
ChainHead: freshness.Reference{
BlockNumber: &head,
Timestamp: &now,
AgeSeconds: func() *int64 { v := int64(1); return &v }(),
Source: freshness.SourceReported,
Confidence: freshness.ConfidenceHigh,
Provenance: freshness.ProvenanceRPC,
Completeness: freshness.CompletenessComplete,
},
LatestIndexedTransaction: freshness.Reference{
BlockNumber: &txBlock,
Timestamp: &now,
AgeSeconds: func() *int64 { v := int64(120); return &v }(),
Source: freshness.SourceReported,
Confidence: freshness.ConfidenceHigh,
Provenance: freshness.ProvenanceTxIndex,
Completeness: freshness.CompletenessPartial,
},
LatestNonEmptyBlock: freshness.Reference{
BlockNumber: &txBlock,
Timestamp: &now,
AgeSeconds: func() *int64 { v := int64(120); return &v }(),
DistanceFromHead: &distance,
Source: freshness.SourceReported,
Confidence: freshness.ConfidenceHigh,
Provenance: freshness.ProvenanceTxIndex,
Completeness: freshness.CompletenessPartial,
},
},
&freshness.SummaryCompleteness{
TransactionsFeed: freshness.CompletenessPartial,
BlocksFeed: freshness.CompletenessComplete,
},
&freshness.Sampling{StatsGeneratedAt: &now},
nil
},
}
got := s.BuildBridgeStatusData(context.Background())
ccip, ok := got["ccip_relay"].(map[string]interface{})
require.True(t, ok)
@@ -156,6 +200,9 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
probe, ok := ccip["url_probe"].(map[string]interface{})
require.True(t, ok)
require.Equal(t, true, probe["ok"])
require.Contains(t, got, "freshness")
require.Contains(t, got, "subsystems")
require.Contains(t, got, "mode")
}
func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
@@ -197,7 +244,11 @@ func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz")
s := &Server{}
s := &Server{
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
return nil, nil, nil, nil
},
}
got := s.BuildBridgeStatusData(context.Background())
require.Equal(t, "degraded", got["status"])
}

View File

@@ -12,6 +12,7 @@ import (
"time"
"github.com/ethereum/go-ethereum/common"
"github.com/explorer/backend/api/freshness"
"github.com/explorer/backend/libs/go-rpc-gateway"
)
@@ -19,13 +20,18 @@ var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`)
// Server handles Track 1 endpoints (uses RPC gateway from lib)
type Server struct {
rpcGateway *gateway.RPCGateway
rpcGateway *gateway.RPCGateway
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error)
}
// NewServer creates a new Track 1 server
func NewServer(rpcGateway *gateway.RPCGateway) *Server {
func NewServer(
rpcGateway *gateway.RPCGateway,
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error),
) *Server {
return &Server{
rpcGateway: rpcGateway,
rpcGateway: rpcGateway,
freshnessLoader: freshnessLoader,
}
}

View File

@@ -1,5 +1,22 @@
# Generic snippet: proxy /api/ to a backend (Blockscout, Go API, etc.)
# Include in your server block. Replace upstream host/port as needed.
#
# Keep the exact /api/v2/stats route on the Go-side explorer API when you need
# enriched freshness/completeness metadata while the rest of /api/v2/* stays on
# the Blockscout upstream.
location = /api/v2/stats {
proxy_pass http://127.0.0.1:8081/api/v2/stats;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type";
}
location /api/ {
proxy_pass http://127.0.0.1:4000;

View File

@@ -1,5 +1,10 @@
# API Errors Fix
> Historical note: this file documents legacy static-SPA fixes and deploy
> patterns. The canonical live frontend deployment is now
> `./scripts/deploy-next-frontend-to-vmid5000.sh`. Treat manual `index.html`
> copy steps here as compatibility history, not the primary operator path.
## Issues Fixed
### 1. `createSkeletonLoader is not defined` Error
@@ -54,12 +59,12 @@ bash explorer-monorepo/scripts/deploy-frontend-fix.sh
#### Option 1: Using Deployment Script (from Proxmox host)
```bash
cd /home/intlc/projects/proxmox/explorer-monorepo
bash scripts/deploy-frontend-to-vmid5000.sh
bash scripts/deploy-next-frontend-to-vmid5000.sh
```
#### Option 2: Manual Deployment (from VMID 5000)
```bash
# On VMID 5000, copy the file:
# Historical static-SPA compatibility only:
cp /path/to/explorer-monorepo/frontend/public/index.html /var/www/html/index.html
chown www-data:www-data /var/www/html/index.html
@@ -69,6 +74,7 @@ nginx -t && systemctl restart nginx
#### Option 3: Using SCP (from local machine)
```bash
# Historical static-SPA compatibility only:
scp explorer-monorepo/frontend/public/index.html root@192.168.11.140:/var/www/html/index.html
ssh root@192.168.11.140 "chown www-data:www-data /var/www/html/index.html && nginx -t && systemctl restart nginx"
```
@@ -113,4 +119,3 @@ Test the following scenarios:
- For ChainID 138, all API calls now use Blockscout REST API format
- Error handling includes retry buttons for better UX
- Skeleton loaders provide visual feedback during data loading

View File

@@ -29,10 +29,10 @@ inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/*
### Legacy Static Deploy
```bash
# From explorer-monorepo root
./scripts/deploy.sh
```
The historical static SPA deploy path is deprecated. The old scripts
`./scripts/deploy.sh` and `./scripts/deploy-frontend-to-vmid5000.sh` now fail
fast with a deprecation message and intentionally point operators back to the
canonical Next.js deploy path.
### Manual Deploy
@@ -40,21 +40,25 @@ inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/*
# Canonical Next deployment:
./scripts/deploy-next-frontend-to-vmid5000.sh
# Legacy static fallback only:
scp frontend/public/index.html root@192.168.11.140:/var/www/html/index.html
# Compatibility assets only:
# frontend/public/index.html
# frontend/public/explorer-spa.js
```
### Environment Variables
The deployment script uses these environment variables:
The canonical Next deployment script uses its own VM/host defaults and deploy
workflow. Prefer reviewing `scripts/deploy-next-frontend-to-vmid5000.sh`
directly instead of relying on the older static script env contract below.
Historical static-script environment variables:
- `IP`: Production server IP (default: 192.168.11.140)
- `DOMAIN`: Domain name (default: explorer.d-bis.org)
- `PASSWORD`: SSH password (default: L@kers2010)
```bash
IP=192.168.11.140 DOMAIN=explorer.d-bis.org ./scripts/deploy.sh
```
These applied to the deprecated static deploy script and are no longer the
recommended operator interface.
## Mission-control and Track 4 runtime wiring
@@ -89,7 +93,7 @@ If deployment fails, rollback to previous version:
```bash
ssh root@192.168.11.140
cp /var/www/html/index.html.backup.* /var/www/html/index.html
ls -1dt /opt/solacescanscout/frontend/releases/* | head
```
For the Next standalone path, restart the previous release by repointing

View File

@@ -1,5 +1,9 @@
# Explorer API Access Checklist and Fixes
> Mixed-era note: this document spans both the current Next frontend and older
> static-SPA CSP/deploy history. The canonical frontend deployment path is now
> `./scripts/deploy-next-frontend-to-vmid5000.sh`.
The frontend is reachable at **https://explorer.d-bis.org** (FQDN) or by **VM IP** (**http://192.168.11.140**). In both cases it needs the **Blockscout v2 API** at the same origin under `/api/`. If you see **502 Bad Gateway**, **no blocks/transactions feeds**, or "Failed to load", the API may be unreachable. Use this checklist to verify and restore access.
**See also:** [EXPLORER_API_REFERENCE.md](EXPLORER_API_REFERENCE.md) for the list of Blockscout v2 endpoints used by the frontend.
@@ -135,7 +139,7 @@ The ethers.js v5 UMD bundle from the CDN uses `eval`/`new Function()` for ABI de
If the browser still reports **“Content Security Policy blocks the use of 'eval'”** or **script-src blocked**:
1. **Redeploy the frontend** so the live site gets the current `index.html` (with the meta CSP including `'unsafe-eval'`). For VMID 5000, run **`scripts/deploy-frontend-to-vmid5000.sh`** (frontend-only) or **`scripts/complete-explorer-api-access.sh`** (full). Alternatively, copy `frontend/public/index.html` to the servers web root (e.g. `/var/www/html/`).
1. **Redeploy the frontend** so the live site gets the current frontend bundle and nginx/API wiring. For VMID 5000, run **`scripts/deploy-next-frontend-to-vmid5000.sh`** for the canonical frontend path or **`scripts/complete-explorer-api-access.sh`** for the broader API/nginx fix flow. References to `frontend/public/index.html` below are historical static-SPA compatibility details.
2. **Check what CSP the browser sees** DevTools → Network → select the document request (the HTML page) → Headers → **Response Headers**`Content-Security-Policy`. It should contain `'unsafe-eval'` in `script-src`. If the response has a CSP header **without** `'unsafe-eval'`, that header is coming from your server (nginx or app) or from a proxy (e.g. Cloudflare). Update the config that serves the explorer so its CSP includes `'unsafe-eval'`, then reload (hard refresh or incognito).
3. **If you use Cloudflare** In the dashboard, check Transform Rules, Page Rules, or Security → Settings for any **Content-Security-Policy** (or similar) header that might override the origin. Ensure that headers `script-src` includes `'unsafe-eval'`, or remove the override so the origin CSP is used.

View File

@@ -1,5 +1,10 @@
# Explorer Code Review
> Historical architecture snapshot: this review reflects a mixed Next.js +
> legacy static-SPA period. The live frontend is now the Next standalone app,
> while `frontend/public/index.html` and `frontend/public/explorer-spa.js`
> remain compatibility/reference assets only.
**Date:** 2025-02
**Scope:** Backend (Go), Frontend (Next.js + SPA), libs, deployment, CI.
@@ -11,8 +16,8 @@
|-------|------|--------|
| **API** | Go 1.22, net/http | REST API (blocks, transactions, addresses, search, stats, Etherscan compat, auth, feature flags). Tiered tracks (14) with optional/required auth. |
| **Indexer** | Go, go-ethereum, pgx | Listens to chain (RPC/WS), processes blocks/txs, writes to PostgreSQL. |
| **Frontend (live)** | Vanilla JS SPA | `frontend/public/index.html` — single HTML + inline script, deployed at https://explorer.d-bis.org. Uses Blockscout-style API, ethers.js from CDN, VMID 2201 RPC. |
| **Frontend (dev)** | Next.js 15, React, TypeScript | `frontend/src/` — app + pages router, dev/build only; uses shared libs (api-client, ui-primitives). |
| **Frontend (live)** | Next.js 15, React, TypeScript | `frontend/src/` — standalone deployment on VMID 5000; uses shared libs and the explorer-owned freshness/trust model. |
| **Frontend (compatibility)** | Vanilla JS SPA | `frontend/public/index.html` + `frontend/public/explorer-spa.js` — retained for compatibility/reference, not the primary live deployment path. |
| **Libs** | In-repo under `backend/libs/`, `frontend/libs/` | go-pgconfig, go-logging, go-chain-adapters, go-rpc-gateway, go-http-middleware, go-bridge-aggregator; frontend-api-client, frontend-ui-primitives. |
---
@@ -161,7 +166,7 @@
|------|--------|
| **Next.js workspace warning** | Done: Comment added in `frontend/next.config.js`; align package manager in frontend or ignore for dev/build. (Next 14 does not support `outputFileTracingRoot` in config; standalone trace uses project root.) |
| **CORS** | Done: `CORS_ALLOWED_ORIGIN` env in `server.go`; default `*`, set to e.g. `https://explorer.d-bis.org` to restrict. Documented in `deployment/ENVIRONMENT_TEMPLATE.env`. |
| **SPA file size** | Done: main app script extracted to `frontend/public/explorer-spa.js` (~3.5k lines); `index.html` now ~1.15k lines. Deploy scripts copy `explorer-spa.js` (e.g. `deploy-frontend-to-vmid5000.sh`, `deploy.sh`). |
| **SPA file size** | Historical compatibility asset: main app script extracted to `frontend/public/explorer-spa.js` (~3.5k lines); `index.html` now ~1.15k lines. The old deploy scripts are deprecated shims rather than active operator paths. |
| **SPA vs Next canonical** | Done: `README.md` states production serves the SPA, Next.js is for local dev and build validation only. |
| **CSP unsafe-eval** | Done: comment in `index.html` CSP meta updated: "Can be removed when moving to ethers v6 build (no UMD eval)." |
| **Further product work** | See `docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md` (i18n, event log decoding, token list, health endpoint, etc.). |

View File

@@ -0,0 +1,154 @@
# Explorer Dead-Ends, Gaps, and Orphans Audit
Date: 2026-04-11
This audit records the remaining pruning surface after the frontend trust,
freshness, and deployment-path cleanup work. The goal is to distinguish
high-signal cleanup targets from compatibility or historical assets that should
not be deleted casually.
## Canonical Live Paths
- Frontend deploy: `scripts/deploy-next-frontend-to-vmid5000.sh`
- Frontend runtime: `solacescanscout-frontend.service`
- Shared freshness/trust model:
- `frontend/src/utils/explorerFreshness.ts`
- `frontend/src/components/common/FreshnessTrustNote.tsx`
- `frontend/src/components/common/ActivityContextPanel.tsx`
- Explorer-owned freshness backend:
- `backend/api/freshness/`
- `backend/api/rest/stats.go`
- `backend/api/track1/bridge_status_data.go`
## Pruned in This Cleanup Series
- Deprecated static deploy scripts now fail fast and point to the canonical
Next deploy path:
- `scripts/deploy-frontend-to-vmid5000.sh`
- `scripts/deploy.sh`
- Removed relay-summary compatibility helpers from:
- `frontend/src/services/api/missionControl.ts`
- Removed duplicate route action from:
- `frontend/src/data/explorerOperations.ts`
- Hardened deploy build-lock behavior in:
- `scripts/deploy-next-frontend-to-vmid5000.sh`
## Dead-End Guidance Fixed
The following docs were updated to stop presenting deprecated static frontend
deployment as a current operator path:
- `docs/README.md`
- `docs/INDEX.md`
- `docs/DEPLOYMENT.md`
- `README_DEPLOYMENT.md`
## Remaining Historical / Compatibility Assets To Keep For Now
These are not current primary paths, but they still serve compatibility,
reference, or audit roles and should not be removed without a deliberate
migration decision:
- `frontend/public/index.html`
- `frontend/public/explorer-spa.js`
- `frontend/public/chain138-command-center.html`
- `deployment/common/nginx-api-location.conf`
## Remaining Gaps
### 0. Static compatibility assets are not orphaned yet
The following assets are still part of the runtime or deployment surface and
cannot be deleted safely in a pure pruning pass:
- `frontend/public/index.html`
- `frontend/public/explorer-spa.js`
- `frontend/public/chain138-command-center.html`
Current hard blockers:
- canonical deploy script still copies them:
- `scripts/deploy-next-frontend-to-vmid5000.sh`
- live product still links the command center:
- `frontend/src/components/common/Navbar.tsx`
- `frontend/src/components/common/Footer.tsx`
- `frontend/src/data/explorerOperations.ts`
- `frontend/src/pages/docs/index.tsx`
- compatibility/runtime verification still expects them:
- `scripts/verify-explorer-api-access.sh`
- several legacy remediation scripts still push the static SPA to
`/var/www/html/index.html`:
- `scripts/deploy-frontend-fix.sh`
- `scripts/fix-explorer-remote.sh`
- `scripts/fix-explorer-complete.sh`
- `scripts/complete-explorer-api-access.sh`
Recommendation:
- treat retirement of these assets as an explicit migration
- first decide whether the command center remains a supported public artifact
- then remove static-SPA push logic from the remediation scripts
- only after that delete the files and clean the remaining references
### 1. Historical docs still describe the old static SPA as if it were primary
These are not the best operator entry points anymore, but they appear to be
historical records, troubleshooting notes, or code-review artifacts rather than
active runbooks:
- `docs/FRONTEND_DEPLOYMENT_FIX.md`
- `docs/FRONTEND_FIXES_COMPLETE.md`
- `docs/API_ERRORS_FIX.md`
- `docs/EXPLORER_LOADING_TROUBLESHOOTING.md`
- `docs/EXPLORER_API_ACCESS.md`
- `docs/EXPLORER_CODE_REVIEW.md`
- `docs/EXPLORER_FRONTEND_TESTING.md`
- `docs/STRUCTURE.md`
- `docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md`
Recommendation:
- keep them for now
- a first banner-stamp sweep has already been applied to the highest-signal set
- only rewrite/delete them if we decide to retire the compatibility assets
### 2. Compatibility assets still create pruning ambiguity
The repo still contains both:
- the live Next frontend path
- the historical static SPA assets
Recommendation:
- keep the compatibility assets until all docs and operators no longer depend on
them for rollback/reference
- when retired, remove the assets and do a repo-wide `frontend/public/index.html`
reference cleanup in one explicit migration
### 3. Public routing ownership is still split
Freshness truth is now much cleaner, but public route ownership still spans:
- Blockscout-owned public API behavior
- explorer-owned `track1` / mission-control behavior
- Next frontend presentation logic
Recommendation:
- continue consolidating around the explorer-owned freshness contract
- treat backend source-of-truth wiring as the next cleanup frontier, not more
shell polish
## Orphaned / Removed Compatibility Paths Confirmed Gone
These frontend compatibility abstractions were fully removed and should not be
reintroduced:
- `getRelaySummary` in `frontend/src/services/api/missionControl.ts`
- `subscribeRelaySummary` in `frontend/src/services/api/missionControl.ts`
## Suggested Next Pruning Sweep
1. Stamp the historical static-SPA docs above with a clear banner:
`Historical static-SPA guidance; not the canonical deployment path.`
2. Decide whether `frontend/public/index.html` and `frontend/public/explorer-spa.js`
still have an operational rollback role.
3. If not, remove them in one explicit migration and clean all remaining
references repo-wide.
4. After that, re-run the dead-end/orphan audit and remove the remaining
compatibility mentions from troubleshooting docs.

View File

@@ -1,10 +1,15 @@
# Explorer Frontend Testing
> Historical note: this testing note captures legacy static-SPA routing
> behavior during the explorer transition. The canonical live frontend is now
> the Next standalone app deployed with
> `./scripts/deploy-next-frontend-to-vmid5000.sh`.
## Summary
Path-based URLs (e.g. `/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506`) now work on the explorer. The fix includes:
1. **SPA path-based routing** `applyHashRoute()` in `frontend/public/index.html` reads both `pathname` and `hash`, so `/address/0x...`, `/tx/0x...`, `/block/123`, etc. load correctly.
1. **SPA path-based routing** historically, `applyHashRoute()` in `frontend/public/index.html` read both `pathname` and `hash`, so `/address/0x...`, `/tx/0x...`, `/block/123`, etc. loaded correctly.
2. **Nginx SPA paths** Nginx serves `index.html` for `/address`, `/tx`, `/block`, `/token`, `/blocks`, `/transactions`, `/bridge`, `/weth`, `/watchlist`, and `/nft`.
3. **HTTP + HTTPS** Both HTTP (for internal tests) and HTTPS serve the SPA for these paths.

View File

@@ -1,5 +1,9 @@
# Explorer "Loading…" / "—" Troubleshooting
> Historical note: parts of this troubleshooting guide still refer to the old
> static-SPA deployment path. The current production frontend is the Next
> standalone app deployed with `./scripts/deploy-next-frontend-to-vmid5000.sh`.
When **`/api/v2/stats`** returns 200 with data but the SPA still shows "—" or "Loading blocks…" / "Loading transactions…" / "Loading bridge data…" / "Tokens: Loading…", the failure is in **frontend→API wiring** or **frontend runtime**.
## Expected UI (screenshots)
@@ -71,13 +75,13 @@ After editing `frontend/public/explorer-spa.js`, redeploy the frontend to VMID 5
```bash
# From repo root (with SSH to r630-02)
EXPLORER_VM_HOST=root@192.168.11.12 bash explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh
EXPLORER_VM_HOST=root@192.168.11.12 bash explorer-monorepo/scripts/deploy-next-frontend-to-vmid5000.sh
```
Or from the Proxmox host that runs VMID 5000:
```bash
/path/to/repo/explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh
/path/to/repo/explorer-monorepo/scripts/deploy-next-frontend-to-vmid5000.sh
```
Then hard-refresh the explorer (Ctrl+Shift+R / Cmd+Shift+R) and re-check Console + Network.

View File

@@ -1,9 +1,15 @@
# Frontend Deployment Fix
> Historical note: this document describes the legacy static-SPA deployment
> path. The canonical live frontend deployment is now
> `./scripts/deploy-next-frontend-to-vmid5000.sh`. Keep this file only as
> compatibility/audit reference unless you are deliberately working on the old
> static assets.
## Problem
The explorer at `https://explorer.d-bis.org/` shows "Page not found" because:
1. Nginx is proxying to Blockscout (port 4000) which serves its own UI
2. The custom frontend (`explorer-monorepo/frontend/public/index.html`) is not deployed to `/var/www/html/` on VMID 5000
2. The historical custom static frontend (`explorer-monorepo/frontend/public/index.html`) is not deployed to `/var/www/html/` on VMID 5000
## Solution
@@ -33,21 +39,21 @@ Deploy the custom frontend to `/var/www/html/index.html`:
**From Proxmox host:**
```bash
cd /home/intlc/projects/proxmox/explorer-monorepo
bash scripts/deploy-frontend-to-vmid5000.sh
bash scripts/deploy-next-frontend-to-vmid5000.sh
```
**Or manually from VMID 5000:**
```bash
# If you have access to the repo in VMID 5000
# Historical static-SPA compatibility only:
cp /home/intlc/projects/proxmox/explorer-monorepo/frontend/public/index.html /var/www/html/index.html
chown www-data:www-data /var/www/html/index.html
```
**Or using SSH from Proxmox host:**
```bash
# Using existing deploy script
# Deprecated static deploy shim; kept only for historical compatibility context
cd /home/intlc/projects/proxmox/explorer-monorepo
PASSWORD="L@kers2010" bash scripts/deploy.sh
bash scripts/deploy.sh
```
### Step 3: Verify
@@ -95,4 +101,3 @@ The updated nginx config:
## Files Modified
- `/etc/nginx/sites-available/blockscout` - Nginx configuration
- `/var/www/html/index.html` - Custom frontend (needs to be deployed)

View File

@@ -1,5 +1,10 @@
# Frontend Errors - Complete Fix Summary
> Historical note: this fix summary was written against the older static-SPA
> frontend. The canonical live frontend is now the Next standalone app on VMID
> 5000, while `frontend/public/index.html` remains a compatibility/reference
> asset.
**Date**: $(date)
**Status**: ✅ **ALL FIXES APPLIED**
@@ -139,4 +144,3 @@ Fetching blocks from Blockscout: https://explorer.d-bis.org/api/v2/blocks?page=1
---
**Status**: ✅ All frontend errors have been fixed and tested.

View File

@@ -17,7 +17,7 @@
- **[../frontend/FRONTEND_REVIEW.md](../frontend/FRONTEND_REVIEW.md)** - Frontend code review (SPA + React)
- **[../frontend/FRONTEND_TASKS_AND_REVIEW.md](../frontend/FRONTEND_TASKS_AND_REVIEW.md)** - Task list (C1L4) and detail review
- **Deploy frontend:** From repo root run `./scripts/deploy-frontend-to-vmid5000.sh`
- **Deploy frontend:** From repo root run `./scripts/deploy-next-frontend-to-vmid5000.sh`
- **Full fix (API + frontend):** `./scripts/complete-explorer-api-access.sh`
---
@@ -198,4 +198,3 @@
---
**Last Updated:** 2025-02-09

View File

@@ -29,11 +29,12 @@ Overview of documentation for the ChainID 138 Explorer (SolaceScan).
Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`, then proxy `/` and `/_next/` using [deployment/common/nginx-next-frontend-proxy.conf](/home/intlc/projects/proxmox/explorer-monorepo/deployment/common/nginx-next-frontend-proxy.conf).
**Legacy static SPA deploy (fallback only):**
**Legacy static SPA compatibility assets:**
```bash
./scripts/deploy-frontend-to-vmid5000.sh
```
The historical static SPA (`frontend/public/index.html` +
`frontend/public/explorer-spa.js`) remains in-repo for compatibility and audit
reference only. The old static deploy scripts are deprecated shims and are not
supported deployment targets.
**Full explorer/API fix (Blockscout + nginx + frontend):**
@@ -61,4 +62,4 @@ Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggreg
---
*Last updated: 2025-02-09*
*Last updated: 2026-04-11*

View File

@@ -1,5 +1,9 @@
# Reusable Components Extraction Plan
> Historical planning note: references in this file to the explorer SPA or old
> deploy scripts describe the pre-Next transition state. The canonical live
> frontend deployment is now `./scripts/deploy-next-frontend-to-vmid5000.sh`.
**Completion status (in-repo):** All libs are present under `backend/libs/` and `frontend/libs/`. Backend is wired to use **go-pgconfig** (API + indexer), **go-rpc-gateway** (Track 1). Frontend is wired to use **frontend-api-client** (services/api/client) and **frontend-ui-primitives** (all pages using Card, Table, Address). CI uses `submodules: recursive`; README documents clone with submodules. To publish as separate repos, copy each lib to its own repo and add as submodule.
**Review and test:** Backend handlers that need the DB use `requireDB(w)`; without a DB they return 503. Tests run with a nil DB and accept 200/503/404 as appropriate. Run backend tests: `go test ./...` in `backend/`. Frontend build: `npm run build` in `frontend/` (ESLint uses root `.eslintrc.cjs` and frontend `"root": true` in `.eslintrc.json`). E2E: `npm run e2e` from repo root (Playwright, default base URL https://explorer.d-bis.org; set `EXPLORER_URL` for local).
@@ -216,7 +220,7 @@ To minimize breakage and respect dependencies:
- **Backend:** All REST route handlers, track* endpoint logic, indexer (listener + processor + backfill), Blockscout/etherscan compatibility, explorer-specific config (chain_id 138, RPC URLs), and migrations (schema stays here; libs use interfaces or config).
- **Frontend:** All pages and views, `public/index.html` SPA, explorer API service modules (blocks, transactions, addresses), Next.js app and deployment config.
- **Deployment:** All explorer- and VMID 5000specific scripts (`fix-502-blockscout.sh`, `complete-explorer-api-access.sh`, `deploy-frontend-to-vmid5000.sh`, etc.), and nginx/config that reference explorer.d-bis.org and Blockscout.
- **Deployment:** All explorer- and VMID 5000specific scripts (`fix-502-blockscout.sh`, `complete-explorer-api-access.sh`, legacy `deploy-frontend-to-vmid5000.sh`, etc.), and nginx/config that reference explorer.d-bis.org and Blockscout.
- **Docs:** All current documentation (API access, deployment, runbook, etc.).
---

View File

@@ -1,13 +1,18 @@
# Monorepo Structure
> Historical note: this file still reflects the earlier static-SPA-oriented
> layout. The canonical live frontend is now the Next app in `frontend/src/`,
> deployed via `./scripts/deploy-next-frontend-to-vmid5000.sh`.
## Directory Overview
### `/frontend`
Frontend application code.
- **`public/`**: Static HTML, CSS, JavaScript files served directly
- `index.html`: Main explorer interface
- **`src/`**: Source files (if using build tools like webpack, vite, etc.)
- **`public/`**: Compatibility/reference static assets
- `index.html`: Historical static explorer interface
- `explorer-spa.js`: Historical extracted SPA script
- **`src/`**: Canonical Next.js frontend source
- **`assets/`**: Images, fonts, and other static assets
### `/backend`
@@ -19,7 +24,8 @@ Backend services (if needed for future enhancements).
### `/scripts`
Deployment and utility scripts.
- **`deploy.sh`**: Deploy explorer to production
- **`deploy-next-frontend-to-vmid5000.sh`**: Canonical frontend deploy
- **`deploy.sh`**: Deprecated static deploy shim
- **`test.sh`**: Test explorer functionality
### `/docs`
@@ -65,7 +71,7 @@ explorer-monorepo/
### Frontend Changes
1. Edit `frontend/public/index.html` directly (current approach)
1. Edit `frontend/src/` for the live frontend (current approach)
2. Or set up build tools in `frontend/src/` for compiled output
### Backend Changes
@@ -77,4 +83,3 @@ explorer-monorepo/
1. Add docs to `docs/` directory
2. Update README.md as needed

View File

@@ -1,5 +1,9 @@
# Tiered Architecture Implementation Summary
> Historical note: this implementation summary was written during the
> static-SPA/Next transition. References to `frontend/public/index.html`
> describe legacy feature-gating work, not the canonical live frontend path.
## Overview
The SolaceScan Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control.
@@ -41,7 +45,7 @@ All components have been implemented according to the plan:
- **Security**: IP whitelist and audit logging integrated
### ✅ Phase 7: Frontend & Integration
- **Frontend Feature Gating**: Wallet connect UI and track-based feature visibility (`frontend/public/index.html`)
- **Frontend Feature Gating**: Wallet connect UI and track-based feature visibility (historically in `frontend/public/index.html`, now carried forward in the Next frontend shell)
- **Route Integration**: Track-aware routing structure (`backend/api/rest/routes.go`)
## Architecture
@@ -84,7 +88,7 @@ Backend
- `backend/database/migrations/0010_track_schema.auth_only.sql` - shared Blockscout DB auth/operator subset
### Frontend
- Updated `frontend/public/index.html` with feature gating
- Updated the historical static SPA `frontend/public/index.html` with feature gating during the transition period
## Next Steps

View File

@@ -0,0 +1,530 @@
# Explorer Freshness And Diagnostics Contract
This document defines the minimum public freshness and diagnostics payloads the SolaceScan frontend needs in order to present chain activity, transaction visibility, and snapshot posture without relying on frontend inference.
It is intended to close the remaining gap between:
- a frontend that now renders and explains state honestly, and
- upstream APIs that still omit authoritative freshness metadata for several critical surfaces.
## Goal
The frontend should be able to answer these questions directly from public API fields:
1. Is the chain head current?
2. When was the latest visible transaction indexed?
3. What is the latest non-empty block?
4. Is the homepage using a live feed, a snapshot, or mixed evidence?
5. Which subsystem is stale: RPC, indexing, relay monitoring, or stats?
6. Which values are reported directly vs inferred vs unavailable?
The frontend should not have to infer these from a combination of:
- `/api/v2/stats`
- `/api/v2/main-page/blocks`
- `/api/v2/main-page/transactions`
- `/explorer-api/v1/track1/bridge/status`
unless there is no backend alternative.
## Design Principles
- Prefer explicit freshness fields over derived heuristics.
- Separate chain freshness from indexed-transaction freshness.
- Distinguish reported facts from inferred or partial facts.
- Make incompleteness first-class.
- Keep the contract calm and operational, not alarmist.
## Proposed Public Endpoints
Two additions are recommended.
### 1. Extend `GET /api/v2/stats`
This endpoint already feeds the homepage summary cards. It should become the authoritative public summary for chain freshness and indexed activity freshness.
### 2. Extend `GET /explorer-api/v1/track1/bridge/status`
This endpoint already powers Mission Control. It should expose snapshot/feed posture and subsystem freshness more directly.
If backend implementation prefers separation, these fields may instead be exposed from a new endpoint:
`GET /explorer-api/v1/track1/observability/freshness`
The frontend does not require a separate endpoint as long as the fields below are available from a stable public contract.
## Required Additions To `/api/v2/stats`
### Current gaps
The current `stats` payload gives totals, but it does not reliably expose:
- latest indexed transaction timestamp
- latest non-empty block
- authoritative utilization freshness
- confidence/completeness metadata
### Required fields
```json
{
"total_blocks": 3873353,
"total_transactions": 52391,
"total_addresses": 10294,
"latest_block": 3873353,
"average_block_time": 2000,
"gas_prices": {
"slow": 0.02,
"average": 0.03,
"fast": 0.05
},
"network_utilization_percentage": 0,
"transactions_today": 18,
"freshness": {
"chain_head": {
"block_number": 3873353,
"timestamp": "2026-04-10T21:42:15Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"latest_indexed_transaction": {
"hash": "0x...",
"block_number": 3858013,
"timestamp": "2026-04-10T12:31:05Z",
"age_seconds": 33070,
"source": "reported",
"confidence": "high"
},
"latest_non_empty_block": {
"block_number": 3858013,
"timestamp": "2026-04-10T12:31:05Z",
"age_seconds": 33070,
"distance_from_head": 15340,
"source": "reported",
"confidence": "high"
},
"latest_indexed_block": {
"block_number": 3873353,
"timestamp": "2026-04-10T21:42:15Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
}
},
"completeness": {
"transactions_feed": "complete",
"blocks_feed": "complete",
"gas_metrics": "partial",
"utilization_metrics": "partial"
},
"sampling": {
"stats_generated_at": "2026-04-10T21:42:16Z",
"stats_window_seconds": 300,
"rpc_probe_at": "2026-04-10T21:42:15Z"
}
}
```
## Field Semantics
### `freshness.chain_head`
The latest chain head known from the authoritative public RPC or canonical head source.
This is the answer to:
- "Is the chain alive?"
- "Is head visibility current?"
### `freshness.latest_indexed_transaction`
The most recent transaction currently visible in the public indexed transaction feed.
This is the answer to:
- "How recent is the latest visible transaction?"
### `freshness.latest_non_empty_block`
The most recent indexed block containing one or more transactions.
This is the answer to:
- "Are head blocks empty because the chain is quiet?"
- "What is the last block with visible activity?"
### `freshness.latest_indexed_block`
The latest block successfully indexed into the explorer's public block dataset.
This disambiguates:
- current chain head
- current explorer indexed head
### `completeness.*`
Simple public-facing availability states for each summary subsystem:
- `complete`
- `partial`
- `stale`
- `unavailable`
These should not be interpreted as outage severity; they describe data completeness only.
### `sampling.*`
Metadata for when the summary itself was generated and what freshness window it represents.
## Required Additions To Mission Control Payload
Mission Control currently provides useful relay detail, but the homepage still infers snapshot scope and partial feed posture from surrounding evidence.
### Required fields
```json
{
"data": {
"status": "degraded",
"checked_at": "2026-04-10T21:42:16Z",
"mode": {
"kind": "snapshot",
"updated_at": "2026-04-10T21:42:16Z",
"age_seconds": 1,
"reason": "live_homepage_stream_not_attached",
"scope": "relay_monitoring_homepage_card_only",
"source": "reported",
"confidence": "high"
},
"subsystems": {
"rpc_head": {
"status": "operational",
"updated_at": "2026-04-10T21:42:15Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"tx_index": {
"status": "stale",
"updated_at": "2026-04-10T12:31:05Z",
"age_seconds": 33070,
"source": "reported",
"confidence": "high"
},
"bridge_relay_monitoring": {
"status": "degraded",
"updated_at": "2026-04-10T21:42:16Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"stats_summary": {
"status": "partial",
"updated_at": "2026-04-10T21:42:16Z",
"age_seconds": 1,
"source": "reported",
"confidence": "medium"
}
}
}
}
```
## Required Enumerations
These enums should be consistent across public surfaces.
### Activity interpretation
- `active`
- `quiet`
- `sparse_activity`
- `fresh_head_stale_tx_visibility`
- `limited_observability`
This value should be emitted only when the backend can support it directly. Otherwise the frontend may continue to derive it as a presentation layer.
### Data source confidence
- `high`
- `medium`
- `low`
- `unknown`
### Data origin
- `reported`
- `inferred`
- `sampled`
- `unavailable`
### Completeness
- `complete`
- `partial`
- `stale`
- `unavailable`
## Example Payloads
These examples are intended to accelerate frontend/backend alignment by showing how the contract should represent common live states.
### Example A: Healthy Live State
```json
{
"freshness": {
"chain_head": {
"block_number": 3874000,
"timestamp": "2026-04-10T22:10:14Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"latest_indexed_block": {
"block_number": 3874000,
"timestamp": "2026-04-10T22:10:14Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"latest_indexed_transaction": {
"hash": "0x...",
"block_number": 3873998,
"timestamp": "2026-04-10T22:10:10Z",
"age_seconds": 5,
"source": "reported",
"confidence": "high"
},
"latest_non_empty_block": {
"block_number": 3873998,
"timestamp": "2026-04-10T22:10:10Z",
"age_seconds": 5,
"distance_from_head": 2,
"source": "reported",
"confidence": "high"
}
},
"completeness": {
"transactions_feed": "complete",
"blocks_feed": "complete",
"gas_metrics": "complete",
"utilization_metrics": "complete"
}
}
```
### Example B: Quiet Chain But Current
```json
{
"freshness": {
"chain_head": {
"block_number": 3875000,
"timestamp": "2026-04-10T23:10:14Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"latest_indexed_block": {
"block_number": 3875000,
"timestamp": "2026-04-10T23:10:14Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"latest_indexed_transaction": {
"hash": "0x...",
"block_number": 3874902,
"timestamp": "2026-04-10T23:01:42Z",
"age_seconds": 512,
"source": "reported",
"confidence": "high"
},
"latest_non_empty_block": {
"block_number": 3874902,
"timestamp": "2026-04-10T23:01:42Z",
"age_seconds": 512,
"distance_from_head": 98,
"source": "reported",
"confidence": "high"
}
},
"activity_interpretation": "quiet"
}
```
### Example C: Fresh Head, Stale Transaction Visibility
```json
{
"freshness": {
"chain_head": {
"block_number": 3876000,
"timestamp": "2026-04-11T00:10:14Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"latest_indexed_block": {
"block_number": 3875999,
"timestamp": "2026-04-11T00:10:12Z",
"age_seconds": 3,
"source": "reported",
"confidence": "high"
},
"latest_indexed_transaction": {
"hash": "0x...",
"block_number": 3860660,
"timestamp": "2026-04-10T15:02:10Z",
"age_seconds": 32900,
"source": "reported",
"confidence": "high"
},
"latest_non_empty_block": {
"block_number": 3860660,
"timestamp": "2026-04-10T15:02:10Z",
"age_seconds": 32900,
"distance_from_head": 15340,
"source": "reported",
"confidence": "high"
}
},
"activity_interpretation": "fresh_head_stale_tx_visibility",
"completeness": {
"transactions_feed": "stale",
"blocks_feed": "complete",
"gas_metrics": "partial",
"utilization_metrics": "partial"
}
}
```
### Example D: Snapshot Mode State
```json
{
"data": {
"status": "degraded",
"checked_at": "2026-04-11T00:10:15Z",
"mode": {
"kind": "snapshot",
"updated_at": "2026-04-11T00:10:15Z",
"age_seconds": 1,
"reason": "live_homepage_stream_not_attached",
"scope": "relay_monitoring_homepage_card_only",
"source": "reported",
"confidence": "high"
},
"subsystems": {
"rpc_head": {
"status": "operational",
"updated_at": "2026-04-11T00:10:14Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
},
"tx_index": {
"status": "stale",
"updated_at": "2026-04-10T15:02:10Z",
"age_seconds": 32900,
"source": "reported",
"confidence": "high"
},
"bridge_relay_monitoring": {
"status": "degraded",
"updated_at": "2026-04-11T00:10:15Z",
"age_seconds": 1,
"source": "reported",
"confidence": "high"
}
}
}
}
```
## Frontend Usage Rules
Once the fields above exist, the frontend should follow these rules:
1. Use backend freshness fields directly where present.
2. Stop deriving latest transaction age from the transactions page feed when `freshness.latest_indexed_transaction` is available.
3. Stop deriving last non-empty block from recent block scanning when `freshness.latest_non_empty_block` is available.
4. Use `mode.kind`, `mode.reason`, and `mode.scope` directly for homepage snapshot messaging.
5. Use `source` and `confidence` badges only where they improve trust and do not clutter.
## Backward-Compatible Rollout Plan
### Phase A
Add fields without removing any current keys:
- extend `/api/v2/stats`
- extend bridge status payload with `mode` and `subsystems`
### Phase B
Frontend prefers new fields when available and falls back to inference when absent.
### Phase C
Once fields are consistently present in production:
- reduce frontend inference paths
- remove duplicate explanatory fallback logic where it is no longer needed
## Minimum Viable Backend Implementation
If full rollout is not possible immediately, the minimum high-leverage addition is:
### `/api/v2/stats`
- `freshness.chain_head`
- `freshness.latest_indexed_transaction`
- `freshness.latest_non_empty_block`
- `sampling.stats_generated_at`
### `/explorer-api/v1/track1/bridge/status`
- `mode.kind`
- `mode.updated_at`
- `mode.reason`
- `mode.scope`
That alone would materially reduce frontend ambiguity.
## Why This Contract Matters
The frontend now presents state honestly enough that the remaining ambiguity is no longer visual. It is contractual.
Without these fields, the UI must keep inferring:
- whether the chain is quiet or stale
- whether the homepage is in snapshot mode because of relay posture or indexing posture
- whether low activity is real or a visibility gap
With these fields, the product becomes:
- more trustworthy
- easier to evaluate externally
- less likely to be misread as broken
## Summary
The next backend milestone is not broad API expansion. It is a targeted public freshness contract.
The public explorer needs explicit answers for:
- current chain head
- current indexed head
- latest visible transaction
- last non-empty block
- snapshot/feed mode
- subsystem freshness/completeness
That is the smallest backend addition with the highest frontend trust impact.

View File

@@ -0,0 +1,278 @@
# Explorer Freshness Implementation Checklist
This checklist converts the freshness contract into a backend implementation plan against the current SolaceScan code paths:
- [stats.go](/home/intlc/projects/proxmox/explorer-monorepo/backend/api/rest/stats.go)
- [mission_control.go](/home/intlc/projects/proxmox/explorer-monorepo/backend/api/rest/mission_control.go)
Use this document as the handoff from frontend trust requirements to backend delivery.
See also:
- [EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md)
- [track-api-contracts.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/track-api-contracts.md)
## Scope
This checklist covers four buckets:
1. field ownership and source of truth
2. response-shape rollout
3. freshness semantics
4. confidence and completeness behavior
## Bucket 1: Field Ownership And Source Of Truth
| Field | Endpoint | Backend owner | Source of truth | Directly measured or derived | Cadence | Nullable | Frontend dependency |
| --- | --- | --- | --- | --- | --- | --- | --- |
| `freshness.chain_head.block_number` | `/api/v2/stats` | `stats.go` with RPC helper | authoritative public RPC head | directly measured | per stats request or short cache | no | homepage head freshness, blocks/trust cues |
| `freshness.chain_head.timestamp` | `/api/v2/stats` | `stats.go` with RPC helper | authoritative public RPC head block timestamp | directly measured | per stats request or short cache | no | head age, chain visibility |
| `freshness.latest_indexed_block.block_number` | `/api/v2/stats` | `stats.go` | explorer DB `MAX(blocks.number)` | directly measured | per stats request | no | distinguish head vs indexed head |
| `freshness.latest_indexed_block.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed block timestamp | directly measured | per stats request | yes until wired | detail-page trust cues |
| `freshness.latest_indexed_transaction.hash` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | activity summary |
| `freshness.latest_indexed_transaction.block_number` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | tx freshness explanation |
| `freshness.latest_indexed_transaction.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | tx age, stale tx visibility |
| `freshness.latest_non_empty_block.block_number` | `/api/v2/stats` | `stats.go` | explorer DB latest block where `transaction_count > 0` or equivalent join | derived from indexed block/tx data | per stats request | yes | quiet-chain vs stale-visibility interpretation |
| `freshness.latest_non_empty_block.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest non-empty block row | derived from indexed block/tx data | per stats request | yes | recent activity framing |
| `freshness.latest_non_empty_block.distance_from_head` | `/api/v2/stats` | `stats.go` | computed from chain head minus last non-empty block | derived | per stats request | yes | homepage block-gap explanation |
| `completeness.transactions_feed` | `/api/v2/stats` | `stats.go` | comparison of tx freshness vs head freshness | derived | per stats request | no | trust badges |
| `completeness.blocks_feed` | `/api/v2/stats` | `stats.go` | indexed block freshness vs chain head freshness | derived | per stats request | no | trust badges |
| `completeness.gas_metrics` | `/api/v2/stats` | `stats.go` | gas fields presence and quality | derived | per stats request | no | gas card honesty |
| `completeness.utilization_metrics` | `/api/v2/stats` | `stats.go` | utilization field presence and quality | derived | per stats request | no | utilization card honesty |
| `sampling.stats_generated_at` | `/api/v2/stats` | `stats.go` | server clock at response generation | directly measured | per response | no | “updated” copy |
| `sampling.rpc_probe_at` | `/api/v2/stats` | `stats.go` | latest successful RPC sample timestamp | directly measured or nullable | per stats request | yes | source confidence |
| `mode.kind` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | mission-control feed mode | directly measured if known, otherwise derived conservatively | per response / SSE tick | no | snapshot/live messaging |
| `mode.updated_at` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | mission-control snapshot timestamp | directly measured | per response | no | snapshot age |
| `mode.reason` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | bridge/homepage mode controller | directly measured if available, else nullable | per response | yes | scope explanation |
| `mode.scope` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | bridge/homepage mode controller | directly measured if available, else nullable | per response | yes | “what is affected?” |
| `subsystems.rpc_head.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | RPC probe result | directly measured | per response | no | mission-control trust cues |
| `subsystems.tx_index.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` using stats freshness or shared helper | explorer DB tx freshness | derived from authoritative indexed data | per response / shared cache | yes | homepage stale-tx explanation |
| `subsystems.bridge_relay_monitoring.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | existing relay probe payload | directly measured | per response | no | lane posture |
| `subsystems.stats_summary.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` or shared summary helper | stats freshness sample | derived | per response | yes | homepage summary confidence |
## Bucket 2: Response-Shape Rollout
### Ship immediately as nullable additions
These are low-risk additive fields that can be introduced without breaking existing clients.
- `freshness.latest_indexed_transaction.*`
- `freshness.latest_non_empty_block.*`
- `freshness.latest_indexed_block.timestamp`
- `sampling.stats_generated_at`
- `sampling.rpc_probe_at`
- `mode.kind`
- `mode.updated_at`
- `mode.reason`
- `mode.scope`
- `subsystems.*`
### Ship after backend wiring
These need real data acquisition or shared helpers.
- `freshness.chain_head.*`
- `completeness.transactions_feed`
- `completeness.blocks_feed`
- `completeness.gas_metrics`
- `completeness.utilization_metrics`
### Derived computations
These may be computed in backend code once the authoritative inputs exist.
- `freshness.latest_non_empty_block.distance_from_head`
- subsystem `status`
- completeness enums
- optional `activity_interpretation`
### Frontend adoption order
1. Prefer new fields when present.
2. Fall back to current inference when absent.
3. Remove inference once fields are stable in production.
## Bucket 3: Freshness Semantics
Each field must answer a precise question.
### `freshness.chain_head`
- Meaning: latest chain head observed from the authoritative public RPC
- Must not mean: latest indexed explorer block
- If unknown: return `null` object members where needed plus completeness/confidence state
### `freshness.latest_indexed_block`
- Meaning: latest block successfully indexed into the explorer DB or visible explorer block source
- Must not mean: latest RPC head
### `freshness.latest_indexed_transaction`
- Meaning: latest transaction currently visible in the public indexed transaction feed
- Must not mean: latest mempool event or latest raw RPC tx if not visible in the explorer feed
### `freshness.latest_non_empty_block`
- Meaning: latest indexed block containing at least one visible indexed transaction
- This is the critical disambiguator for quiet-chain vs stale-visibility interpretation
### `mode.kind`
- Meaning: the current homepage/mission-control delivery mode
- Allowed values: `live`, `snapshot`, `mixed`, `unknown`
### `mode.scope`
- Meaning: which user-visible surface is affected by mode choice
- Examples:
- `relay_monitoring_homepage_card_only`
- `homepage_summary_only`
- `bridge_monitoring_and_homepage`
### `mode.reason`
- Meaning: why snapshot or mixed mode is active
- Must be calm and operational, not blame-oriented
- Examples:
- `live_homepage_stream_not_attached`
- `relay_snapshot_only_source`
- `partial_observability_inputs`
### `subsystems.*`
- Meaning: freshness of each component, not overall product health
- Recommended subsystem keys:
- `rpc_head`
- `tx_index`
- `bridge_relay_monitoring`
- `stats_summary`
## Bucket 4: Confidence And Completeness
Every nullable or derived field should have explicit semantics.
### Confidence
- `high`: authoritative source and recent sample
- `medium`: authoritative source but partially stale, or a stable derived value from strong inputs
- `low`: weakly derived or missing one of the underlying inputs
- `unknown`: no basis to express confidence
### Completeness
- `complete`: field is current and supported by recent source data
- `partial`: field exists but some required inputs are missing or weak
- `stale`: field is known, but the latest available value is older than acceptable freshness
- `unavailable`: no trustworthy value exists
### Null and zero handling
- Unknown must be `null`, not synthetic `0`
- Zero may be returned only when zero is a real measured value
- If a value is null, a sibling completeness/confidence field must explain why
## Acceptance Tests
These should be implemented in backend tests and used as rollout gates.
### 1. Current head, stale tx visibility
If chain head is current but tx visibility is stale:
- `freshness.chain_head` must be current
- `freshness.latest_indexed_transaction` must be older
- `freshness.latest_non_empty_block` must be exposed
- completeness must not report all feeds as `complete`
### 2. Quiet chain, current visibility
If recent head blocks are genuinely empty:
- `freshness.chain_head` must still be current
- `freshness.latest_non_empty_block` must be present
- `freshness.latest_indexed_transaction` must be present
- API must not force a stale diagnosis if visibility itself is current
### 3. Snapshot mode active
If snapshot mode is active:
- `mode.kind` must be `snapshot` or `mixed`
- `mode.scope` must state what is affected
- `mode.reason` must be present if known
### 4. Unknown fields
If a field is unknown:
- return `null`
- expose confidence/completeness state
- do not return fake zero values
## Backend Implementation Checklist
### `stats.go`
- [ ] Extend `explorerStats` with nullable freshness/completeness/sampling fields.
- [ ] Add query/helper for latest indexed transaction.
- [ ] Add query/helper for latest non-empty block.
- [ ] Add query/helper for latest indexed block timestamp.
- [ ] Add RPC helper for current chain head number and timestamp.
- [ ] Compute `distance_from_head` when both chain head and latest non-empty block are present.
- [ ] Compute completeness enums for blocks, transactions, gas metrics, and utilization.
- [ ] Return `null` for unknowns rather than synthetic zero values.
- [ ] Add internal tests covering:
- healthy current state
- quiet-chain state
- stale tx visibility state
- null/unknown field handling
### `mission_control.go`
- [ ] Extend bridge status response with `mode`.
- [ ] Extend bridge status response with `subsystems`.
- [ ] Reuse or call shared freshness helper for tx index freshness rather than duplicating logic.
- [ ] Emit `mode.scope` and `mode.reason` only when backend can support them.
- [ ] Use `unknown` or nullable values when reason/scope cannot be stated authoritatively.
- [ ] Add tests covering:
- live mode
- snapshot mode
- mixed mode
- tx index stale while RPC head remains current
### Shared rollout
- [ ] Frontend reads new fields opportunistically.
- [ ] Existing frontend inference remains as fallback until backend fields are stable.
- [ ] Swagger/OpenAPI docs updated after implementation.
- [ ] Public docs updated only after payload shape is live.
## Test Coverage Guidance
For every field, capture:
- who computes it
- from what source
- at what cadence
- whether nullable or required
- fallback behavior
- confidence/completeness semantics
- frontend dependency
- backend test case name
That metadata is more important than perfect initial coverage breadth.
## Shortest Path To Value
If the team wants the fastest possible trust win, implement these first:
1. `freshness.chain_head`
2. `freshness.latest_indexed_transaction`
3. `freshness.latest_non_empty_block`
4. `sampling.stats_generated_at`
5. `mode.kind`
6. `mode.scope`
7. `mode.reason`
That is the minimum set that lets the frontend stop guessing about the most visible freshness ambiguity.

View File

@@ -1,5 +1,7 @@
# Track API Contracts
See also: [EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md) for the public freshness and observability fields required by the current SolaceScan frontend.
Complete API contract definitions for all 4 tracks of SolaceScan Explorer.
## Track 1: Public Meta Explorer (No Auth Required)

View File

@@ -0,0 +1,137 @@
import Link from 'next/link'
import { Card } from '@/libs/frontend-ui-primitives'
import EntityBadge from '@/components/common/EntityBadge'
import type { ChainActivityContext } from '@/utils/activityContext'
import { formatRelativeAge, formatTimestamp } from '@/utils/format'
import { Explain, useUiMode } from './UiModeContext'
function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' {
switch (state) {
case 'active':
return 'success'
case 'low':
case 'inactive':
return 'warning'
default:
return 'neutral'
}
}
function resolveLabel(state: ChainActivityContext['state']): string {
switch (state) {
case 'active':
return 'active'
case 'low':
return 'low activity'
case 'inactive':
return 'inactive'
default:
return 'unknown'
}
}
function renderHeadline(context: ChainActivityContext): string {
if (context.transaction_visibility_unavailable) {
return 'Transaction index freshness is currently unavailable, while chain-head visibility remains live.'
}
if (context.state === 'unknown') {
return 'Recent activity context is temporarily unavailable.'
}
if (context.state === 'active') {
return 'Recent transactions are close to the visible chain tip.'
}
if (context.head_is_idle) {
return 'The chain head is advancing, but the latest visible transaction is older than the current tip.'
}
return 'Recent transaction activity is sparse right now.'
}
export default function ActivityContextPanel({
context,
title = 'Chain Activity Context',
}: {
context: ChainActivityContext
title?: string
}) {
const { mode } = useUiMode()
const tone = resolveTone(context.state)
const dualTimelineLabel =
context.latest_block_timestamp && context.latest_transaction_timestamp
? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx`
: 'Dual timeline unavailable'
return (
<Card className="border border-sky-200 bg-sky-50/60 dark:border-sky-900/40 dark:bg-sky-950/20" title={title}>
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div>
<div className="text-base font-semibold text-gray-900 dark:text-white">{renderHeadline(context)}</div>
<Explain>
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
Use the transaction tip and last non-empty block below to distinguish a quiet chain from a broken explorer.
</p>
</Explain>
</div>
<EntityBadge label={resolveLabel(context.state)} tone={tone} />
</div>
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Block</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{formatRelativeAge(context.latest_block_timestamp)}
</div>
</div>
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Latest Transaction</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{formatRelativeAge(context.latest_transaction_timestamp)}
</div>
</div>
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Last Non-Empty Block</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number}` : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{formatRelativeAge(context.last_non_empty_block_timestamp)}
</div>
</div>
<div className="rounded-2xl border border-white/50 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Block Gap</div>
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
{context.block_gap_to_latest_transaction != null ? context.block_gap_to_latest_transaction.toLocaleString() : 'Unknown'}
</div>
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
{mode === 'guided'
? 'Difference between the current tip and the latest visible transaction block.'
: dualTimelineLabel}
</div>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm text-gray-600 dark:text-gray-400">
{context.latest_transaction_block_number != null ? (
<Link href={`/blocks/${context.latest_transaction_block_number}`} className="text-primary-600 hover:underline">
Open latest transaction block
</Link>
) : null}
{context.last_non_empty_block_number != null ? (
<Link href={`/blocks/${context.last_non_empty_block_number}`} className="text-primary-600 hover:underline">
Open last non-empty block
</Link>
) : null}
{context.latest_transaction_timestamp ? (
<span>Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)}</span>
) : null}
</div>
</div>
</Card>
)
}

View File

@@ -0,0 +1,27 @@
import BrandMark from './BrandMark'
export default function BrandLockup({ compact = false }: { compact?: boolean }) {
return (
<>
<BrandMark size={compact ? 'compact' : 'default'} />
<span className="min-w-0">
<span
className={[
'block truncate font-semibold tracking-[-0.02em] text-gray-950 dark:text-white',
compact ? 'text-[1.45rem]' : 'text-[1.65rem]',
].join(' ')}
>
SolaceScan
</span>
<span
className={[
'block truncate font-medium uppercase text-gray-500 dark:text-gray-400',
compact ? 'text-[0.72rem] tracking-[0.14em]' : 'text-[0.8rem] tracking-[0.12em]',
].join(' ')}
>
Chain 138 Explorer by DBIS
</span>
</span>
</>
)
}

View File

@@ -0,0 +1,45 @@
export default function BrandMark({ size = 'default' }: { size?: 'default' | 'compact' }) {
const containerClassName =
size === 'compact'
? 'h-10 w-10 rounded-xl'
: 'h-11 w-11 rounded-2xl'
const iconClassName = size === 'compact' ? 'h-6 w-6' : 'h-7 w-7'
return (
<span
className={[
'relative inline-flex shrink-0 items-center justify-center border border-primary-200/70 bg-white text-primary-600 shadow-[0_10px_30px_rgba(37,99,235,0.10)] transition-transform group-hover:-translate-y-0.5 dark:border-primary-500/20 dark:bg-gray-900 dark:text-primary-400',
containerClassName,
].join(' ')}
>
<svg className={iconClassName} viewBox="0 0 32 32" fill="none" aria-hidden>
<path
d="M16 4.75 7.5 9.2v9.55L16 23.2l8.5-4.45V9.2L16 4.75Z"
stroke="currentColor"
strokeWidth="1.8"
/>
<path
d="m7.75 9.45 8.25 4.3 8.25-4.3"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
/>
<path d="M16 13.9v9" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" />
<path
d="M22.75 6.8c2.35 1.55 3.9 4.2 3.9 7.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
opacity=".9"
/>
<path
d="M9.35 6.8c-2.3 1.55-3.85 4.2-3.85 7.2"
stroke="currentColor"
strokeWidth="1.6"
strokeLinecap="round"
opacity=".65"
/>
</svg>
</span>
)
}

View File

@@ -2,22 +2,25 @@ import type { ReactNode } from 'react'
import Navbar from './Navbar'
import Footer from './Footer'
import ExplorerAgentTool from './ExplorerAgentTool'
import { UiModeProvider } from './UiModeContext'
export default function ExplorerChrome({ children }: { children: ReactNode }) {
return (
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
>
Skip to content
</a>
<Navbar />
<div id="main-content" className="flex-1">
{children}
<UiModeProvider>
<div className="flex min-h-screen flex-col bg-gray-50 text-gray-900 dark:bg-gray-900 dark:text-gray-100">
<a
href="#main-content"
className="sr-only focus:not-sr-only focus:absolute focus:left-4 focus:top-4 focus:z-[100] focus:rounded-md focus:bg-primary-600 focus:px-4 focus:py-2 focus:text-sm focus:font-medium focus:text-white"
>
Skip to content
</a>
<Navbar />
<div id="main-content" className="flex-1">
{children}
</div>
<ExplorerAgentTool />
<Footer />
</div>
<ExplorerAgentTool />
<Footer />
</div>
</UiModeProvider>
)
}

View File

@@ -0,0 +1,85 @@
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import type { ExplorerStats } from '@/services/api/stats'
import type { ChainActivityContext } from '@/utils/activityContext'
import {
resolveFreshnessSourceLabel,
summarizeFreshnessConfidence,
} from '@/utils/explorerFreshness'
import { formatRelativeAge } from '@/utils/format'
function buildSummary(context: ChainActivityContext) {
if (context.transaction_visibility_unavailable) {
return 'Chain-head visibility is current, while transaction freshness is currently unavailable.'
}
if (context.state === 'active') {
return 'Chain head and latest indexed transactions are closely aligned.'
}
if (context.head_is_idle) {
return 'Chain head is current, while latest visible transactions trail the tip.'
}
if (context.state === 'low' || context.state === 'inactive') {
return 'Chain head is current, and recent visible transaction activity is sparse.'
}
return 'Freshness context is based on the latest visible public explorer evidence.'
}
function buildDetail(context: ChainActivityContext) {
if (context.transaction_visibility_unavailable) {
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
}
const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp)
const latestNonEmptyBlock =
context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown'
if (context.head_is_idle) {
return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.`
}
if (context.state === 'active') {
return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.`
}
return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.`
}
export default function FreshnessTrustNote({
context,
stats,
bridgeStatus,
scopeLabel,
className = '',
}: {
context: ChainActivityContext
stats?: ExplorerStats | null
bridgeStatus?: MissionControlBridgeStatusResponse | null
scopeLabel?: string
className?: string
}) {
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
const normalizedClassName = className ? ` ${className}` : ''
return (
<div className={`rounded-2xl border border-gray-200 bg-white/80 px-4 py-3 text-sm dark:border-gray-800 dark:bg-gray-950/40${normalizedClassName}`}>
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
<div className="mt-1 text-gray-600 dark:text-gray-400">
{buildDetail(context)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
{confidenceBadges.map((badge) => (
<span
key={badge}
className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 dark:border-gray-700 dark:bg-gray-900/70"
>
{badge}
</span>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,202 @@
'use client'
import { useRouter } from 'next/navigation'
import { useEffect, useMemo, useRef, useState } from 'react'
import { Explain, useUiMode } from './UiModeContext'
export type HeaderCommandItem = {
href?: string
label: string
description?: string
section: string
keywords?: string[]
onSelect?: () => void | Promise<void>
}
function SearchIcon({ className = 'h-4 w-4' }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.9} d="m21 21-4.35-4.35" />
<circle cx="11" cy="11" r="6.5" strokeWidth={1.9} />
</svg>
)
}
function matchItem(item: HeaderCommandItem, query: string) {
const haystack = `${item.label} ${item.description || ''} ${item.section} ${(item.keywords || []).join(' ')}`.toLowerCase()
return haystack.includes(query.toLowerCase())
}
export default function HeaderCommandPalette({
open,
onClose,
items,
}: {
open: boolean
onClose: () => void
items: HeaderCommandItem[]
}) {
const router = useRouter()
const { mode } = useUiMode()
const inputRef = useRef<HTMLInputElement | null>(null)
const itemRefs = useRef<Array<HTMLButtonElement | null>>([])
const [query, setQuery] = useState('')
const [activeIndex, setActiveIndex] = useState(0)
const filteredItems = useMemo(() => {
const matches = query.trim()
? items.filter((item) => matchItem(item, query))
: items
return [
{
href: `/search${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`,
label: query.trim() ? `Search for “${query.trim()}` : 'Open full explorer search',
description: query.trim()
? 'Jump to the full search surface with the current query.'
: 'Open the full search page and browse the explorer index.',
section: 'Search',
keywords: ['query', 'find', 'lookup'],
},
...matches,
]
}, [items, query])
useEffect(() => {
if (!open) {
setQuery('')
setActiveIndex(0)
return
}
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
event.preventDefault()
onClose()
}
}
document.addEventListener('keydown', handleKeyDown)
requestAnimationFrame(() => inputRef.current?.focus())
return () => document.removeEventListener('keydown', handleKeyDown)
}, [onClose, open])
useEffect(() => {
setActiveIndex(0)
}, [query])
useEffect(() => {
if (!open) return
itemRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' })
}, [activeIndex, open])
if (!open) return null
const handleSelect = async (item: HeaderCommandItem) => {
onClose()
if (item.onSelect) {
await item.onSelect()
return
}
if (item.href) {
router.push(item.href)
}
}
return (
<div className="fixed inset-0 z-[80] flex items-start justify-center bg-gray-950/45 px-4 py-20 backdrop-blur-sm">
<div
role="dialog"
aria-modal="true"
aria-label="Explorer command palette"
className="w-full max-w-2xl overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-[0_30px_100px_rgba(15,23,42,0.32)] dark:border-gray-700 dark:bg-gray-950"
>
<div className="border-b border-gray-200 px-5 py-4 dark:border-gray-800">
<label htmlFor="header-command-search" className="sr-only">
Search explorer destinations
</label>
<div className="flex items-center gap-3 rounded-2xl border border-gray-200 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900">
<SearchIcon className="h-5 w-5 text-gray-500 dark:text-gray-400" />
<input
id="header-command-search"
ref={inputRef}
value={query}
onChange={(event) => setQuery(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'ArrowDown') {
event.preventDefault()
setActiveIndex((index) => Math.min(index + 1, filteredItems.length - 1))
}
if (event.key === 'ArrowUp') {
event.preventDefault()
setActiveIndex((index) => Math.max(index - 1, 0))
}
if (event.key === 'Enter') {
event.preventDefault()
const activeItem = filteredItems[activeIndex]
if (activeItem) void handleSelect(activeItem)
}
}}
placeholder={mode === 'expert' ? 'Search tx / addr / block / tool' : 'Search pages, tools, tokens, and routes'}
className="w-full border-0 bg-transparent text-sm text-gray-900 placeholder:text-gray-500 focus:outline-none dark:text-white dark:placeholder:text-gray-400"
/>
<kbd className="rounded-lg border border-gray-200 px-2 py-1 text-[11px] font-medium uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
Esc
</kbd>
</div>
<Explain>
<p className="mt-3 text-xs leading-5 text-gray-500 dark:text-gray-400">
Search destinations and run high-frequency header actions from one keyboard-first surface.
</p>
</Explain>
</div>
<div className="max-h-[60vh] overflow-y-auto p-3">
<div className="grid gap-1.5">
{filteredItems.map((item, index) => (
<button
key={`${item.section}-${item.label}-${item.href || item.label}`}
ref={(node) => {
itemRefs.current[index] = node
}}
type="button"
onMouseEnter={() => setActiveIndex(index)}
onClick={() => void handleSelect(item)}
className={[
'flex w-full items-start gap-3 rounded-2xl px-4 py-3 text-left transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500',
activeIndex === index
? 'bg-primary-50 text-primary-900 dark:bg-primary-500/10 dark:text-primary-100'
: 'bg-white text-gray-800 hover:bg-gray-100 dark:bg-gray-950 dark:text-gray-100 dark:hover:bg-gray-900',
].join(' ')}
>
<span className="mt-0.5 inline-flex rounded-lg border border-gray-200 px-2 py-1 text-[11px] font-semibold uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
{item.section}
</span>
<span className="min-w-0">
<span className="block font-semibold">{item.label}</span>
{mode === 'guided' && item.description ? (
<span className="mt-0.5 block text-xs leading-5 text-gray-500 dark:text-gray-400">
{item.description}
</span>
) : null}
</span>
</button>
))}
</div>
</div>
<div className="border-t border-gray-200 px-5 py-3 text-[11px] uppercase tracking-[0.16em] text-gray-500 dark:border-gray-800 dark:text-gray-400">
{mode === 'expert' ? 'Keyboard-first ' : 'Use '}
<kbd className="rounded border border-gray-200 px-1.5 py-0.5 font-medium dark:border-gray-700">/</kbd> or{' '}
<kbd className="rounded border border-gray-200 px-1.5 py-0.5 font-medium dark:border-gray-700">Ctrl/Cmd + K</kbd>{' '}
{mode === 'expert' ? 'to reopen.' : 'to reopen this palette.'}
</div>
</div>
<button
type="button"
onClick={onClose}
aria-label="Close command palette"
className="fixed inset-0 -z-10 cursor-default"
/>
</div>
)
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,57 @@
'use client'
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
export type UiMode = 'guided' | 'expert'
const UI_MODE_STORAGE_KEY = 'explorer_ui_mode'
const UiModeContext = createContext<{
mode: UiMode
setMode: (mode: UiMode) => void
toggleMode: () => void
} | null>(null)
export function UiModeProvider({ children }: { children: ReactNode }) {
const [mode, setModeState] = useState<UiMode>('guided')
useEffect(() => {
if (typeof window === 'undefined') return
const stored = window.localStorage.getItem(UI_MODE_STORAGE_KEY)
if (stored === 'guided' || stored === 'expert') {
setModeState(stored)
}
}, [])
const setMode = (nextMode: UiMode) => {
setModeState(nextMode)
if (typeof window !== 'undefined') {
window.localStorage.setItem(UI_MODE_STORAGE_KEY, nextMode)
}
}
const value = useMemo(
() => ({
mode,
setMode,
toggleMode: () => setMode(mode === 'guided' ? 'expert' : 'guided'),
}),
[mode],
)
return <UiModeContext.Provider value={value}>{children}</UiModeContext.Provider>
}
export function useUiMode() {
const context = useContext(UiModeContext)
if (!context) {
throw new Error('useUiMode must be used within a UiModeProvider')
}
return context
}
export function Explain({ children }: { children: ReactNode }) {
const { mode } = useUiMode()
if (mode === 'expert') return null
return <>{children}</>
}

View File

@@ -16,6 +16,10 @@ import {
} from '@/services/api/stats'
import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { formatWeiAsEth } from '@/utils/format'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
import OperationsPageShell, {
MetricCard,
StatusBadge,
@@ -121,6 +125,17 @@ export default function AnalyticsOperationsPage({
() => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0),
[trailingWindow],
)
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks,
transactions,
latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null,
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
}),
[blocks, bridgeStatus, stats, transactions],
)
return (
<OperationsPageShell page={page}>
@@ -130,6 +145,17 @@ export default function AnalyticsOperationsPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Analytics Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="This page combines public stats, recent block samples, and indexed transactions."
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<MetricCard
title="Total Blocks"
@@ -264,6 +290,11 @@ export default function AnalyticsOperationsPage({
<Card title="Recent Blocks">
<div className="space-y-4">
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
</p>
) : null}
{blocks.map((block) => (
<div
key={block.hash}

View File

@@ -51,6 +51,14 @@ function resolveSnapshot(relay?: MissionControlRelayPayload): MissionControlRela
return relay?.url_probe?.body || relay?.file_snapshot || null
}
function relayPolicyCue(snapshot: MissionControlRelaySnapshot | null): string | null {
if (!snapshot) return null
if (String(snapshot.status || '').toLowerCase() === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return 'Delivery disabled by policy'
}
return null
}
function laneToneClasses(status: string): string {
const normalized = status.toLowerCase()
if (['degraded', 'stale', 'stopped', 'down', 'snapshot-error'].includes(normalized)) {
@@ -300,6 +308,11 @@ export default function BridgeMonitoringPage({
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
Bridge: {shortAddress(lane.bridgeAddress)}
</div>
{relayPolicyCue(resolveSnapshot((getMissionControlRelays(bridgeStatus) || {})[lane.key])) ? (
<div className="mt-3 text-xs font-medium uppercase tracking-wide text-amber-700 dark:text-amber-300">
{relayPolicyCue(resolveSnapshot((getMissionControlRelays(bridgeStatus) || {})[lane.key]))}
</div>
) : null}
</Card>
))}
</div>

View File

@@ -5,6 +5,11 @@ import { explorerFeaturePages } from '@/data/explorerOperations'
import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config'
import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { routesApi, type RouteMatrixResponse } from '@/services/api/routes'
import { useUiMode } from '@/components/common/UiModeContext'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
function relativeAge(isoString?: string): string {
if (!isoString) return 'Unknown'
@@ -60,6 +65,7 @@ export default function OperationsHubPage({
initialTokenList = null,
initialCapabilities = null,
}: OperationsHubPageProps) {
const { mode } = useUiMode()
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
@@ -138,6 +144,19 @@ export default function OperationsHubPage({
new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[])
).slice(0, 8)
}, [tokenList])
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: [],
transactions: [],
latestBlockNumber: bridgeStatus?.data?.chains?.['138']?.block_number
? Number(bridgeStatus.data.chains['138'].block_number)
: null,
latestBlockTimestamp: null,
freshness: resolveEffectiveFreshness(null, bridgeStatus),
}),
[bridgeStatus],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -167,6 +186,16 @@ export default function OperationsHubPage({
</Card>
) : null}
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Operations Freshness Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
bridgeStatus={bridgeStatus}
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
/>
</div>
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
@@ -226,7 +255,7 @@ export default function OperationsHubPage({
{relativeAge(bridgeStatus?.data?.checked_at)}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Public mission-control snapshot freshness.
{mode === 'guided' ? 'Public mission-control snapshot freshness.' : 'Mission-control freshness.'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -235,7 +264,7 @@ export default function OperationsHubPage({
{relativeAge(routeMatrix?.updated)}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Token-aggregation route inventory timestamp.
{mode === 'guided' ? 'Token-aggregation route inventory timestamp.' : 'Route inventory timestamp.'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -244,7 +273,7 @@ export default function OperationsHubPage({
{networksConfig?.defaultChainId ?? 'Unknown'}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
Wallet onboarding points at Chain 138 by default.
{mode === 'guided' ? 'Wallet onboarding points at Chain 138 by default.' : 'Default wallet chain.'}
</div>
</div>
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">
@@ -255,7 +284,7 @@ export default function OperationsHubPage({
: 'Partial'}
</div>
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.
{mode === 'guided' ? '`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.' : 'Wallet RPC support.'}
</div>
</div>
</div>

View File

@@ -33,6 +33,14 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) {
return relay?.url_probe?.body || relay?.file_snapshot
}
function relaySummary(snapshot: ReturnType<typeof relaySnapshot>) {
if (!snapshot) return 'destination unknown'
if (snapshot.status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return `Delivery paused · queue ${formatNumber(snapshot.queue?.size ?? 0)}`
}
return `Queue ${formatNumber(snapshot.queue?.size ?? 0)} · ${snapshot.destination?.chain_name || 'destination unknown'}`
}
export default function WethOperationsPage({
initialBridgeStatus = null,
initialPlannerCapabilities = null,
@@ -112,13 +120,13 @@ export default function WethOperationsPage({
<MetricCard
title="Mainnet WETH Lane"
value={mainnetWeth?.status || 'unknown'}
description={`Queue ${formatNumber(mainnetWeth?.queue?.size ?? 0)} · ${mainnetWeth?.destination?.chain_name || 'destination unknown'}`}
description={relaySummary(mainnetWeth)}
className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20"
/>
<MetricCard
title="Mainnet cW Lane"
value={mainnetCw?.status || 'unknown'}
description={`Queue ${formatNumber(mainnetCw?.queue?.size ?? 0)} · ${mainnetCw?.destination?.chain_name || 'destination unknown'}`}
description={relaySummary(mainnetCw)}
className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20"
/>
<MetricCard

View File

@@ -10,36 +10,106 @@ import {
} from '@/services/api/stats'
import {
missionControlApi,
summarizeMissionControlRelay,
type MissionControlBridgeStatusResponse,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import { loadDashboardData } from '@/utils/dashboard'
import EntityBadge from '@/components/common/EntityBadge'
import { formatTimestamp, formatWeiAsEth } from '@/utils/format'
import { formatRelativeAge, formatTimestamp, formatWeiAsEth } from '@/utils/format'
import { transactionsApi, type Transaction } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
type HomeStats = ExplorerStats
interface HomePageProps {
initialStats?: HomeStats | null
initialRecentBlocks?: Block[]
initialRecentTransactions?: Transaction[]
initialTransactionTrend?: ExplorerTransactionTrendPoint[]
initialActivitySnapshot?: ExplorerRecentActivitySnapshot | null
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
initialRelaySummary?: MissionControlRelaySummary | null
}
function resolveRelaySeverityLabel(status?: string, tone?: 'normal' | 'warning' | 'danger') {
const normalized = String(status || '').toLowerCase()
if (normalized === 'down') return 'down'
if (normalized === 'degraded' || normalized === 'stale' || normalized === 'stopped') return 'degraded'
if (normalized === 'paused') return 'paused'
if (['starting', 'unknown', 'snapshot-error'].includes(normalized) || tone === 'warning') return 'warning'
return 'operational'
}
function resolveRelayBadgeTone(status?: string, tone?: 'normal' | 'warning' | 'danger'): 'success' | 'info' | 'warning' {
const severity = resolveRelaySeverityLabel(status, tone)
if (severity === 'operational') return 'success'
if (severity === 'warning') return 'info'
return 'warning'
}
function getLaneImpactNote(key: string, severity: string) {
if (key === 'mainnet_weth' && severity === 'paused') {
return 'New Mainnet WETH bridge deliveries are currently queued while this lane is paused. Core Chain 138 browsing remains available.'
}
if (key === 'avax' || key === 'avalanche' || key === 'avax_cw' || key === 'avax_to_138') {
return severity === 'operational'
? 'Avalanche lane visibility is healthy.'
: 'Affects Avalanche-connected bridge visibility and routing. Core Chain 138 browsing remains available.'
}
if (key.includes('mainnet')) {
return severity === 'operational'
? 'Ethereum Mainnet relay visibility is healthy.'
: 'Affects Mainnet bridge posture and route visibility more than core Chain 138 browsing.'
}
if (key.includes('bsc')) {
return severity === 'operational'
? 'BSC relay visibility is healthy.'
: 'Affects BSC-connected bridge posture and route visibility more than core Chain 138 browsing.'
}
return severity === 'operational'
? 'Relay lane visibility is healthy.'
: 'Affects this relay lane more than core Chain 138 chain browsing.'
}
function formatObservabilityValue(value: number | null, formatter: (value: number) => string) {
if (value == null) {
return { value: 'Unknown', note: 'Not reported by the current public stats payload.' }
}
return { value: formatter(value), note: 'Current public stats payload.' }
}
function formatGasPriceGwei(value: number) {
if (!Number.isFinite(value)) return 'Unknown'
return `${value.toFixed(3)} gwei`
}
export default function Home({
initialStats = null,
initialRecentBlocks = [],
initialRecentTransactions = [],
initialTransactionTrend = [],
initialActivitySnapshot = null,
initialBridgeStatus = null,
initialRelaySummary = null,
}: HomePageProps) {
const { mode } = useUiMode()
const [stats, setStats] = useState<HomeStats | null>(initialStats)
const [recentBlocks, setRecentBlocks] = useState<Block[]>(initialRecentBlocks)
const [transactionTrend, setTransactionTrend] = useState<ExplorerTransactionTrendPoint[]>(initialTransactionTrend)
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [activitySnapshot, setActivitySnapshot] = useState<ExplorerRecentActivitySnapshot | null>(initialActivitySnapshot)
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
const [relaySummary, setRelaySummary] = useState<MissionControlRelaySummary | null>(initialRelaySummary)
const [missionExpanded, setMissionExpanded] = useState(false)
const [relayExpanded, setRelayExpanded] = useState(false)
const [relayPage, setRelayPage] = useState(1)
const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>(
initialRelaySummary ? 'fallback' : 'connecting'
initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting'
)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null
@@ -92,14 +162,41 @@ export default function Home({
}
}, [])
useEffect(() => {
let cancelled = false
if (recentTransactions.length > 0) {
return () => {
cancelled = true
}
}
transactionsApi.listSafe(chainId, 1, 5)
.then(({ ok, data }) => {
if (!cancelled && ok && data.length > 0) {
setRecentTransactions(data)
}
})
.catch((error) => {
if (!cancelled && process.env.NODE_ENV !== 'production') {
console.warn('Failed to load recent transactions for activity context:', error)
}
})
return () => {
cancelled = true
}
}, [chainId, recentTransactions.length])
useEffect(() => {
let cancelled = false
const loadSnapshot = async () => {
try {
const summary = await missionControlApi.getRelaySummary()
const status = await missionControlApi.getBridgeStatus()
if (!cancelled) {
setRelaySummary(summary)
setBridgeStatus(status)
setRelaySummary(summarizeMissionControlRelay(status))
}
} catch (error) {
if (!cancelled && process.env.NODE_ENV !== 'production') {
@@ -110,10 +207,11 @@ export default function Home({
loadSnapshot()
const unsubscribe = missionControlApi.subscribeRelaySummary(
(summary) => {
const unsubscribe = missionControlApi.subscribeBridgeStatus(
(status) => {
if (!cancelled) {
setRelaySummary(summary)
setBridgeStatus(status)
setRelaySummary(summarizeMissionControlRelay(status))
setRelayFeedState('live')
}
},
@@ -144,103 +242,375 @@ export default function Home({
(best, point) => (!best || point.transaction_count > best.transaction_count ? point : best),
null,
)
const averageBlockTimeSeconds =
stats?.average_block_time_ms != null ? Math.round(stats.average_block_time_ms / 1000) : null
const averageGasPriceGwei = stats?.average_gas_price_gwei ?? null
const transactionsToday = stats?.transactions_today ?? null
const networkUtilization =
stats?.network_utilization_percentage != null ? Math.round(stats.network_utilization_percentage) : null
const relayAttentionCount = relaySummary?.items.filter((item) => item.tone !== 'normal').length || 0
const relayOperationalCount = relaySummary?.items.filter((item) => item.tone === 'normal').length || 0
const relayPrimaryItems = relaySummary?.items.slice(0, 6) || []
const relayPageSize = 4
const relayPageCount = relaySummary?.items.length ? Math.max(1, Math.ceil(relaySummary.items.length / relayPageSize)) : 1
const relayVisibleItems = relaySummary?.items.slice((relayPage - 1) * relayPageSize, relayPage * relayPageSize) || []
const chainStatus = bridgeStatus?.data?.chains?.['138'] || (bridgeStatus?.data?.chains ? Object.values(bridgeStatus.data.chains)[0] : null)
const checkedAt = bridgeStatus?.data?.checked_at || null
const missionHeadline = relaySummary
? relaySummary.tone === 'danger'
? 'Relay lanes need attention'
: relaySummary.tone === 'warning'
? 'Relay lanes are degraded'
: 'Relay lanes are operational'
: chainStatus?.status === 'operational'
? 'Chain 138 public health is operational'
: chainStatus?.status
? `Chain 138 public health is ${chainStatus.status}`
: 'Mission control snapshot available'
const missionDescription = (() => {
const parts: string[] = []
if (checkedAt) parts.push(`Last checked ${formatTimestamp(checkedAt)}`)
if (chainStatus?.head_age_sec != null) parts.push(`head age ${Math.round(chainStatus.head_age_sec)}s`)
if (chainStatus?.latency_ms != null) parts.push(`RPC latency ${Math.round(chainStatus.latency_ms)}ms`)
if (relaySummary?.items.length) {
parts.push(`${relayOperationalCount} operational lanes`)
if (relayAttentionCount > 0) parts.push(`${relayAttentionCount} flagged lanes`)
} else {
parts.push('relay inventory unavailable in the current snapshot')
}
return parts.join(' · ')
})()
const snapshotAgeLabel = checkedAt ? formatRelativeAge(checkedAt) : 'Unknown'
const chainVisibilityState =
chainStatus?.head_age_sec != null
? chainStatus.head_age_sec <= 30
? 'current'
: chainStatus.head_age_sec <= 120
? 'slightly delayed'
: 'stale'
: 'unknown'
const snapshotReason =
relayFeedState === 'fallback'
? 'Live indexing or relay streaming is not currently attached to this homepage card.'
: relayFeedState === 'live'
? 'Receiving named live mission-control events.'
: 'Negotiating the mission-control event stream.'
const snapshotScope =
bridgeStatus?.data?.mode?.scope
? bridgeStatus.data.mode.scope.replaceAll('_', ' ')
: relayFeedState === 'fallback'
? 'This primarily affects relay-lane freshness on the homepage card. Core explorer pages and public RPC health can still be current.'
: relayFeedState === 'live'
? 'Relay and chain status are arriving through live mission-control events.'
: 'Homepage status is waiting for the mission-control stream to settle.'
const missionImpact = relayAttentionCount > 0
? 'Some cross-chain relay lanes are degraded. Core Chain 138 operation remains visible through the public RPC and explorer surfaces.'
: 'Core Chain 138 operation and the visible relay lanes are currently healthy.'
const activityContext = summarizeChainActivity({
blocks: recentBlocks,
transactions: recentTransactions,
latestBlockNumber: latestBlock,
latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
})
const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null
const blockCompleteness = stats?.completeness?.blocks_feed || null
const statsGeneratedAt = stats?.sampling?.stats_generated_at || null
const missionMode = bridgeStatus?.data?.mode || null
const freshnessIssues = Object.entries({
...(bridgeStatus?.data?.sampling?.issues || {}),
...(stats?.sampling?.issues || {}),
})
const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp
? formatRelativeAge(activityContext.latest_transaction_timestamp)
: 'Unknown'
const latestTransactionFreshness =
activityContext.latest_transaction_age_seconds == null
? 'Latest transaction freshness is unavailable.'
: activityContext.latest_transaction_age_seconds <= 15 * 60
? 'Recent visible transactions are close to the chain head.'
: activityContext.latest_transaction_age_seconds <= 3 * 60 * 60
? 'The chain head is current, but visible transactions are older than the current tip.'
: 'The chain head is current, but visible transactions are substantially older than the current tip.'
const severityBreakdown = {
down: relaySummary?.items.filter((item) => item.status === 'down').length || 0,
degraded: relaySummary?.items.filter((item) => item.status === 'degraded').length || 0,
warning:
relaySummary?.items.filter((item) => ['paused', 'starting', 'unknown', 'snapshot-error'].includes(item.status)).length || 0,
}
const avgBlockTimeSummary = formatObservabilityValue(
averageBlockTimeSeconds,
(value) => `${value}s`,
)
const avgGasPriceSummary = formatObservabilityValue(
averageGasPriceGwei,
formatGasPriceGwei,
)
const transactionsTodaySummary = formatObservabilityValue(
transactionsToday,
(value) => value.toLocaleString(),
)
const networkUtilizationSummary =
networkUtilization == null
? { value: 'Unknown', note: 'Utilization is not reported by the current public stats payload.' }
: networkUtilization === 0
? { value: '0%', note: 'No utilization was reported in the latest visible stats sample.' }
: { value: `${networkUtilization}%`, note: 'Current public stats payload.' }
const missionCollapsedSummary = relaySummary
? `${missionHeadline} · ${relayOperationalCount} operational`
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
useEffect(() => {
setRelayPage(1)
}, [relaySummary?.items.length])
useEffect(() => {
if (relayPage > relayPageCount) {
setRelayPage(relayPageCount)
}
}, [relayPage, relayPageCount])
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<div className="mb-6 sm:mb-8">
<h1 className="mb-2 text-3xl font-bold sm:text-4xl">SolaceScan</h1>
<p className="text-base text-gray-600 dark:text-gray-400 sm:text-lg">Chain 138 Explorer by DBIS</p>
</div>
{relaySummary && (
<Card className={`mb-6 border shadow-sm ${relayToneClasses}`}>
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="text-sm font-semibold uppercase tracking-[0.22em] opacity-75">Mission Control</div>
<div className="mt-2 text-xl font-semibold sm:text-2xl">
{relaySummary.tone === 'danger'
? 'Relay lanes need attention'
: relaySummary.tone === 'warning'
? 'Relay lanes are degraded'
: 'Relay lanes are operational'}
</div>
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
{relaySummary.text}. This surface summarizes the public relay posture in a compact operator-friendly format.
</p>
<div className="mt-4 flex flex-wrap gap-2">
<EntityBadge
label={relayFeedState === 'live' ? 'live sse' : relayFeedState === 'fallback' ? 'snapshot fallback' : 'connecting'}
tone={relayFeedState === 'fallback' ? 'warning' : relayFeedState === 'connecting' ? 'info' : 'success'}
/>
<EntityBadge
label={relaySummary.tone === 'danger' ? 'attention needed' : relaySummary.tone === 'warning' ? 'degraded' : 'operational'}
tone={relaySummary.tone === 'danger' ? 'warning' : relaySummary.tone === 'warning' ? 'info' : 'success'}
/>
<EntityBadge label={`${relayOperationalCount} operational`} tone="success" />
<EntityBadge label={`${relayAttentionCount} flagged`} tone={relayAttentionCount > 0 ? 'warning' : 'info'} />
</div>
{(relaySummary || bridgeStatus) && (
<Card
className={`border shadow-sm ${relayToneClasses} ${missionExpanded ? 'mb-6' : 'mb-4 !p-2 sm:!p-2'}`}
>
<div className={missionExpanded ? 'flex flex-col gap-5' : 'flex'}>
<button
type="button"
onClick={() => setMissionExpanded((current) => !current)}
aria-expanded={missionExpanded}
className={`flex w-full items-center justify-between text-left shadow-sm backdrop-blur transition hover:bg-white/70 dark:border-white/10 dark:bg-black/10 dark:hover:bg-black/20 ${
missionExpanded
? 'gap-3 rounded-xl border border-white/40 bg-white/55 px-4 py-2.5'
: 'gap-2 rounded-lg border border-white/35 bg-white/50 px-3 py-2'
}`}
>
<div className={`min-w-0 opacity-90 ${missionExpanded ? 'text-sm leading-6 sm:text-base' : 'text-sm leading-5'}`}>
<span className="font-semibold uppercase tracking-[0.22em] opacity-75">Mission Control</span>
<span className={missionExpanded ? 'mx-2 opacity-40' : 'mx-1.5 opacity-40'}></span>
<span>{missionCollapsedSummary}</span>
</div>
<div
className={`shrink-0 font-semibold opacity-80 ${mode === 'guided' ? 'text-sm' : 'text-lg leading-none'}`}
aria-label={missionExpanded ? 'Hide details' : 'Show details'}
title={missionExpanded ? 'Hide details' : 'Show details'}
>
{mode === 'guided' ? (missionExpanded ? 'Hide details' : 'Show details') : (missionExpanded ? '\u2303' : '\u2304')}
</div>
</button>
<div className="grid min-w-[220px] gap-3 sm:grid-cols-2 lg:w-[290px] lg:grid-cols-1">
<div className="rounded-2xl border border-white/40 bg-white/50 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Live Feed</div>
{missionExpanded ? (
<>
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="max-w-3xl">
<div className="mt-2 text-xl font-semibold sm:text-2xl">{missionHeadline}</div>
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
{missionDescription}.
{mode === 'guided'
? ' This surface summarizes the public chain and relay posture in a compact operator-friendly format.'
: ' Public chain and relay posture.'}
</p>
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
{missionImpact}
</p>
<Explain>
<p className="mt-2 text-sm leading-6 opacity-90 sm:text-base">
{latestTransactionFreshness}
</p>
</Explain>
<div className="mt-4 flex flex-wrap gap-2">
<EntityBadge
label={relayFeedState === 'live' ? 'live sse' : relayFeedState === 'fallback' ? 'snapshot' : 'connecting'}
tone={relayFeedState === 'fallback' ? 'warning' : relayFeedState === 'connecting' ? 'info' : 'success'}
/>
{relaySummary ? (
<EntityBadge
label={relaySummary.tone === 'danger' ? 'attention needed' : relaySummary.tone === 'warning' ? 'degraded' : 'operational'}
tone={relaySummary.tone === 'danger' ? 'warning' : relaySummary.tone === 'warning' ? 'info' : 'success'}
/>
) : null}
{chainStatus?.status ? (
<EntityBadge
label={`chain 138 ${chainStatus.status}`}
tone={chainStatus.status === 'operational' ? 'success' : 'warning'}
/>
) : null}
<EntityBadge label={`${relayOperationalCount} operational`} tone="success" />
{severityBreakdown.down > 0 ? <EntityBadge label={`${severityBreakdown.down} down`} tone="warning" /> : null}
{severityBreakdown.degraded > 0 ? <EntityBadge label={`${severityBreakdown.degraded} degraded`} tone="info" /> : null}
{severityBreakdown.warning > 0 ? <EntityBadge label={`${severityBreakdown.warning} warning`} tone="warning" /> : null}
</div>
</div>
<div className="grid min-w-[220px] gap-3 sm:grid-cols-2 lg:w-[290px] lg:grid-cols-1">
<div className="rounded-2xl border border-white/40 bg-white/50 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Live Feed</div>
<div className="mt-2 text-lg font-semibold">
{missionMode?.kind === 'live'
? 'Streaming'
: missionMode?.kind === 'snapshot' || relayFeedState === 'fallback'
? 'Snapshot mode'
: 'Connecting'}
</div>
<div className="mt-1 text-sm opacity-80">
{`${statsGeneratedAt ? `Snapshot updated ${formatRelativeAge(statsGeneratedAt)}.` : `Snapshot updated ${snapshotAgeLabel}.`} ${
missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason
}`}
</div>
<div className="mt-2 text-xs opacity-75">
{snapshotScope}
</div>
{freshnessIssues.length > 0 ? (
<div className="mt-2 text-xs opacity-75">
Freshness diagnostics: {freshnessIssues.map(([key]) => key.replaceAll('_', ' ')).join(', ')}.
</div>
) : null}
</div>
<div className="flex flex-col gap-2">
<Link
href="/bridge"
className="inline-flex items-center justify-center rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-black dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100"
>
Open bridge monitoring
</Link>
<Link
href="/operations"
className="inline-flex items-center justify-center rounded-xl border border-current/20 px-4 py-2.5 text-sm font-semibold hover:bg-white/40 dark:hover:bg-black/10"
>
Open operations hub
</Link>
</div>
</div>
</div>
{chainStatus ? (
<div className="grid gap-3 md:grid-cols-3 xl:grid-cols-5">
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Chain 138 Status</div>
<div className="mt-2 text-lg font-semibold">{chainStatus.status || 'unknown'}</div>
<div className="mt-1 text-sm opacity-80">{chainStatus.name || 'Defi Oracle Meta Mainnet'}</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Head Age</div>
<div className="mt-2 text-lg font-semibold">
{relayFeedState === 'live' ? 'Streaming' : relayFeedState === 'fallback' ? 'Snapshot mode' : 'Connecting'}
{chainStatus.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'}
</div>
<div className="mt-1 text-sm opacity-80">Latest public RPC head freshness.</div>
<div className="mt-2 text-xs opacity-75">Chain visibility is currently {chainVisibilityState}.</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">RPC Latency</div>
<div className="mt-2 text-lg font-semibold">
{chainStatus.latency_ms != null ? `${Math.round(chainStatus.latency_ms)}ms` : 'Unknown'}
</div>
<div className="mt-1 text-sm opacity-80">Public Chain 138 RPC probe latency.</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Latest Transaction</div>
<div className="mt-2 text-lg font-semibold">
{activityContext.latest_transaction_block_number != null ? `#${activityContext.latest_transaction_block_number}` : 'Unknown'}
</div>
<div className="mt-1 text-sm opacity-80">{latestTransactionAgeLabel}</div>
<div className="mt-2 text-xs opacity-75">
Latest visible transaction freshness{txCompleteness ? ` · ${txCompleteness}` : ''}.
</div>
</div>
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<div className="text-xs font-semibold uppercase tracking-wide opacity-70">Last Non-Empty Block</div>
<div className="mt-2 text-lg font-semibold">
{activityContext.last_non_empty_block_number != null ? `#${activityContext.last_non_empty_block_number}` : 'Unknown'}
</div>
<div className="mt-1 text-sm opacity-80">
{relayFeedState === 'live'
? 'Receiving named mission-control events.'
: relayFeedState === 'fallback'
? 'Using the latest available snapshot.'
: 'Negotiating the event stream.'}
{activityContext.block_gap_to_latest_transaction != null
? `${activityContext.block_gap_to_latest_transaction.toLocaleString()} blocks behind tip`
: 'Gap unavailable'}
</div>
</div>
<div className="flex flex-col gap-2">
<Link
href="/operations"
className="inline-flex items-center justify-center rounded-xl bg-gray-900 px-4 py-2.5 text-sm font-semibold text-white hover:bg-black dark:bg-white dark:text-gray-900 dark:hover:bg-gray-100"
>
Open operations hub
</Link>
<Link
href="/explorer-api/v1/mission-control/stream"
className="inline-flex items-center justify-center rounded-xl border border-current/20 px-4 py-2.5 text-sm font-semibold hover:bg-white/40 dark:hover:bg-black/10"
>
Open live stream
</Link>
</div>
</div>
</div>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{relayPrimaryItems.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{item.label}</div>
<div className="mt-1 text-xs uppercase tracking-wide opacity-70">{item.status}</div>
</div>
<EntityBadge
label={item.tone === 'danger' ? 'flagged' : item.tone === 'warning' ? 'degraded' : 'live'}
tone={item.tone === 'danger' ? 'warning' : item.tone === 'warning' ? 'info' : 'success'}
/>
</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
</div>
))}
</div>
) : null}
{relaySummary.items.length > relayPrimaryItems.length ? (
<div className="text-sm opacity-80">
Showing {relayPrimaryItems.length} of {relaySummary.items.length} relay lanes. The live stream and operations hub carry the fuller view.
</div>
{relaySummary?.items.length ? (
<div className="space-y-3">
<button
type="button"
onClick={() => setRelayExpanded((current) => !current)}
aria-expanded={relayExpanded}
className="flex w-full items-center justify-between gap-4 rounded-2xl border border-white/40 bg-white/55 p-4 text-left shadow-sm backdrop-blur transition hover:bg-white/70 dark:border-white/10 dark:bg-black/10 dark:hover:bg-black/20"
>
<div>
<div className="text-sm font-semibold">Relay lane status</div>
<p className="mt-1 text-sm leading-6 opacity-90">
{relaySummary.text}. {relaySummary.items.length} configured lanes.
</p>
</div>
<div className="shrink-0 text-sm font-semibold opacity-80">
{relayExpanded ? 'Hide lanes' : 'Show lanes'}
</div>
</button>
{relayExpanded ? (
<>
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-2">
{relayVisibleItems.map((item) => (
<div
key={item.key}
className="rounded-2xl border border-white/40 bg-white/55 p-4 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10"
>
<div className="flex items-start justify-between gap-3">
<div>
<div className="text-sm font-semibold">{item.label}</div>
<div className="mt-1 text-xs uppercase tracking-wide opacity-70">{item.status}</div>
</div>
<EntityBadge label={resolveRelaySeverityLabel(item.status, item.tone)} tone={resolveRelayBadgeTone(item.status, item.tone)} />
</div>
<p className="mt-3 text-sm leading-6 opacity-90">{item.text}</p>
<p className="mt-2 text-xs opacity-75">
{getLaneImpactNote(item.key, resolveRelaySeverityLabel(item.status, item.tone))}
</p>
</div>
))}
</div>
{relayPageCount > 1 ? (
<div className="flex items-center justify-between gap-3 rounded-2xl border border-white/40 bg-white/40 px-4 py-3 text-sm shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
<button
type="button"
onClick={() => setRelayPage((current) => Math.max(1, current - 1))}
disabled={relayPage === 1}
className="rounded-lg border border-current/20 px-3 py-2 font-semibold disabled:cursor-not-allowed disabled:opacity-40"
>
Previous
</button>
<div className="text-center opacity-80">
Page {relayPage} of {relayPageCount}
</div>
<button
type="button"
onClick={() => setRelayPage((current) => Math.min(relayPageCount, current + 1))}
disabled={relayPage === relayPageCount}
className="rounded-lg border border-current/20 px-3 py-2 font-semibold disabled:cursor-not-allowed disabled:opacity-40"
>
Next
</button>
</div>
) : null}
</>
) : null}
</div>
) : (
<div className="rounded-2xl border border-white/40 bg-white/55 p-4 text-sm opacity-90 shadow-sm backdrop-blur dark:border-white/10 dark:bg-black/10">
The current mission-control snapshot does not include per-lane relay inventory. Chain health is still shown above, and the bridge monitoring page remains the canonical operator view.
</div>
)}
{relaySummary ? (
<div className="flex flex-wrap gap-2 text-sm opacity-80">
{severityBreakdown.down > 0 ? <EntityBadge label={`${severityBreakdown.down} down`} tone="warning" /> : null}
{severityBreakdown.degraded > 0 ? <EntityBadge label={`${severityBreakdown.degraded} degraded`} tone="info" /> : null}
{severityBreakdown.warning > 0 ? <EntityBadge label={`${severityBreakdown.warning} warning`} tone="warning" /> : null}
</div>
) : null}
</>
) : null}
</div>
</Card>
@@ -253,22 +623,61 @@ export default function Home({
<div className="text-xl font-bold sm:text-2xl">
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
{activityContext.latest_block_timestamp
? `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`
: 'Head freshness unavailable.'}
</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Visible public explorer block count.</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Latest visible tx {latestTransactionAgeLabel}.</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Current public explorer address count.</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Block Time</div>
<div className="text-xl font-bold sm:text-2xl">{avgBlockTimeSummary.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{avgBlockTimeSummary.note}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Avg Gas Price</div>
<div className="text-xl font-bold sm:text-2xl">{avgGasPriceSummary.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{avgGasPriceSummary.note}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Transactions Today</div>
<div className="text-xl font-bold sm:text-2xl">{transactionsTodaySummary.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{transactionsTodaySummary.note}</div>
</Card>
<Card>
<div className="text-sm text-gray-600 dark:text-gray-400">Network Utilization</div>
<div className="text-xl font-bold sm:text-2xl">{networkUtilizationSummary.value}</div>
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{networkUtilizationSummary.note}</div>
</Card>
</div>
)}
<div className="mb-8">
<ActivityContextPanel context={activityContext} />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={stats}
bridgeStatus={bridgeStatus}
scopeLabel="Homepage status combines chain freshness, transaction visibility, and mission-control posture."
/>
</div>
{!stats && (
<Card className="mb-8">
<p className="text-sm text-gray-600 dark:text-gray-400">
@@ -284,6 +693,11 @@ export default function Home({
</p>
) : (
<div className="space-y-2">
{shouldExplainEmptyHeadBlocks(recentBlocks, activityContext) ? (
<p className="rounded-xl border border-amber-200 bg-amber-50/70 px-3 py-2 text-sm text-amber-900 dark:border-amber-900/40 dark:bg-amber-950/20 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
</p>
) : null}
{recentBlocks.map((block) => (
<div key={block.number} className="flex flex-col gap-1.5 border-b border-gray-200 py-2 last:border-0 dark:border-gray-700 sm:flex-row sm:items-center sm:justify-between">
<div>
@@ -315,7 +729,9 @@ export default function Home({
<div className="mt-8 grid grid-cols-1 gap-4 lg:grid-cols-2">
<Card title="Activity Pulse">
<p className="text-sm leading-6 text-gray-600 dark:text-gray-400">
A concise public view of chain activity, index coverage, and recent execution patterns.
{mode === 'guided'
? 'A concise public view of chain activity, index coverage, and recent execution patterns.'
: 'Public chain activity and index posture.'}
</p>
<div className="mt-4 grid gap-3 sm:grid-cols-2">
<div className="rounded-2xl border border-gray-200 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-900/40">

View File

@@ -6,6 +6,7 @@ import type {
} from '@/components/wallet/AddToMetaMask'
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
import Link from 'next/link'
import { Explain, useUiMode } from '@/components/common/UiModeContext'
interface WalletPageProps {
initialNetworks?: NetworksCatalog | null
@@ -17,19 +18,35 @@ interface WalletPageProps {
}
export default function WalletPage(props: WalletPageProps) {
const { mode } = useUiMode()
return (
<main className="container mx-auto px-4 py-6 sm:py-8">
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet & MetaMask</h1>
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
<p className="mb-6 text-sm leading-7 text-gray-600 dark:text-gray-400 sm:text-base">
Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable.
{mode === 'guided'
? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.'
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
</p>
<AddToMetaMask {...props} />
<div className="mt-6 rounded-lg border border-gray-200 bg-white p-4 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-400">
Need swap and liquidity discovery too? Visit the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity Access
</Link>{' '}
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
<Explain>
<>
Need swap and liquidity discovery too? Visit the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity Access
</Link>{' '}
page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints.
</>
</Explain>
{mode === 'expert' ? (
<>
Liquidity and planner posture lives on the{' '}
<Link href="/liquidity" className="font-medium text-primary-600 hover:underline dark:text-primary-400">
Liquidity Access
</Link>{' '}
surface.
</>
) : null}
</div>
</main>
)

View File

@@ -14,7 +14,7 @@ export interface ExplorerFeaturePage {
actions: ExplorerFeatureAction[]
}
const legacyNote =
const sharedOperationsNote =
'These pages collect the public monitoring, route, wallet, and topology surfaces that support Chain 138 operations and investigation.'
export const explorerFeaturePages = {
@@ -23,7 +23,7 @@ export const explorerFeaturePages = {
title: 'Bridge & Relay Monitoring',
description:
'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.',
note: legacyNote,
note: sharedOperationsNote,
actions: [
{
title: 'Mission-control live stream',
@@ -73,7 +73,7 @@ export const explorerFeaturePages = {
title: 'Routes, Pools, and Execution Access',
description:
'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths from one public explorer surface.',
note: legacyNote,
note: sharedOperationsNote,
actions: [
{
title: 'Liquidity access',
@@ -81,12 +81,6 @@ export const explorerFeaturePages = {
href: '/liquidity',
label: 'Open liquidity access',
},
{
title: 'Pools inventory',
description: 'Jump to the pool overview page for quick PMM route and asset discovery.',
href: '/pools',
label: 'Open pools page',
},
{
title: 'Pools inventory',
description: 'Open the live pools page instead of dropping into a raw backend response.',
@@ -112,7 +106,7 @@ export const explorerFeaturePages = {
title: 'WETH Utilities & Bridge References',
description:
'Reach the WETH-focused tooling that operators use during support and bridge investigation without depending on the hidden legacy explorer navigation.',
note: legacyNote,
note: sharedOperationsNote,
actions: [
{
title: 'Bridge monitoring',
@@ -146,7 +140,7 @@ export const explorerFeaturePages = {
title: 'Analytics & Network Activity',
description:
'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.',
note: legacyNote,
note: sharedOperationsNote,
actions: [
{
title: 'Blocks',
@@ -176,11 +170,11 @@ export const explorerFeaturePages = {
],
},
operator: {
eyebrow: 'Operator Shortcuts',
title: 'Operator Panel Shortcuts',
eyebrow: 'Operator Surface',
title: 'Operator Surface',
description:
'Expose the public operational shortcuts for bridge checks, route validation, liquidity entry points, and documentation.',
note: legacyNote,
'Expose the public operator surface for bridge checks, route validation, planner providers, liquidity entry points, and documentation.',
note: sharedOperationsNote,
actions: [
{
title: 'Bridge monitoring',
@@ -220,7 +214,7 @@ export const explorerFeaturePages = {
title: 'System & Topology',
description:
'Jump straight into the public topology and reference surfaces that describe how Chain 138, bridge monitoring, and adjacent systems fit together.',
note: legacyNote,
note: sharedOperationsNote,
actions: [
{
title: 'Visual command center',
@@ -254,7 +248,7 @@ export const explorerFeaturePages = {
title: 'Operations Hub',
description:
'This hub exposes the public operational surfaces for bridge monitoring, routes, wrapped-asset references, analytics shortcuts, operator links, and topology views.',
note: legacyNote,
note: sharedOperationsNote,
actions: [
{
title: 'Bridge & relay monitoring',

View File

@@ -8,6 +8,12 @@ import { readWatchlistFromStorage } from '@/utils/watchlist'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
function normalizeAddress(value: string) {
const trimmed = value.trim()
@@ -16,6 +22,9 @@ function normalizeAddress(value: string) {
interface AddressesPageProps {
initialRecentTransactions: Transaction[]
initialLatestBlocks: Array<{ number: number; timestamp: string }>
initialStats: ExplorerStats | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeRecentTransactions(transactions: Transaction[]): Transaction[] {
@@ -26,17 +35,43 @@ function serializeRecentTransactions(transactions: Transaction[]): Transaction[]
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
created_at: transaction.created_at,
})),
),
) as Transaction[]
}
export default function AddressesPage({ initialRecentTransactions }: AddressesPageProps) {
export default function AddressesPage({
initialRecentTransactions,
initialLatestBlocks,
initialStats,
initialBridgeStatus,
}: AddressesPageProps) {
const router = useRouter()
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [query, setQuery] = useState('')
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [watchlist, setWatchlist] = useState<string[]>([])
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: initialLatestBlocks.map((block) => ({
chain_id: chainId,
number: block.number,
hash: '',
timestamp: block.timestamp,
miner: '',
transaction_count: 0,
gas_used: 0,
gas_limit: 0,
})),
transactions: recentTransactions,
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
}),
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
)
useEffect(() => {
if (initialRecentTransactions.length > 0) {
@@ -111,6 +146,17 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Recent Address Activity Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="Recently active addresses are derived from the latest visible indexed transactions."
/>
</div>
<Card className="mb-6" title="Open An Address">
<form onSubmit={handleOpenAddress} className="flex flex-col gap-3 md:flex-row">
<input
@@ -158,7 +204,7 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
<Card title="Recently Active Addresses">
{activeAddresses.length === 0 ? (
<p className="text-sm text-gray-600 dark:text-gray-400">
Recent address activity is unavailable right now. You can still open an address directly above.
Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.
</p>
) : (
<div className="space-y-3">
@@ -177,14 +223,30 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa
export const getServerSideProps: GetServerSideProps<AddressesPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
])
const initialRecentTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
const initialLatestBlocks = Array.isArray(blocksResult?.items)
? blocksResult.items
.map((item) => ({
number: Number(item.height || 0),
timestamp: item.timestamp || '',
}))
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
: []
return {
props: {
initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions),
initialLatestBlocks,
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
initialBridgeStatus: bridgeResult,
},
}
}

View File

@@ -1,20 +1,55 @@
import type { GetServerSideProps } from 'next'
import { useCallback, useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { blocksApi, Block } from '@/services/api/blocks'
import { Card, Address } from '@/libs/frontend-ui-primitives'
import Link from 'next/link'
import PageIntro from '@/components/common/PageIntro'
import { formatTimestamp } from '@/utils/format'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeBlock } from '@/services/api/blockscout'
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
import type { Transaction } from '@/services/api/transactions'
import { transactionsApi } from '@/services/api/transactions'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness'
interface BlocksPageProps {
initialBlocks: Block[]
initialRecentTransactions: Transaction[]
initialStats: ExplorerStats | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
function serializeTransactions(transactions: Transaction[]): Transaction[] {
return JSON.parse(
JSON.stringify(
transactions.map((transaction) => ({
hash: transaction.hash,
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
value: transaction.value,
status: transaction.status ?? null,
contract_address: transaction.contract_address ?? null,
fee: transaction.fee ?? null,
created_at: transaction.created_at,
})),
),
) as Transaction[]
}
export default function BlocksPage({
initialBlocks,
initialRecentTransactions,
initialStats,
initialBridgeStatus,
}: BlocksPageProps) {
const pageSize = 20
const [blocks, setBlocks] = useState<Block[]>(initialBlocks)
const [recentTransactions, setRecentTransactions] = useState<Transaction[]>(initialRecentTransactions)
const [loading, setLoading] = useState(initialBlocks.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
@@ -47,8 +82,43 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
void loadBlocks()
}, [initialBlocks, loadBlocks, page])
useEffect(() => {
if (initialRecentTransactions.length > 0) {
setRecentTransactions(initialRecentTransactions)
return
}
let active = true
transactionsApi.listSafe(chainId, 1, 5)
.then(({ ok, data }) => {
if (active && ok && data.length > 0) {
setRecentTransactions(data)
}
})
.catch(() => {
if (active) {
setRecentTransactions([])
}
})
return () => {
active = false
}
}, [chainId, initialRecentTransactions])
const showPagination = page > 1 || blocks.length > 0
const canGoNext = blocks.length === pageSize
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks,
transactions: recentTransactions,
latestBlockNumber: blocks[0]?.number ?? null,
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
}),
[blocks, initialBridgeStatus, initialStats, recentTransactions],
)
return (
<div className="container mx-auto px-4 py-6 sm:py-8">
@@ -63,12 +133,30 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Block Production Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="This page focuses on recent visible head blocks."
/>
</div>
{loading ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Loading blocks...</p>
</Card>
) : (
<div className="space-y-4">
{shouldExplainEmptyHeadBlocks(blocks, activityContext) ? (
<Card className="border border-amber-200 bg-amber-50/70 dark:border-amber-900/40 dark:bg-amber-950/20">
<p className="text-sm text-amber-900 dark:text-amber-100">
Recent head blocks are currently empty; use the latest transaction block for recent visible activity.
</p>
</Card>
) : null}
{blocks.length === 0 ? (
<Card>
<p className="text-sm text-gray-600 dark:text-gray-400">Recent blocks are unavailable right now.</p>
@@ -161,13 +249,23 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) {
export const getServerSideProps: GetServerSideProps<BlocksPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null)
const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null),
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
])
return {
props: {
initialBlocks: Array.isArray(blocksResult?.items)
? blocksResult.items.map((item) => normalizeBlock(item as never, chainId))
: [],
initialRecentTransactions: Array.isArray(transactionsResult?.items)
? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)))
: [],
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
initialBridgeStatus: bridgeResult,
},
}
}

View File

@@ -6,12 +6,12 @@ import PageIntro from '@/components/common/PageIntro'
const docsCards = [
{
title: 'GRU guide',
title: 'GRU Guide',
href: '/docs/gru',
description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.',
},
{
title: 'Transaction evidence matrix',
title: 'Transaction Evidence Matrix',
href: '/docs/transaction-review',
description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.',
},

View File

@@ -11,16 +11,21 @@ import {
} from '@/services/api/stats'
import {
summarizeMissionControlRelay,
type MissionControlBridgeStatusResponse,
type MissionControlRelaySummary,
} from '@/services/api/missionControl'
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
interface IndexPageProps {
initialStats: ExplorerStats | null
initialRecentBlocks: Block[]
initialRecentTransactions: Transaction[]
initialTransactionTrend: ExplorerTransactionTrendPoint[]
initialActivitySnapshot: ExplorerRecentActivitySnapshot | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
initialRelaySummary: MissionControlRelaySummary | null
}
@@ -28,10 +33,28 @@ export default function IndexPage(props: IndexPageProps) {
return <HomePage {...props} />
}
function serializeTransactions(transactions: Transaction[]): Transaction[] {
return JSON.parse(
JSON.stringify(
transactions.map((transaction) => ({
hash: transaction.hash,
block_number: transaction.block_number,
from_address: transaction.from_address,
to_address: transaction.to_address ?? null,
value: transaction.value,
status: transaction.status ?? null,
contract_address: transaction.contract_address ?? null,
fee: transaction.fee ?? null,
created_at: transaction.created_at,
})),
),
) as Transaction[]
}
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [statsResult, blocksResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
const [statsResult, blocksResult, transactionsResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
fetchPublicJson<{
total_blocks?: number | string | null
total_transactions?: number | string | null
@@ -39,6 +62,7 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
latest_block?: number | string | null
}>('/api/v2/stats'),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
'/api/v2/stats/charts/transactions'
),
@@ -60,10 +84,18 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
: [],
initialRecentTransactions:
transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items)
? serializeTransactions(
transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)),
)
: [],
initialTransactionTrend:
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [],
initialActivitySnapshot:
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
initialBridgeStatus:
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
initialRelaySummary:
bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null,
},

View File

@@ -14,6 +14,7 @@ import {
} from '@/utils/search'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { useUiMode } from '@/components/common/UiModeContext'
type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped'
@@ -28,6 +29,7 @@ export default function SearchPage({
initialRawResults,
initialCuratedTokens,
}: SearchPageProps) {
const { mode } = useUiMode()
const router = useRouter()
const routerQuery = typeof router.query.q === 'string' ? router.query.q : ''
const [query, setQuery] = useState(initialQuery)
@@ -193,7 +195,11 @@ export default function SearchPage({
<PageIntro
eyebrow="Explorer Lookup"
title="Search"
description="Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search."
description={
mode === 'guided'
? 'Search by address, transaction hash, block number, or token symbol. Direct identifiers can jump straight into detail pages, while broader terms fall back to indexed search.'
: 'Search address, tx hash, block, or token symbol. Direct identifiers jump straight to detail pages.'
}
actions={[
{ href: '/tokens', label: 'Token shortcuts' },
{ href: '/addresses', label: 'Browse addresses' },
@@ -207,7 +213,7 @@ export default function SearchPage({
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search by address, transaction hash, block number..."
placeholder={mode === 'guided' ? 'Search by address, transaction hash, block number...' : 'Search tx / addr / block / token'}
className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
/>
<button
@@ -237,7 +243,9 @@ export default function SearchPage({
{!loading && tokenTarget && (
<Card className="mb-6" title="Direct Token Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.
{mode === 'guided'
? 'This matches a curated Chain 138 token, so you can go straight to the token detail page instead of sifting through generic search results.'
: 'Curated Chain 138 token match.'}
</p>
<div className="mt-4">
<Link href={tokenTarget.href} className="text-primary-600 hover:underline">
@@ -250,7 +258,9 @@ export default function SearchPage({
{!loading && !tokenTarget && directTarget && (
<Card className="mb-6" title="Direct Match">
<p className="text-sm text-gray-600 dark:text-gray-400">
This looks like a direct explorer identifier. You can open it without waiting for indexed search results.
{mode === 'guided'
? 'This looks like a direct explorer identifier. You can open it without waiting for indexed search results.'
: 'Direct explorer identifier detected.'}
</p>
<div className="mt-4">
<Link href={directTarget.href} className="text-primary-600 hover:underline">

View File

@@ -8,9 +8,18 @@ import EntityBadge from '@/components/common/EntityBadge'
import PageIntro from '@/components/common/PageIntro'
import { fetchPublicJson } from '@/utils/publicExplorer'
import { normalizeTransaction } from '@/services/api/blockscout'
import { summarizeChainActivity } from '@/utils/activityContext'
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
interface TransactionsPageProps {
initialTransactions: Transaction[]
initialLatestBlocks: Array<{ number: number; timestamp: string }>
initialStats: ExplorerStats | null
initialBridgeStatus: MissionControlBridgeStatusResponse | null
}
function serializeTransactionList(transactions: Transaction[]): Transaction[] {
@@ -33,12 +42,37 @@ function serializeTransactionList(transactions: Transaction[]): Transaction[] {
) as Transaction[]
}
export default function TransactionsPage({ initialTransactions }: TransactionsPageProps) {
export default function TransactionsPage({
initialTransactions,
initialLatestBlocks,
initialStats,
initialBridgeStatus,
}: TransactionsPageProps) {
const pageSize = 20
const [transactions, setTransactions] = useState<Transaction[]>(initialTransactions)
const [loading, setLoading] = useState(initialTransactions.length === 0)
const [page, setPage] = useState(1)
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const activityContext = useMemo(
() =>
summarizeChainActivity({
blocks: initialLatestBlocks.map((block) => ({
chain_id: chainId,
number: block.number,
hash: '',
timestamp: block.timestamp,
miner: '',
transaction_count: 0,
gas_used: 0,
gas_limit: 0,
})),
transactions,
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
}),
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
)
const loadTransactions = useCallback(async () => {
setLoading(true)
@@ -163,6 +197,17 @@ export default function TransactionsPage({ initialTransactions }: TransactionsPa
]}
/>
<div className="mb-6">
<ActivityContextPanel context={activityContext} title="Transaction Recency Context" />
<FreshnessTrustNote
className="mt-3"
context={activityContext}
stats={initialStats}
bridgeStatus={initialBridgeStatus}
scopeLabel="This page reflects the latest indexed visible transaction activity."
/>
</div>
{!loading && transactions.length > 0 && (
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<Card>
@@ -250,14 +295,30 @@ export default function TransactionsPage({ initialTransactions }: TransactionsPa
export const getServerSideProps: GetServerSideProps<TransactionsPageProps> = async () => {
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null)
const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null),
fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null),
fetchPublicJson<Record<string, unknown>>('/api/v2/stats').catch(() => null),
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
])
const initialTransactions = Array.isArray(transactionsResult?.items)
? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))
: []
const initialLatestBlocks = Array.isArray(blocksResult?.items)
? blocksResult.items
.map((item) => ({
number: Number(item.height || 0),
timestamp: item.timestamp || '',
}))
.filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp)
: []
return {
props: {
initialTransactions: serializeTransactionList(initialTransactions),
initialLatestBlocks,
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
initialBridgeStatus: bridgeResult,
},
}
}

View File

@@ -16,6 +16,10 @@ export interface MissionControlRelayItemSummary {
export interface MissionControlRelaySnapshot {
status?: string
monitoring?: {
delivery_enabled?: boolean
shedding?: boolean
}
service?: {
profile?: string
}
@@ -59,10 +63,40 @@ export interface MissionControlChainStatus {
block_number?: string
}
export interface MissionControlMode {
kind?: string | null
updated_at?: string | null
age_seconds?: number | null
reason?: string | null
scope?: string | null
source?: string | null
confidence?: string | null
provenance?: string | null
}
export interface MissionControlSubsystemStatus {
status?: string | null
updated_at?: string | null
age_seconds?: number | null
source?: string | null
confidence?: string | null
provenance?: string | null
completeness?: string | null
}
export interface MissionControlBridgeStatusResponse {
data?: {
status?: string
checked_at?: string
freshness?: unknown
sampling?: {
stats_generated_at?: string | null
rpc_probe_at?: string | null
stats_window_seconds?: number | null
issues?: Record<string, string> | null
}
mode?: MissionControlMode
subsystems?: Record<string, MissionControlSubsystemStatus>
chains?: Record<string, MissionControlChainStatus>
ccip_relay?: MissionControlRelayPayload
ccip_relays?: Record<string, MissionControlRelayPayload>
@@ -100,6 +134,16 @@ function relativeAge(isoString?: string): string {
return `${hours}h ago`
}
function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: string): string {
if (status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
return snapshot.queue?.size && snapshot.queue.size > 0 ? 'delivery paused (queueing)' : 'delivery paused'
}
if (status === 'paused' && snapshot.monitoring?.shedding) {
return 'paused (shedding)'
}
return status
}
export function summarizeMissionControlRelay(
response: MissionControlBridgeStatusResponse | null | undefined
): MissionControlRelaySummary | null {
@@ -142,11 +186,12 @@ export function summarizeMissionControlRelay(
}
const status = String(snapshot.status || 'unknown').toLowerCase()
const statusLabel = describeRelayStatus(snapshot, status)
const destination = snapshot.destination?.chain_name
const queueSize = snapshot.queue?.size
const pollAge = relativeAge(snapshot.last_source_poll?.at)
let text = `${label}: ${status}`
let text = `${label}: ${statusLabel}`
if (destination) text += ` -> ${destination}`
if (queueSize != null) text += ` · queue ${queueSize}`
if (pollAge) text += ` · polled ${pollAge}`
@@ -204,11 +249,6 @@ export const missionControlApi = {
return (await response.json()) as MissionControlBridgeStatusResponse
},
getRelaySummary: async (): Promise<MissionControlRelaySummary | null> => {
const json = await missionControlApi.getBridgeStatus()
return summarizeMissionControlRelay(json)
},
subscribeBridgeStatus: (
onStatus: (status: MissionControlBridgeStatusResponse) => void,
onError?: (error: unknown) => void
@@ -241,16 +281,4 @@ export const missionControlApi = {
eventSource.close()
}
},
subscribeRelaySummary: (
onSummary: (summary: MissionControlRelaySummary | null) => void,
onError?: (error: unknown) => void
): (() => void) => {
return missionControlApi.subscribeBridgeStatus(
(payload) => {
onSummary(summarizeMissionControlRelay(payload))
},
onError
)
},
}

View File

@@ -19,6 +19,13 @@ describe('normalizeExplorerStats', () => {
total_transactions: 34,
total_addresses: 56,
latest_block: 78,
average_block_time_ms: null,
average_gas_price_gwei: null,
network_utilization_percentage: null,
transactions_today: null,
freshness: null,
completeness: null,
sampling: null,
})
})
@@ -34,6 +41,49 @@ describe('normalizeExplorerStats', () => {
total_transactions: 15788,
total_addresses: 376,
latest_block: null,
average_block_time_ms: null,
average_gas_price_gwei: null,
network_utilization_percentage: null,
transactions_today: null,
freshness: null,
completeness: null,
sampling: null,
})
})
it('normalizes freshness and completeness metadata when present', () => {
expect(
normalizeExplorerStats({
total_blocks: '1',
total_transactions: '2',
total_addresses: '3',
latest_block: '4',
freshness: {
chain_head: { block_number: '4', timestamp: '2026-04-10T22:10:15Z', age_seconds: '1', source: 'reported' },
latest_indexed_block: { block_number: '4', timestamp: '2026-04-10T22:10:15Z', age_seconds: '1' },
latest_indexed_transaction: { block_number: '3', timestamp: '2026-04-10T22:00:15Z', age_seconds: '600' },
latest_non_empty_block: { block_number: '3', timestamp: '2026-04-10T22:00:15Z', age_seconds: '600', distance_from_head: '1' },
},
completeness: {
transactions_feed: 'partial',
blocks_feed: 'complete',
},
sampling: {
stats_generated_at: '2026-04-10T22:10:16Z',
},
}),
).toMatchObject({
freshness: {
chain_head: { block_number: 4, age_seconds: 1, source: 'reported' },
latest_non_empty_block: { distance_from_head: 1 },
},
completeness: {
transactions_feed: 'partial',
blocks_feed: 'complete',
},
sampling: {
stats_generated_at: '2026-04-10T22:10:16Z',
},
})
})

View File

@@ -5,6 +5,46 @@ export interface ExplorerStats {
total_transactions: number
total_addresses: number
latest_block: number | null
average_block_time_ms: number | null
average_gas_price_gwei: number | null
network_utilization_percentage: number | null
transactions_today: number | null
freshness: ExplorerFreshnessSnapshot | null
completeness: ExplorerStatsCompleteness | null
sampling: ExplorerStatsSampling | null
}
export interface ExplorerFreshnessReference {
block_number: number | null
timestamp: string | null
age_seconds: number | null
hash?: string | null
distance_from_head?: number | null
source?: string | null
confidence?: string | null
provenance?: string | null
completeness?: string | null
}
export interface ExplorerFreshnessSnapshot {
chain_head: ExplorerFreshnessReference
latest_indexed_block: ExplorerFreshnessReference
latest_indexed_transaction: ExplorerFreshnessReference
latest_non_empty_block: ExplorerFreshnessReference
}
export interface ExplorerStatsCompleteness {
transactions_feed?: string | null
blocks_feed?: string | null
gas_metrics?: string | null
utilization_metrics?: string | null
}
export interface ExplorerStatsSampling {
stats_generated_at?: string | null
rpc_probe_at?: string | null
stats_window_seconds?: number | null
issues?: Record<string, string> | null
}
export interface ExplorerTransactionTrendPoint {
@@ -31,6 +71,34 @@ interface RawExplorerStats {
total_transactions?: number | string | null
total_addresses?: number | string | null
latest_block?: number | string | null
average_block_time?: number | string | null
gas_prices?: {
slow?: number | string | null
average?: number | string | null
fast?: number | string | null
} | null
network_utilization_percentage?: number | string | null
transactions_today?: number | string | null
freshness?: {
chain_head?: RawExplorerFreshnessReference | null
latest_indexed_block?: RawExplorerFreshnessReference | null
latest_indexed_transaction?: RawExplorerFreshnessReference | null
latest_non_empty_block?: RawExplorerFreshnessReference | null
} | null
completeness?: ExplorerStatsCompleteness | null
sampling?: ExplorerStatsSampling | null
}
interface RawExplorerFreshnessReference {
block_number?: number | string | null
timestamp?: string | null
age_seconds?: number | string | null
hash?: string | null
distance_from_head?: number | string | null
source?: string | null
confidence?: string | null
provenance?: string | null
completeness?: string | null
}
function toNumber(value: number | string | null | undefined): number {
@@ -39,8 +107,40 @@ function toNumber(value: number | string | null | undefined): number {
return 0
}
function normalizeFreshnessReference(raw?: RawExplorerFreshnessReference | null): ExplorerFreshnessReference {
return {
block_number:
raw?.block_number == null || raw.block_number === '' ? null : toNumber(raw.block_number),
timestamp: raw?.timestamp || null,
age_seconds: raw?.age_seconds == null || raw.age_seconds === '' ? null : toNumber(raw.age_seconds),
hash: raw?.hash || null,
distance_from_head:
raw?.distance_from_head == null || raw.distance_from_head === ''
? null
: toNumber(raw.distance_from_head),
source: raw?.source || null,
confidence: raw?.confidence || null,
provenance: raw?.provenance || null,
completeness: raw?.completeness || null,
}
}
function normalizeFreshnessSnapshot(raw?: RawExplorerStats['freshness'] | null): ExplorerFreshnessSnapshot | null {
if (!raw) return null
return {
chain_head: normalizeFreshnessReference(raw.chain_head),
latest_indexed_block: normalizeFreshnessReference(raw.latest_indexed_block),
latest_indexed_transaction: normalizeFreshnessReference(raw.latest_indexed_transaction),
latest_non_empty_block: normalizeFreshnessReference(raw.latest_non_empty_block),
}
}
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
const latestBlockValue = raw.latest_block
const averageBlockTimeValue = raw.average_block_time
const gasPriceAverageValue = raw.gas_prices?.average
const networkUtilizationValue = raw.network_utilization_percentage
const transactionsTodayValue = raw.transactions_today
return {
total_blocks: toNumber(raw.total_blocks),
@@ -50,6 +150,25 @@ export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
latestBlockValue == null || latestBlockValue === ''
? null
: toNumber(latestBlockValue),
average_block_time_ms:
averageBlockTimeValue == null || averageBlockTimeValue === ''
? null
: toNumber(averageBlockTimeValue),
average_gas_price_gwei:
gasPriceAverageValue == null || gasPriceAverageValue === ''
? null
: toNumber(gasPriceAverageValue),
network_utilization_percentage:
networkUtilizationValue == null || networkUtilizationValue === ''
? null
: toNumber(networkUtilizationValue),
transactions_today:
transactionsTodayValue == null || transactionsTodayValue === ''
? null
: toNumber(transactionsTodayValue),
freshness: normalizeFreshnessSnapshot(raw.freshness),
completeness: raw.completeness || null,
sampling: raw.sampling || null,
}
}

View File

@@ -0,0 +1,111 @@
import type { Block } from '@/services/api/blocks'
import type { Transaction } from '@/services/api/transactions'
import type { ExplorerFreshnessSnapshot } from '@/services/api/stats'
export type ChainActivityState = 'active' | 'low' | 'inactive' | 'unknown'
export interface ChainActivityContext {
latest_block_number: number | null
latest_block_timestamp: string | null
latest_transaction_block_number: number | null
latest_transaction_timestamp: string | null
last_non_empty_block_number: number | null
last_non_empty_block_timestamp: string | null
block_gap_to_latest_transaction: number | null
latest_transaction_age_seconds: number | null
state: ChainActivityState
head_is_idle: boolean
transaction_visibility_unavailable: boolean
}
function sortDescending(values: number[]): number[] {
return [...values].sort((left, right) => right - left)
}
function toTimestamp(value?: string | null): number | null {
if (!value) return null
const parsed = Date.parse(value)
return Number.isFinite(parsed) ? parsed : null
}
export function summarizeChainActivity(input: {
blocks?: Block[]
transactions?: Transaction[]
latestBlockNumber?: number | null
latestBlockTimestamp?: string | null
freshness?: ExplorerFreshnessSnapshot | null
}): ChainActivityContext {
const freshness = input.freshness || null
const blocks = Array.isArray(input.blocks) ? input.blocks : []
const transactions = Array.isArray(input.transactions) ? input.transactions : []
const latestBlockFromList = sortDescending(blocks.map((block) => block.number).filter((value) => Number.isFinite(value)))[0] ?? null
const latestBlock = freshness?.chain_head.block_number ?? input.latestBlockNumber ?? latestBlockFromList
const latestBlockTimestamp =
freshness?.chain_head.timestamp ??
input.latestBlockTimestamp ??
blocks.find((block) => block.number === latestBlock)?.timestamp ??
blocks[0]?.timestamp ??
null
const latestTransaction = freshness?.latest_indexed_transaction.block_number ?? sortDescending(
transactions.map((transaction) => transaction.block_number).filter((value) => Number.isFinite(value)),
)[0] ?? null
const latestTransactionRecord =
transactions.find((transaction) => transaction.block_number === latestTransaction) ?? transactions[0] ?? null
const nonEmptyBlock =
freshness?.latest_non_empty_block.block_number ??
sortDescending(blocks.filter((block) => block.transaction_count > 0).map((block) => block.number))[0] ?? latestTransaction
const nonEmptyBlockTimestamp =
freshness?.latest_non_empty_block.timestamp ??
blocks.find((block) => block.number === nonEmptyBlock)?.timestamp ??
latestTransactionRecord?.created_at ??
null
const latestTransactionTimestamp = freshness?.latest_indexed_transaction.timestamp ?? latestTransactionRecord?.created_at ?? null
const transactionVisibilityUnavailable =
freshness?.latest_indexed_transaction.source === 'unavailable' ||
freshness?.latest_indexed_transaction.completeness === 'unavailable'
const latestTransactionAgeSeconds =
freshness?.latest_indexed_transaction.age_seconds ??
(() => {
const timestamp = toTimestamp(latestTransactionTimestamp)
if (timestamp == null) return null
return Math.max(0, Math.round((Date.now() - timestamp) / 1000))
})()
const gap = freshness?.latest_non_empty_block.distance_from_head ??
(latestBlock != null && latestTransaction != null
? Math.max(0, latestBlock - latestTransaction)
: null)
const state: ChainActivityState =
latestTransactionAgeSeconds == null
? 'unknown'
: latestTransactionAgeSeconds <= 15 * 60
? 'active'
: latestTransactionAgeSeconds <= 3 * 60 * 60
? 'low'
: 'inactive'
const headIsIdle =
gap != null &&
gap > 0 &&
latestTransactionAgeSeconds != null &&
latestTransactionAgeSeconds > 0
return {
latest_block_number: latestBlock,
latest_block_timestamp: latestBlockTimestamp,
latest_transaction_block_number: latestTransaction,
latest_transaction_timestamp: latestTransactionTimestamp,
last_non_empty_block_number: nonEmptyBlock,
last_non_empty_block_timestamp: nonEmptyBlockTimestamp,
block_gap_to_latest_transaction: gap,
latest_transaction_age_seconds: latestTransactionAgeSeconds,
state,
head_is_idle: headIsIdle,
transaction_visibility_unavailable: transactionVisibilityUnavailable,
}
}

View File

@@ -0,0 +1,132 @@
import { describe, expect, it } from 'vitest'
import { resolveEffectiveFreshness, summarizeFreshnessConfidence } from './explorerFreshness'
describe('resolveEffectiveFreshness', () => {
it('prefers stats freshness when it is present', () => {
expect(
resolveEffectiveFreshness(
{
total_blocks: 1,
total_transactions: 2,
total_addresses: 3,
latest_block: 4,
average_block_time_ms: null,
average_gas_price_gwei: null,
network_utilization_percentage: null,
transactions_today: null,
freshness: {
chain_head: { block_number: 10, timestamp: '2026-04-11T07:00:00Z', age_seconds: 1 },
latest_indexed_block: { block_number: 10, timestamp: '2026-04-11T07:00:00Z', age_seconds: 1 },
latest_indexed_transaction: { block_number: 9, timestamp: '2026-04-11T06:59:50Z', age_seconds: 11 },
latest_non_empty_block: { block_number: 9, timestamp: '2026-04-11T06:59:50Z', age_seconds: 11, distance_from_head: 1 },
},
completeness: null,
sampling: null,
},
{
data: {
freshness: {
chain_head: { block_number: 20 },
},
},
},
),
).toMatchObject({
chain_head: { block_number: 10 },
latest_non_empty_block: { distance_from_head: 1 },
})
})
it('falls back to mission-control freshness when stats freshness is unavailable', () => {
expect(
resolveEffectiveFreshness(
{
total_blocks: 1,
total_transactions: 2,
total_addresses: 3,
latest_block: 4,
average_block_time_ms: null,
average_gas_price_gwei: null,
network_utilization_percentage: null,
transactions_today: null,
freshness: null,
completeness: null,
sampling: null,
},
{
data: {
freshness: {
chain_head: { block_number: '20', timestamp: '2026-04-11T07:00:00Z', age_seconds: '2' },
latest_indexed_block: { block_number: '20', timestamp: '2026-04-11T07:00:00Z', age_seconds: '2' },
latest_indexed_transaction: { block_number: '19', timestamp: '2026-04-11T06:59:59Z', age_seconds: '3' },
latest_non_empty_block: { block_number: '19', distance_from_head: '1' },
},
},
},
),
).toMatchObject({
chain_head: { block_number: 20, age_seconds: 2 },
latest_indexed_transaction: { block_number: 19, age_seconds: 3 },
latest_non_empty_block: { block_number: 19, distance_from_head: 1 },
})
})
it('summarizes confidence in user-facing trust language', () => {
expect(
summarizeFreshnessConfidence(
{
total_blocks: 1,
total_transactions: 2,
total_addresses: 3,
latest_block: 4,
average_block_time_ms: null,
average_gas_price_gwei: null,
network_utilization_percentage: null,
transactions_today: null,
freshness: {
chain_head: {
block_number: 10,
timestamp: '2026-04-11T07:00:00Z',
age_seconds: 1,
confidence: 'high',
completeness: 'complete',
source: 'reported',
},
latest_indexed_block: {
block_number: 10,
timestamp: '2026-04-11T07:00:00Z',
age_seconds: 1,
},
latest_indexed_transaction: {
block_number: 9,
timestamp: '2026-04-11T06:59:50Z',
age_seconds: 11,
confidence: 'high',
completeness: 'partial',
source: 'reported',
},
latest_non_empty_block: {
block_number: 9,
timestamp: '2026-04-11T06:59:50Z',
age_seconds: 11,
distance_from_head: 1,
},
},
completeness: null,
sampling: null,
},
{
data: {
mode: {
kind: 'snapshot',
},
},
},
),
).toEqual([
'Head: directly reported',
'Transactions: partial visibility',
'Feed: snapshot',
])
})
})

View File

@@ -0,0 +1,104 @@
import type { Block } from '@/services/api/blocks'
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
import {
normalizeExplorerStats,
type ExplorerFreshnessReference,
type ExplorerFreshnessSnapshot,
type ExplorerStats,
} from '@/services/api/stats'
import type { ChainActivityContext } from '@/utils/activityContext'
export function resolveEffectiveFreshness(
stats: ExplorerStats | null | undefined,
bridgeStatus: MissionControlBridgeStatusResponse | null | undefined,
): ExplorerFreshnessSnapshot | null {
if (stats?.freshness) {
return stats.freshness
}
const missionFreshness = bridgeStatus?.data?.freshness
if (!missionFreshness || typeof missionFreshness !== 'object') {
return null
}
return normalizeExplorerStats({
freshness: missionFreshness as Record<string, unknown>,
}).freshness
}
export function resolveFreshnessSourceLabel(
stats: ExplorerStats | null | undefined,
bridgeStatus: MissionControlBridgeStatusResponse | null | undefined,
): string {
if (stats?.freshness) {
return 'Based on public stats and indexed explorer freshness.'
}
if (bridgeStatus?.data?.freshness) {
return 'Based on mission-control freshness and latest visible public data.'
}
return 'Based on the latest visible public explorer data.'
}
export function summarizeFreshnessConfidence(
stats: ExplorerStats | null | undefined,
bridgeStatus: MissionControlBridgeStatusResponse | null | undefined,
): string[] {
const effectiveFreshness = resolveEffectiveFreshness(stats, bridgeStatus)
if (!effectiveFreshness) {
return ['Freshness: unavailable']
}
const chainConfidence = describeFreshnessReference('Head', effectiveFreshness.chain_head)
const txConfidence = describeFreshnessReference('Transactions', effectiveFreshness.latest_indexed_transaction)
const snapshotMode = bridgeStatus?.data?.mode?.kind || null
return [
chainConfidence,
txConfidence,
snapshotMode ? `Feed: ${snapshotMode}` : 'Feed: direct',
]
}
function describeFreshnessReference(label: string, reference: ExplorerFreshnessReference): string {
const completeness = String(reference.completeness || '').toLowerCase()
const confidence = String(reference.confidence || '').toLowerCase()
const source = String(reference.source || '').toLowerCase()
if (source === 'unavailable' || completeness === 'unavailable') {
return `${label}: unavailable`
}
if (completeness === 'partial') {
return `${label}: partial visibility`
}
if (confidence === 'high') {
return `${label}: directly reported`
}
if (confidence === 'medium') {
return `${label}: reported sample`
}
if (confidence === 'low') {
return `${label}: limited confidence`
}
return `${label}: reported`
}
export function shouldExplainEmptyHeadBlocks(
blocks: Pick<Block, 'transaction_count'>[],
context: ChainActivityContext,
): boolean {
if (!Array.isArray(blocks) || blocks.length === 0) {
return false
}
const visibleBlocks = blocks.slice(0, Math.min(blocks.length, 5))
const allVisibleBlocksEmpty = visibleBlocks.every((block) => Number(block.transaction_count || 0) === 0)
return allVisibleBlocksEmpty && Boolean(context.head_is_idle && (context.block_gap_to_latest_transaction || 0) > 0)
}

View File

@@ -68,3 +68,17 @@ export function formatTimestamp(value?: string | null): string {
}
return date.toLocaleString()
}
export function formatRelativeAge(value?: string | null): string {
if (!value) return 'Unknown'
const parsed = Date.parse(value)
if (!Number.isFinite(parsed)) return 'Unknown'
const seconds = Math.max(0, Math.round((Date.now() - parsed) / 1000))
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.round(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.round(minutes / 60)
if (hours < 48) return `${hours}h ago`
const days = Math.round(hours / 24)
return `${days}d ago`
}

View File

@@ -177,6 +177,21 @@ from pathlib import Path
import re
path = Path('/etc/nginx/sites-available/blockscout')
text = path.read_text()
stats_block = ''' # Explorer stats override: keep freshness/completeness metadata on the explorer-owned backend.
location = /api/v2/stats {
proxy_pass http://127.0.0.1:8081/api/v2/stats;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
add_header Access-Control-Allow-Origin *;
add_header Access-Control-Allow-Methods "GET, POST, OPTIONS";
add_header Access-Control-Allow-Headers "Content-Type, Authorization";
}
'''
explorer_block = ''' # Explorer backend API (auth, features, AI, explorer-owned v1 helpers)
location /explorer-api/v1/ {
proxy_pass http://127.0.0.1:8081/api/v1/;
@@ -195,6 +210,58 @@ explorer_block = ''' # Explorer backend API (auth, features, AI, explorer-own
escaped_explorer_block = explorer_block.replace('$', '\\$')
if escaped_explorer_block in text:
text = text.replace(escaped_explorer_block, explorer_block)
escaped_stats_block = stats_block.replace('$', '\\$')
if escaped_stats_block in text:
text = text.replace(escaped_stats_block, stats_block)
def dedupe_named_location_block(text: str, marker: str, next_markers: list[str]) -> str:
first = text.find(marker)
if first == -1:
return text
second = text.find(marker, first + len(marker))
if second == -1:
return text
next_positions = [text.find(candidate, second) for candidate in next_markers]
next_positions = [pos for pos in next_positions if pos != -1]
if not next_positions:
return text
return text[:first] + text[second:min(next_positions)] + text[min(next_positions):]
text = dedupe_named_location_block(
text,
' # Explorer backend API (auth, features, AI, explorer-owned v1 helpers)\n',
[
' # Blockscout API endpoint - MUST come before the redirect location\n',
' # API endpoint - MUST come before the redirect location\n',
' # Token-aggregation API for the explorer SPA live route-tree and pool intelligence.\n',
' # Token-aggregation API at /api/v1/ for the Snap site. Service runs on port 3001.\n',
],
)
text = dedupe_named_location_block(
text,
' # Explorer stats override: keep freshness/completeness metadata on the explorer-owned backend.\n',
[
' # Explorer backend API (auth, features, AI, explorer-owned v1 helpers)\n',
' # Blockscout API endpoint - MUST come before the redirect location\n',
' # API endpoint - MUST come before the redirect location\n',
' # Token-aggregation API for the explorer SPA live route-tree and pool intelligence.\n',
' # Token-aggregation API at /api/v1/ for the Snap site. Service runs on port 3001.\n',
],
)
text = dedupe_named_location_block(
text,
' # Enriched explorer stats come from the Go-side API on 8081.\n',
[
' # Explorer stats override: keep freshness/completeness metadata on the explorer-owned backend.\n',
' # Explorer backend API (auth, features, AI, explorer-owned v1 helpers)\n',
' # Blockscout API endpoint - MUST come before the redirect location\n',
' # API endpoint - MUST come before the redirect location\n',
' # Token-aggregation API for the explorer SPA live route-tree and pool intelligence.\n',
' # Token-aggregation API at /api/v1/ for the Snap site. Service runs on port 3001.\n',
],
)
legacy_patterns = [
r"\n\s*# Explorer AI endpoints on the explorer backend service \(HTTP\)\n\s*location /api/v1/ai/ \{.*?\n\s*\}\n",
@@ -206,6 +273,12 @@ for pattern in legacy_patterns:
http_needle = ' # Blockscout API endpoint - MUST come before the redirect location\n'
legacy_http_needle = ' # API endpoint - MUST come before the redirect location\n'
if stats_block not in text:
if http_needle in text:
text = text.replace(http_needle, stats_block + http_needle, 1)
elif legacy_http_needle in text:
text = text.replace(legacy_http_needle, stats_block + ' # Blockscout API endpoint - MUST come before the redirect location\n', 1)
if explorer_block not in text:
if http_needle in text:
text = text.replace(http_needle, explorer_block + http_needle, 1)
@@ -213,6 +286,8 @@ if explorer_block not in text:
text = text.replace(legacy_http_needle, explorer_block + ' # Blockscout API endpoint - MUST come before the redirect location\n', 1)
https_needle = ' # Token-aggregation API for the explorer SPA live route-tree and pool intelligence.\n'
if stats_block not in text[text.find('# HTTPS server - Blockscout Explorer'):]:
text = text.replace(' # Token-aggregation API at /api/v1/ for the Snap site. Service runs on port 3001.\n location /api/v1/ {\n proxy_pass http://127.0.0.1:3001/api/v1/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_read_timeout 60s;\n add_header Access-Control-Allow-Origin *;\n }\n\n', stats_block + ' # Token-aggregation API at /api/v1/ for the Snap site. Service runs on port 3001.\n location /api/v1/ {\n proxy_pass http://127.0.0.1:3001/api/v1/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_read_timeout 60s;\n add_header Access-Control-Allow-Origin *;\n }\n\n', 1)
if explorer_block not in text[text.find('# HTTPS server - Blockscout Explorer'):]:
text = text.replace(' # Token-aggregation API at /api/v1/ for the Snap site. Service runs on port 3001.\n location /api/v1/ {\n proxy_pass http://127.0.0.1:3001/api/v1/;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_read_timeout 60s;\n add_header Access-Control-Allow-Origin *;\n }\n\n', explorer_block, 1)
path.write_text(text)

281
scripts/deploy-frontend-to-vmid5000.sh Executable file → Normal file
View File

@@ -1,278 +1,19 @@
#!/bin/bash
#!/usr/bin/env bash
# Deploy legacy static explorer frontend to VMID 5000
# This copies the old SPA assets into /var/www/html/.
# For the current Next.js frontend, use ./scripts/deploy-next-frontend-to-vmid5000.sh
#
# Optional: for air-gapped Mermaid on chain138-command-center.html, run:
# bash explorer-monorepo/scripts/vendor-mermaid-for-command-center.sh
# then switch the script src in chain138-command-center.html to /thirdparty/mermaid.min.js
# Deprecated legacy static frontend deploy shim.
# The canonical deployment path is the Next.js standalone frontend.
set -euo pipefail
VMID=5000
VM_IP="192.168.11.140"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
FRONTEND_SOURCE="${REPO_ROOT}/explorer-monorepo/frontend/public/index.html"
[ -f "$FRONTEND_SOURCE" ] || FRONTEND_SOURCE="${SCRIPT_DIR}/../frontend/public/index.html"
FRONTEND_PUBLIC="$(dirname "$FRONTEND_SOURCE")"
PROXMOX_R630_02="${PROXMOX_HOST_R630_02:-192.168.11.12}"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CANONICAL_SCRIPT="$REPO_ROOT/scripts/deploy-next-frontend-to-vmid5000.sh"
echo "=========================================="
echo "Deploying Legacy Static Explorer Frontend"
echo "=========================================="
echo "This script is deprecated."
echo "The legacy static SPA deployment path is no longer supported as a primary deploy target."
echo ""
# Check if running from Proxmox host or inside container
if [ -f "/proc/1/cgroup" ] && grep -q "lxc" /proc/1/cgroup 2>/dev/null; then
EXEC_PREFIX=""
echo "Running inside VMID 5000"
DEPLOY_METHOD="direct"
run_in_vm() { "$@"; }
elif command -v pct &>/dev/null; then
EXEC_PREFIX="pct exec $VMID --"
echo "Running from Proxmox host, executing in VMID 5000"
DEPLOY_METHOD="pct"
run_in_vm() { pct exec $VMID -- "$@"; }
else
echo "Running from remote: will scp + SSH to $PROXMOX_R630_02 and deploy to VMID $VMID"
DEPLOY_METHOD="remote"
EXEC_PREFIX=""
run_in_vm() { ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct exec $VMID -- $*"; }
fi
# Step 1: Check if frontend file exists
if [ ! -f "$FRONTEND_SOURCE" ]; then
echo "❌ Frontend file not found: $FRONTEND_SOURCE"
echo "Please ensure you're running from the correct directory"
exit 1
fi
echo "✅ Frontend source found: $FRONTEND_SOURCE"
echo ""
# Step 2: Create /var/www/html if it doesn't exist
echo "=== Step 2: Preparing deployment directory ==="
run_in_vm "mkdir -p /var/www/html"
run_in_vm "chown -R www-data:www-data /var/www/html" 2>/dev/null || true
echo "✅ Directory prepared"
echo ""
# Step 3: Backup existing frontend
echo "=== Step 3: Backing up existing frontend ==="
run_in_vm "bash -c 'if [ -f /var/www/html/index.html ]; then cp /var/www/html/index.html /var/www/html/index.html.backup.\$(date +%Y%m%d_%H%M%S); echo \"✅ Backup created\"; else echo \"⚠️ No existing frontend to backup\"; fi'"
echo ""
# Step 4: Deploy frontend
echo "=== Step 4: Deploying frontend ==="
if [ "$DEPLOY_METHOD" = "direct" ]; then
# Running inside VMID 5000
cp "$FRONTEND_SOURCE" /var/www/html/index.html
chown www-data:www-data /var/www/html/index.html 2>/dev/null || true
echo "✅ Frontend deployed"
elif [ "$DEPLOY_METHOD" = "remote" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$FRONTEND_SOURCE" root@${PROXMOX_R630_02}:/tmp/explorer-index.html
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct push $VMID /tmp/explorer-index.html /var/www/html/index.html --perms 0644 && pct exec $VMID -- chown www-data:www-data /var/www/html/index.html"
echo "✅ Frontend deployed via $PROXMOX_R630_02"
else
# Running from Proxmox host
pct push $VMID "$FRONTEND_SOURCE" /var/www/html/index.html
$EXEC_PREFIX chown www-data:www-data /var/www/html/index.html 2>/dev/null || true
echo "✅ Frontend deployed"
fi
echo ""
# Step 4b: Deploy favicon and apple-touch-icon
echo "=== Step 4b: Deploying icons ==="
for ASSET in explorer-spa.js chain138-command-center.html apple-touch-icon.png favicon.ico; do
SRC="${FRONTEND_PUBLIC}/${ASSET}"
if [ ! -f "$SRC" ]; then
echo "⚠️ Skip $ASSET (not found)"
continue
fi
if [ "$DEPLOY_METHOD" = "direct" ]; then
cp "$SRC" "/var/www/html/$ASSET"
chown www-data:www-data "/var/www/html/$ASSET" 2>/dev/null || true
echo "$ASSET deployed"
elif [ "$DEPLOY_METHOD" = "remote" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SRC" root@${PROXMOX_R630_02}:/tmp/"$ASSET"
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct push $VMID /tmp/$ASSET /var/www/html/$ASSET --perms 0644 && pct exec $VMID -- chown www-data:www-data /var/www/html/$ASSET"
echo "$ASSET deployed via $PROXMOX_R630_02"
else
pct push $VMID "$SRC" "/var/www/html/$ASSET"
$EXEC_PREFIX chown www-data:www-data "/var/www/html/$ASSET" 2>/dev/null || true
echo "$ASSET deployed"
fi
done
# Optional local Mermaid (~3 MB) for command center when jsDelivr/CSP is blocked
MERMAID_SRC="${FRONTEND_PUBLIC}/thirdparty/mermaid.min.js"
if [ -f "$MERMAID_SRC" ]; then
echo "=== Step 4b2: Deploying thirdparty/mermaid.min.js (local vendored) ==="
if [ "$DEPLOY_METHOD" = "direct" ]; then
mkdir -p /var/www/html/thirdparty
cp "$MERMAID_SRC" /var/www/html/thirdparty/mermaid.min.js
chown www-data:www-data /var/www/html/thirdparty/mermaid.min.js 2>/dev/null || true
echo "✅ thirdparty/mermaid.min.js deployed"
elif [ "$DEPLOY_METHOD" = "remote" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$MERMAID_SRC" root@${PROXMOX_R630_02}:/tmp/mermaid.min.js
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct exec $VMID -- mkdir -p /var/www/html/thirdparty && pct push $VMID /tmp/mermaid.min.js /var/www/html/thirdparty/mermaid.min.js --perms 0644 && pct exec $VMID -- chown www-data:www-data /var/www/html/thirdparty/mermaid.min.js"
echo "✅ thirdparty/mermaid.min.js deployed via $PROXMOX_R630_02"
else
$EXEC_PREFIX mkdir -p /var/www/html/thirdparty
pct push $VMID "$MERMAID_SRC" /var/www/html/thirdparty/mermaid.min.js
$EXEC_PREFIX chown www-data:www-data /var/www/html/thirdparty/mermaid.min.js 2>/dev/null || true
echo "✅ thirdparty/mermaid.min.js deployed"
fi
echo ""
else
echo " Skip thirdparty/mermaid.min.js (run scripts/vendor-mermaid-for-command-center.sh if CSP/offline needs local Mermaid)"
echo ""
fi
echo "=== Step 4c: Deploying /config JSON (topology, verify example) ==="
run_in_vm "mkdir -p /var/www/html/config"
for CFG in topology-graph.json mission-control-verify.example.json; do
CFG_SRC="${FRONTEND_PUBLIC}/config/${CFG}"
if [ ! -f "$CFG_SRC" ]; then
echo "⚠️ Skip config/$CFG (not found)"
continue
fi
if [ "$DEPLOY_METHOD" = "direct" ]; then
cp "$CFG_SRC" "/var/www/html/config/$CFG"
chown www-data:www-data "/var/www/html/config/$CFG" 2>/dev/null || true
echo "✅ config/$CFG deployed"
elif [ "$DEPLOY_METHOD" = "remote" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$CFG_SRC" root@${PROXMOX_R630_02}:/tmp/explorer-cfg-"$CFG"
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct push $VMID /tmp/explorer-cfg-$CFG /var/www/html/config/$CFG --perms 0644 && pct exec $VMID -- chown www-data:www-data /var/www/html/config/$CFG"
echo "✅ config/$CFG deployed via $PROXMOX_R630_02"
else
pct push $VMID "$CFG_SRC" "/var/www/html/config/$CFG"
$EXEC_PREFIX chown www-data:www-data "/var/www/html/config/$CFG" 2>/dev/null || true
echo "✅ config/$CFG deployed"
fi
done
echo ""
# Step 5 (remote): Apply nginx config so /favicon.ico and /apple-touch-icon.png are served
if [ "$DEPLOY_METHOD" = "remote" ]; then
echo "=== Step 5 (remote): Applying nginx config for icons ==="
FIX_NGINX_SCRIPT="${REPO_ROOT}/explorer-monorepo/scripts/fix-nginx-serve-custom-frontend.sh"
if [ -f "$FIX_NGINX_SCRIPT" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$FIX_NGINX_SCRIPT" root@${PROXMOX_R630_02}:/tmp/fix-nginx-explorer.sh
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct push $VMID /tmp/fix-nginx-explorer.sh /tmp/fix-nginx-explorer.sh --perms 0755 && pct exec $VMID -- /tmp/fix-nginx-explorer.sh"
echo "✅ Nginx config applied (favicon and apple-touch-icon locations)"
else
echo "⚠️ Nginx fix script not found ($FIX_NGINX_SCRIPT); icons may still 404 until nginx is updated on VM"
fi
echo ""
fi
# Step 5 (local/pct): Update nginx configuration
if [ "$DEPLOY_METHOD" != "remote" ]; then
echo "=== Step 5: Updating nginx configuration ==="
$EXEC_PREFIX bash << 'NGINX_UPDATE'
CONFIG_FILE="/etc/nginx/sites-available/blockscout"
# Check if config exists
if [ ! -f "$CONFIG_FILE" ]; then
echo "❌ Nginx config not found: $CONFIG_FILE"
exit 1
fi
# Update HTTPS server block to serve static files for root, proxy API
sed -i '/location \/ {/,/}/c\
# Serve custom frontend for root path\
location = / {\
root /var/www/html;\
try_files /index.html =404;\
}\
\
# Serve static assets\
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {\
root /var/www/html;\
expires 1y;\
add_header Cache-Control "public, immutable";\
}\
\
# Proxy Blockscout UI if needed (fallback)\
location /blockscout/ {\
proxy_pass http://127.0.0.1:4000/;\
proxy_http_version 1.1;\
proxy_set_header Host $host;\
proxy_set_header X-Real-IP $remote_addr;\
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\
proxy_set_header X-Forwarded-Proto $scheme;\
}' "$CONFIG_FILE"
echo "✅ Nginx config updated"
NGINX_UPDATE
# Step 6: Test and restart nginx
echo ""
echo "=== Step 6: Testing and restarting nginx ==="
if $EXEC_PREFIX nginx -t; then
echo "✅ Configuration valid"
$EXEC_PREFIX systemctl restart nginx
echo "✅ Nginx restarted"
else
echo "❌ Configuration has errors"
exit 1
fi
echo ""
fi
# Step 7: Verify deployment
echo "=== Step 7: Verifying deployment ==="
sleep 2
run_in_vm() {
if [ "$DEPLOY_METHOD" = "remote" ]; then
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct exec $VMID -- $1"
else
$EXEC_PREFIX $1
fi
}
# Check if file exists
if run_in_vm "test -f /var/www/html/index.html"; then
echo "✅ Frontend file exists"
# Check if it contains expected content
if run_in_vm "grep -qiE 'SolaceScan|Chain 138 Explorer by DBIS' /var/www/html/index.html"; then
echo "✅ Frontend content verified"
else
echo "⚠️ Frontend file exists but content may be incorrect"
fi
else
echo "❌ Frontend file not found"
exit 1
fi
# Test HTTP endpoint (non-fatal: do not exit on failure)
HTTP_RESPONSE=$(run_in_vm "curl -s --max-time 5 http://localhost/ 2>/dev/null | head -5" 2>/dev/null) || true
if echo "$HTTP_RESPONSE" | grep -qiE "SolaceScan|Chain 138 Explorer by DBIS|<!DOCTYPE html"; then
echo "✅ Frontend is accessible via nginx"
else
echo "⚠️ Frontend may not be accessible (check nginx config)"
echo "Response: $HTTP_RESPONSE"
fi
echo ""
echo "=========================================="
echo "Deployment Complete!"
echo "=========================================="
echo ""
echo "Note: this is the legacy static SPA deployment path."
echo "For the current Next.js frontend, use:"
echo " ./scripts/deploy-next-frontend-to-vmid5000.sh"
echo ""
echo "Frontend should now be accessible at:"
echo " - http://$VM_IP/"
echo " - https://explorer.d-bis.org/"
echo ""
echo "To view logs:"
echo " tail -f /var/log/nginx/blockscout-access.log"
echo " tail -f /var/log/nginx/blockscout-error.log"
echo "Use the canonical Next.js frontend deploy instead:"
echo " bash $CANONICAL_SCRIPT"
echo ""
echo "The static compatibility assets remain in-repo for fallback/reference purposes only."
exit 1

View File

@@ -22,6 +22,7 @@ VERIFY_SCRIPT="${WORKSPACE_ROOT}/scripts/verify/check-explorer-e2e.sh"
RELEASE_ID="$(date +%Y%m%d_%H%M%S)"
TMP_DIR="$(mktemp -d)"
ARCHIVE_NAME="solacescanscout-next-${RELEASE_ID}.tar"
BUILD_LOCK_DIR="${FRONTEND_ROOT}/.next-build-lock"
STATIC_SYNC_FILES=(
"index.html"
"docs.html"
@@ -35,6 +36,9 @@ STATIC_SYNC_FILES=(
)
cleanup() {
if [[ -d "$BUILD_LOCK_DIR" ]]; then
rmdir "$BUILD_LOCK_DIR" 2>/dev/null || true
fi
rm -rf "$TMP_DIR"
}
trap cleanup EXIT
@@ -82,8 +86,25 @@ echo "Frontend root: $FRONTEND_ROOT"
echo "Release: $RELEASE_ID"
echo ""
acquire_build_lock() {
local attempts=0
until mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do
attempts=$((attempts + 1))
if (( attempts == 1 )); then
echo "Waiting for another frontend build to finish..."
fi
if (( attempts >= 120 )); then
echo "Timed out waiting for frontend build lock: $BUILD_LOCK_DIR" >&2
exit 1
fi
sleep 1
done
}
if [[ "${SKIP_BUILD:-0}" != "1" ]]; then
echo "== Building frontend =="
acquire_build_lock
rm -rf "${FRONTEND_ROOT}/.next"
(cd "$FRONTEND_ROOT" && npm run build)
echo ""
fi

73
scripts/deploy.sh Executable file → Normal file
View File

@@ -1,72 +1,19 @@
#!/usr/bin/env bash
# Deploy the legacy static explorer frontend to production
# For the current Next.js frontend deployment, use scripts/deploy-next-frontend-to-vmid5000.sh
# Deprecated legacy static frontend deploy shim.
# Kept only so older runbooks fail clearly instead of redeploying the wrong surface.
set -euo pipefail
IP="${IP:-192.168.11.140}"
DOMAIN="${DOMAIN:-explorer.d-bis.org}"
PASSWORD="${PASSWORD:-L@kers2010}"
# Colors
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
CYAN='\033[0;36m'
NC='\033[0m'
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
log_success() { echo -e "${GREEN}[✓]${NC} $1"; }
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
log_step() { echo -e "${CYAN}[STEP]${NC} $1"; }
# Get script directory
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
CANONICAL_SCRIPT="$REPO_ROOT/scripts/deploy-next-frontend-to-vmid5000.sh"
echo "════════════════════════════════════════════════════════"
echo "Deploy Legacy Chain 138 Explorer Frontend"
echo "════════════════════════════════════════════════════════"
echo "This script is deprecated."
echo "It previously deployed the legacy static SPA, which is no longer the supported production frontend."
echo ""
log_warn "This script deploys the legacy static SPA."
log_warn "For the current Next.js frontend, use ./scripts/deploy-next-frontend-to-vmid5000.sh"
echo ""
# Check if files exist
if [ ! -f "$REPO_ROOT/frontend/public/index.html" ]; then
log_error "Frontend file not found: $REPO_ROOT/frontend/public/index.html"
exit 1
fi
log_step "Step 1: Backing up current deployment..."
sshpass -p "$PASSWORD" ssh -o StrictHostKeyChecking=no root@"$IP" \
"cp /var/www/html/index.html /var/www/html/index.html.backup.$(date +%Y%m%d_%H%M%S) 2>/dev/null || true"
log_success "Backup created"
log_step "Step 2: Deploying frontend files..."
sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no \
"$REPO_ROOT/frontend/public/index.html" \
root@"$IP":/var/www/html/index.html
[ -f "$REPO_ROOT/frontend/public/explorer-spa.js" ] && sshpass -p "$PASSWORD" scp -o StrictHostKeyChecking=no \
"$REPO_ROOT/frontend/public/explorer-spa.js" \
root@"$IP":/var/www/html/explorer-spa.js
log_success "Frontend deployed"
log_step "Step 3: Verifying deployment..."
sleep 2
if curl -k -sI "https://$DOMAIN/" 2>&1 | grep -qi "HTTP.*200"; then
log_success "Deployment verified - Explorer is accessible"
else
log_warn "Deployment completed but verification failed - check manually"
fi
echo ""
log_success "Deployment complete!"
echo ""
log_info "Explorer URL: https://$DOMAIN/"
log_info "To rollback: ssh root@$IP 'cp /var/www/html/index.html.backup.* /var/www/html/index.html'"
echo "Use the canonical Next.js frontend deploy instead:"
echo " bash $CANONICAL_SCRIPT"
echo ""
echo "If you are following an older runbook, update it to the canonical deploy path."
exit 1

View File

@@ -393,12 +393,12 @@ if [ -f /var/www/html/index.html ]; then
else
echo "⚠️ Frontend file exists but may not be the custom one"
echo " Deploy the custom frontend using:"
echo " ./scripts/deploy-frontend-to-vmid5000.sh"
echo " ./scripts/deploy-next-frontend-to-vmid5000.sh"
fi
else
echo "⚠️ Custom frontend not found at /var/www/html/index.html"
echo " Deploy the custom frontend using:"
echo " ./scripts/deploy-frontend-to-vmid5000.sh"
echo " ./scripts/deploy-next-frontend-to-vmid5000.sh"
fi
# Test HTTP endpoint (non-fatal: do not exit on curl/grep failure)
@@ -418,6 +418,6 @@ echo "Nginx Configuration Updated!"
echo "=========================================="
echo ""
echo "Next steps:"
echo "1. Deploy custom frontend: ./scripts/deploy-frontend-to-vmid5000.sh"
echo "2. Or manually copy: cp explorer-monorepo/frontend/public/index.html /var/www/html/index.html"
echo "1. Deploy the canonical Next frontend: ./scripts/deploy-next-frontend-to-vmid5000.sh"
echo "2. Verify the public surface after nginx/NPMplus cutover"
echo ""