Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts.
Made-with: Cursor
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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!**
|
||||
|
||||
|
||||
398
backend/api/freshness/freshness.go
Normal file
398
backend/api/freshness/freshness.go
Normal 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
|
||||
}
|
||||
192
backend/api/freshness/freshness_test.go
Normal file
192
backend/api/freshness/freshness_test.go
Normal 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)
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 server’s 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 header’s `script-src` includes `'unsafe-eval'`, or remove the override so the origin CSP is used.
|
||||
|
||||
|
||||
@@ -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 (1–4) 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.). |
|
||||
|
||||
154
docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md
Normal file
154
docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md
Normal 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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 (C1–L4) 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
|
||||
|
||||
|
||||
@@ -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*
|
||||
|
||||
@@ -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 5000–specific 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 5000–specific 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.).
|
||||
|
||||
---
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
530
docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md
Normal file
530
docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md
Normal 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.
|
||||
278
docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md
Normal file
278
docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md
Normal 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.
|
||||
@@ -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)
|
||||
|
||||
137
frontend/src/components/common/ActivityContextPanel.tsx
Normal file
137
frontend/src/components/common/ActivityContextPanel.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
27
frontend/src/components/common/BrandLockup.tsx
Normal file
27
frontend/src/components/common/BrandLockup.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
45
frontend/src/components/common/BrandMark.tsx
Normal file
45
frontend/src/components/common/BrandMark.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
85
frontend/src/components/common/FreshnessTrustNote.tsx
Normal file
85
frontend/src/components/common/FreshnessTrustNote.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
202
frontend/src/components/common/HeaderCommandPalette.tsx
Normal file
202
frontend/src/components/common/HeaderCommandPalette.tsx
Normal 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
57
frontend/src/components/common/UiModeContext.tsx
Normal file
57
frontend/src/components/common/UiModeContext.tsx
Normal 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}</>
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.',
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
111
frontend/src/utils/activityContext.ts
Normal file
111
frontend/src/utils/activityContext.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
132
frontend/src/utils/explorerFreshness.test.ts
Normal file
132
frontend/src/utils/explorerFreshness.test.ts
Normal 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',
|
||||
])
|
||||
})
|
||||
})
|
||||
104
frontend/src/utils/explorerFreshness.ts
Normal file
104
frontend/src/utils/explorerFreshness.ts
Normal 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)
|
||||
}
|
||||
@@ -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`
|
||||
}
|
||||
|
||||
@@ -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
281
scripts/deploy-frontend-to-vmid5000.sh
Executable file → Normal 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
|
||||
|
||||
@@ -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
73
scripts/deploy.sh
Executable file → Normal 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
|
||||
|
||||
@@ -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 ""
|
||||
|
||||
Reference in New Issue
Block a user