feat(freshness): enhance diagnostics and update snapshot structure
- Introduced a new Diagnostics struct to capture transaction visibility state and activity state. - Updated BuildSnapshot function to return diagnostics alongside snapshot, completeness, and sampling. - Enhanced test cases to validate the new diagnostics data. - Updated frontend components to utilize the new diagnostics information for improved user feedback on freshness context. This change improves the observability of transaction activity and enhances the user experience by providing clearer insights into the freshness of data.
This commit is contained in:
@@ -72,6 +72,22 @@ type Snapshot struct {
|
|||||||
LatestNonEmptyBlock Reference `json:"latest_non_empty_block"`
|
LatestNonEmptyBlock Reference `json:"latest_non_empty_block"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type Diagnostics struct {
|
||||||
|
TxVisibilityState string `json:"tx_visibility_state"`
|
||||||
|
ActivityState string `json:"activity_state"`
|
||||||
|
Explanation string `json:"explanation,omitempty"`
|
||||||
|
TxLagBlocks *int64 `json:"tx_lag_blocks,omitempty"`
|
||||||
|
TxLagSeconds *int64 `json:"tx_lag_seconds,omitempty"`
|
||||||
|
RecentBlockSampleSize *int64 `json:"recent_block_sample_size,omitempty"`
|
||||||
|
RecentNonEmptyBlocks *int64 `json:"recent_non_empty_blocks,omitempty"`
|
||||||
|
RecentTransactions *int64 `json:"recent_transactions,omitempty"`
|
||||||
|
LatestNonEmptyFromBlockFeed Reference `json:"latest_non_empty_block_from_block_feed"`
|
||||||
|
Source Source `json:"source"`
|
||||||
|
Confidence Confidence `json:"confidence"`
|
||||||
|
Provenance Provenance `json:"provenance"`
|
||||||
|
Completeness Completeness `json:"completeness"`
|
||||||
|
}
|
||||||
|
|
||||||
type SummaryCompleteness struct {
|
type SummaryCompleteness struct {
|
||||||
TransactionsFeed Completeness `json:"transactions_feed"`
|
TransactionsFeed Completeness `json:"transactions_feed"`
|
||||||
BlocksFeed Completeness `json:"blocks_feed"`
|
BlocksFeed Completeness `json:"blocks_feed"`
|
||||||
@@ -163,6 +179,49 @@ func classifyMetricPresence[T comparable](value *T) Completeness {
|
|||||||
return CompletenessComplete
|
return CompletenessComplete
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func classifyTxVisibilityState(age *int64) string {
|
||||||
|
if age == nil {
|
||||||
|
return "unavailable"
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case *age <= 15*60:
|
||||||
|
return "current"
|
||||||
|
case *age <= 3*60*60:
|
||||||
|
return "lagging"
|
||||||
|
default:
|
||||||
|
return "stale"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func classifyActivityState(txVisibility string, txLagBlocks, recentTransactions, recentNonEmptyBlocks *int64) (string, string, Completeness) {
|
||||||
|
if txVisibility == "unavailable" {
|
||||||
|
if recentTransactions != nil && *recentTransactions > 0 {
|
||||||
|
return "limited_observability", "Recent blocks show on-chain transaction activity, but indexed transaction freshness is unavailable.", CompletenessPartial
|
||||||
|
}
|
||||||
|
return "limited_observability", "Transaction freshness is unavailable, and recent block activity is limited.", CompletenessUnavailable
|
||||||
|
}
|
||||||
|
|
||||||
|
if recentTransactions != nil && *recentTransactions > 0 {
|
||||||
|
if txLagBlocks != nil && *txLagBlocks > 32 {
|
||||||
|
return "fresh_head_stale_transaction_visibility", "Recent block activity is present closer to the head than the visible indexed transaction feed.", CompletenessPartial
|
||||||
|
}
|
||||||
|
if *recentTransactions <= 3 {
|
||||||
|
return "sparse_activity", "Recent blocks contain only a small amount of transaction activity.", CompletenessComplete
|
||||||
|
}
|
||||||
|
return "active", "Recent blocks contain visible transaction activity close to the head.", CompletenessComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
if recentNonEmptyBlocks != nil && *recentNonEmptyBlocks == 0 {
|
||||||
|
return "quiet_chain", "Recent sampled head blocks are empty, which indicates a quiet chain rather than a broken explorer.", CompletenessComplete
|
||||||
|
}
|
||||||
|
|
||||||
|
if txLagBlocks != nil && *txLagBlocks > 32 {
|
||||||
|
return "fresh_head_stale_transaction_visibility", "The chain head is current, but the indexed transaction feed trails the current tip.", CompletenessPartial
|
||||||
|
}
|
||||||
|
|
||||||
|
return "sparse_activity", "Recent visible transaction activity is limited.", CompletenessComplete
|
||||||
|
}
|
||||||
|
|
||||||
func BuildSnapshot(
|
func BuildSnapshot(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
chainID int,
|
chainID int,
|
||||||
@@ -171,13 +230,22 @@ func BuildSnapshot(
|
|||||||
now time.Time,
|
now time.Time,
|
||||||
averageGasPrice *float64,
|
averageGasPrice *float64,
|
||||||
utilization *float64,
|
utilization *float64,
|
||||||
) (Snapshot, SummaryCompleteness, Sampling, error) {
|
) (Snapshot, SummaryCompleteness, Sampling, Diagnostics, error) {
|
||||||
snapshot := Snapshot{
|
snapshot := Snapshot{
|
||||||
ChainHead: unknownReference(ProvenanceRPC),
|
ChainHead: unknownReference(ProvenanceRPC),
|
||||||
LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex),
|
LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex),
|
||||||
LatestIndexedTransaction: unknownReference(ProvenanceTxIndex),
|
LatestIndexedTransaction: unknownReference(ProvenanceTxIndex),
|
||||||
LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex),
|
LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex),
|
||||||
}
|
}
|
||||||
|
diagnostics := Diagnostics{
|
||||||
|
TxVisibilityState: "unavailable",
|
||||||
|
ActivityState: "limited_observability",
|
||||||
|
LatestNonEmptyFromBlockFeed: unknownReference(ProvenanceExplorerIndex),
|
||||||
|
Source: SourceReported,
|
||||||
|
Confidence: ConfidenceMedium,
|
||||||
|
Provenance: ProvenanceComposite,
|
||||||
|
Completeness: CompletenessUnavailable,
|
||||||
|
}
|
||||||
issues := map[string]string{}
|
issues := map[string]string{}
|
||||||
|
|
||||||
if probeHead != nil {
|
if probeHead != nil {
|
||||||
@@ -270,6 +338,84 @@ func BuildSnapshot(
|
|||||||
issues["latest_non_empty_block"] = err.Error()
|
issues["latest_non_empty_block"] = err.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var latestBlockFeedNonEmptyNumber int64
|
||||||
|
var latestBlockFeedNonEmptyTime time.Time
|
||||||
|
if err := queryRow(ctx,
|
||||||
|
`SELECT b.number, b.timestamp
|
||||||
|
FROM blocks b
|
||||||
|
JOIN (
|
||||||
|
SELECT DISTINCT block_number
|
||||||
|
FROM transactions
|
||||||
|
WHERE block_number IS NOT NULL
|
||||||
|
) tx_blocks
|
||||||
|
ON tx_blocks.block_number = b.number
|
||||||
|
ORDER BY b.number DESC
|
||||||
|
LIMIT 1`,
|
||||||
|
).Scan(&latestBlockFeedNonEmptyNumber, &latestBlockFeedNonEmptyTime); err == nil {
|
||||||
|
timestamp := timePointer(latestBlockFeedNonEmptyTime)
|
||||||
|
ref := Reference{
|
||||||
|
BlockNumber: ptrInt64(latestBlockFeedNonEmptyNumber),
|
||||||
|
Timestamp: timestamp,
|
||||||
|
AgeSeconds: computeAge(timestamp, now),
|
||||||
|
Source: SourceDerived,
|
||||||
|
Confidence: ConfidenceMedium,
|
||||||
|
Provenance: ProvenanceComposite,
|
||||||
|
Completeness: snapshot.LatestIndexedTransaction.Completeness,
|
||||||
|
}
|
||||||
|
if snapshot.ChainHead.BlockNumber != nil {
|
||||||
|
distance := *snapshot.ChainHead.BlockNumber - latestBlockFeedNonEmptyNumber
|
||||||
|
if distance < 0 {
|
||||||
|
distance = 0
|
||||||
|
}
|
||||||
|
ref.DistanceFromHead = ptrInt64(distance)
|
||||||
|
}
|
||||||
|
diagnostics.LatestNonEmptyFromBlockFeed = ref
|
||||||
|
} else {
|
||||||
|
issues["latest_non_empty_block_from_block_feed"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
var recentBlockSampleSize, recentNonEmptyBlocks, recentTransactions int64
|
||||||
|
if err := queryRow(ctx,
|
||||||
|
`SELECT COUNT(*)::bigint,
|
||||||
|
COUNT(*) FILTER (WHERE COALESCE(tx_counts.tx_count, 0) > 0)::bigint,
|
||||||
|
COALESCE(SUM(COALESCE(tx_counts.tx_count, 0)), 0)::bigint
|
||||||
|
FROM (
|
||||||
|
SELECT number
|
||||||
|
FROM blocks
|
||||||
|
ORDER BY number DESC
|
||||||
|
LIMIT 128
|
||||||
|
) recent_blocks
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT block_number, COUNT(*)::bigint AS tx_count
|
||||||
|
FROM transactions
|
||||||
|
WHERE block_number IS NOT NULL
|
||||||
|
GROUP BY block_number
|
||||||
|
) tx_counts
|
||||||
|
ON tx_counts.block_number = recent_blocks.number`,
|
||||||
|
).Scan(&recentBlockSampleSize, &recentNonEmptyBlocks, &recentTransactions); err == nil {
|
||||||
|
diagnostics.RecentBlockSampleSize = ptrInt64(recentBlockSampleSize)
|
||||||
|
diagnostics.RecentNonEmptyBlocks = ptrInt64(recentNonEmptyBlocks)
|
||||||
|
diagnostics.RecentTransactions = ptrInt64(recentTransactions)
|
||||||
|
} else {
|
||||||
|
issues["recent_block_activity"] = err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
if snapshot.ChainHead.BlockNumber != nil && snapshot.LatestIndexedTransaction.BlockNumber != nil {
|
||||||
|
lag := *snapshot.ChainHead.BlockNumber - *snapshot.LatestIndexedTransaction.BlockNumber
|
||||||
|
if lag < 0 {
|
||||||
|
lag = 0
|
||||||
|
}
|
||||||
|
diagnostics.TxLagBlocks = ptrInt64(lag)
|
||||||
|
}
|
||||||
|
diagnostics.TxLagSeconds = snapshot.LatestIndexedTransaction.AgeSeconds
|
||||||
|
diagnostics.TxVisibilityState = classifyTxVisibilityState(snapshot.LatestIndexedTransaction.AgeSeconds)
|
||||||
|
diagnostics.ActivityState, diagnostics.Explanation, diagnostics.Completeness = classifyActivityState(
|
||||||
|
diagnostics.TxVisibilityState,
|
||||||
|
diagnostics.TxLagBlocks,
|
||||||
|
diagnostics.RecentTransactions,
|
||||||
|
diagnostics.RecentNonEmptyBlocks,
|
||||||
|
)
|
||||||
|
|
||||||
statsGeneratedAt := now.UTC().Format(time.RFC3339)
|
statsGeneratedAt := now.UTC().Format(time.RFC3339)
|
||||||
sampling := Sampling{
|
sampling := Sampling{
|
||||||
StatsGeneratedAt: ptrString(statsGeneratedAt),
|
StatsGeneratedAt: ptrString(statsGeneratedAt),
|
||||||
@@ -289,7 +435,7 @@ func BuildSnapshot(
|
|||||||
UtilizationMetric: classifyMetricPresence(utilization),
|
UtilizationMetric: classifyMetricPresence(utilization),
|
||||||
}
|
}
|
||||||
|
|
||||||
return snapshot, completeness, sampling, nil
|
return snapshot, completeness, sampling, diagnostics, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) {
|
func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) {
|
||||||
|
|||||||
@@ -42,6 +42,19 @@ func TestBuildSnapshotHealthyState(t *testing.T) {
|
|||||||
*dest[1].(*time.Time) = now.Add(-5 * time.Second)
|
*dest[1].(*time.Time) = now.Add(-5 * time.Second)
|
||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
|
case 4:
|
||||||
|
return fakeRow{scan: func(dest ...any) error {
|
||||||
|
*dest[0].(*int64) = 198
|
||||||
|
*dest[1].(*time.Time) = now.Add(-5 * time.Second)
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
case 5:
|
||||||
|
return fakeRow{scan: func(dest ...any) error {
|
||||||
|
*dest[0].(*int64) = 128
|
||||||
|
*dest[1].(*int64) = 12
|
||||||
|
*dest[2].(*int64) = 34
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected call %d", call)
|
t.Fatalf("unexpected call %d", call)
|
||||||
return nil
|
return nil
|
||||||
@@ -63,13 +76,14 @@ func TestBuildSnapshotHealthyState(t *testing.T) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
snapshot, completeness, sampling, diagnostics, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(200), *snapshot.ChainHead.BlockNumber)
|
require.Equal(t, int64(200), *snapshot.ChainHead.BlockNumber)
|
||||||
require.Equal(t, int64(198), *snapshot.LatestIndexedTransaction.BlockNumber)
|
require.Equal(t, int64(198), *snapshot.LatestIndexedTransaction.BlockNumber)
|
||||||
require.Equal(t, int64(2), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
require.Equal(t, int64(2), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
||||||
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
|
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
|
||||||
require.NotNil(t, sampling.StatsGeneratedAt)
|
require.NotNil(t, sampling.StatsGeneratedAt)
|
||||||
|
require.Equal(t, "active", diagnostics.ActivityState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
|
func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
|
||||||
@@ -97,6 +111,19 @@ func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
|
|||||||
*dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
|
*dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute))
|
||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
|
case 4:
|
||||||
|
return fakeRow{scan: func(dest ...any) error {
|
||||||
|
*dest[0].(*int64) = 3875998
|
||||||
|
*dest[1].(*time.Time) = now.Add(-4 * time.Second)
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
case 5:
|
||||||
|
return fakeRow{scan: func(dest ...any) error {
|
||||||
|
*dest[0].(*int64) = 128
|
||||||
|
*dest[1].(*int64) = 3
|
||||||
|
*dest[2].(*int64) = 9
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected call %d", call)
|
t.Fatalf("unexpected call %d", call)
|
||||||
return nil
|
return nil
|
||||||
@@ -118,11 +145,12 @@ func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
snapshot, completeness, _, diagnostics, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(15340), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
require.Equal(t, int64(15340), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
||||||
require.Equal(t, CompletenessStale, completeness.TransactionsFeed)
|
require.Equal(t, CompletenessStale, completeness.TransactionsFeed)
|
||||||
require.Equal(t, CompletenessComplete, completeness.BlocksFeed)
|
require.Equal(t, CompletenessComplete, completeness.BlocksFeed)
|
||||||
|
require.Equal(t, "fresh_head_stale_transaction_visibility", diagnostics.ActivityState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
|
func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
|
||||||
@@ -150,6 +178,19 @@ func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
|
|||||||
*dest[1].(*time.Time) = now.Add(-512 * time.Second)
|
*dest[1].(*time.Time) = now.Add(-512 * time.Second)
|
||||||
return nil
|
return nil
|
||||||
}}
|
}}
|
||||||
|
case 4:
|
||||||
|
return fakeRow{scan: func(dest ...any) error {
|
||||||
|
*dest[0].(*int64) = 3874902
|
||||||
|
*dest[1].(*time.Time) = now.Add(-512 * time.Second)
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
case 5:
|
||||||
|
return fakeRow{scan: func(dest ...any) error {
|
||||||
|
*dest[0].(*int64) = 128
|
||||||
|
*dest[1].(*int64) = 0
|
||||||
|
*dest[2].(*int64) = 0
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected call %d", call)
|
t.Fatalf("unexpected call %d", call)
|
||||||
return nil
|
return nil
|
||||||
@@ -171,10 +212,11 @@ func TestBuildSnapshotQuietChainButCurrent(t *testing.T) {
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
snapshot, completeness, _, diagnostics, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, int64(98), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
require.Equal(t, int64(98), *snapshot.LatestNonEmptyBlock.DistanceFromHead)
|
||||||
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
|
require.Equal(t, CompletenessComplete, completeness.TransactionsFeed)
|
||||||
|
require.Equal(t, "quiet_chain", diagnostics.ActivityState)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
|
func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
|
||||||
@@ -184,9 +226,10 @@ func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) {
|
|||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil)
|
snapshot, completeness, sampling, diagnostics, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Nil(t, snapshot.ChainHead.BlockNumber)
|
require.Nil(t, snapshot.ChainHead.BlockNumber)
|
||||||
require.Equal(t, CompletenessUnavailable, completeness.TransactionsFeed)
|
require.Equal(t, CompletenessUnavailable, completeness.TransactionsFeed)
|
||||||
require.NotNil(t, sampling.StatsGeneratedAt)
|
require.NotNil(t, sampling.StatsGeneratedAt)
|
||||||
|
require.Equal(t, "limited_observability", diagnostics.ActivityState)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type explorerStats struct {
|
|||||||
Freshness freshness.Snapshot `json:"freshness"`
|
Freshness freshness.Snapshot `json:"freshness"`
|
||||||
Completeness freshness.SummaryCompleteness `json:"completeness"`
|
Completeness freshness.SummaryCompleteness `json:"completeness"`
|
||||||
Sampling freshness.Sampling `json:"sampling"`
|
Sampling freshness.Sampling `json:"sampling"`
|
||||||
|
Diagnostics freshness.Diagnostics `json:"diagnostics"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type explorerGasPrices struct {
|
type explorerGasPrices struct {
|
||||||
@@ -160,7 +161,7 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
|
|||||||
}
|
}
|
||||||
|
|
||||||
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
|
rpcURL := strings.TrimSpace(os.Getenv("RPC_URL"))
|
||||||
snapshot, completeness, sampling, err := freshness.BuildSnapshot(
|
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
|
||||||
ctx,
|
ctx,
|
||||||
chainID,
|
chainID,
|
||||||
queryRow,
|
queryRow,
|
||||||
@@ -185,6 +186,7 @@ func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc
|
|||||||
stats.Freshness = snapshot
|
stats.Freshness = snapshot
|
||||||
stats.Completeness = completeness
|
stats.Completeness = completeness
|
||||||
stats.Sampling = sampling
|
stats.Sampling = sampling
|
||||||
|
stats.Diagnostics = diagnostics
|
||||||
|
|
||||||
return stats, nil
|
return stats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,13 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
|
|||||||
case 11:
|
case 11:
|
||||||
*dest[0].(*int64) = 40
|
*dest[0].(*int64) = 40
|
||||||
*dest[1].(*time.Time) = time.Now().Add(-5 * time.Second)
|
*dest[1].(*time.Time) = time.Now().Add(-5 * time.Second)
|
||||||
|
case 12:
|
||||||
|
*dest[0].(*int64) = 42
|
||||||
|
*dest[1].(*time.Time) = time.Now().Add(-3 * time.Second)
|
||||||
|
case 13:
|
||||||
|
*dest[0].(*int64) = 128
|
||||||
|
*dest[1].(*int64) = 10
|
||||||
|
*dest[2].(*int64) = 22
|
||||||
default:
|
default:
|
||||||
t.Fatalf("unexpected query call %d", call)
|
t.Fatalf("unexpected query call %d", call)
|
||||||
}
|
}
|
||||||
@@ -102,6 +109,8 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) {
|
|||||||
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
require.NotNil(t, stats.Freshness.ChainHead.BlockNumber)
|
||||||
require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber)
|
require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber)
|
||||||
require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead)
|
require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead)
|
||||||
|
require.Equal(t, "active", stats.Diagnostics.ActivityState)
|
||||||
|
require.Equal(t, int64(4), *stats.Diagnostics.TxLagBlocks)
|
||||||
require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source))
|
require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source))
|
||||||
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics)
|
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics)
|
||||||
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric)
|
require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric)
|
||||||
|
|||||||
@@ -50,12 +50,12 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
|||||||
}
|
}
|
||||||
|
|
||||||
rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter)
|
rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter)
|
||||||
track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
|
track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error) {
|
||||||
if s.db == nil {
|
if s.db == nil {
|
||||||
return nil, nil, nil, nil
|
return nil, nil, nil, nil, nil
|
||||||
}
|
}
|
||||||
now := time.Now().UTC()
|
now := time.Now().UTC()
|
||||||
snapshot, completeness, sampling, err := freshness.BuildSnapshot(
|
snapshot, completeness, sampling, diagnostics, err := freshness.BuildSnapshot(
|
||||||
ctx,
|
ctx,
|
||||||
s.chainID,
|
s.chainID,
|
||||||
s.db.QueryRow,
|
s.db.QueryRow,
|
||||||
@@ -67,9 +67,9 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware
|
|||||||
nil,
|
nil,
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, nil, err
|
return nil, nil, nil, nil, err
|
||||||
}
|
}
|
||||||
return &snapshot, &completeness, &sampling, nil
|
return &snapshot, &completeness, &sampling, &diagnostics, nil
|
||||||
})
|
})
|
||||||
|
|
||||||
// Track 1 routes (public, optional auth)
|
// Track 1 routes (public, optional auth)
|
||||||
|
|||||||
@@ -132,7 +132,7 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
data["operator_verify"] = ov
|
data["operator_verify"] = ov
|
||||||
}
|
}
|
||||||
if s.freshnessLoader != nil {
|
if s.freshnessLoader != nil {
|
||||||
if snapshot, completeness, sampling, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
|
if snapshot, completeness, sampling, diagnostics, err := s.freshnessLoader(ctx); err == nil && snapshot != nil {
|
||||||
subsystems := map[string]interface{}{
|
subsystems := map[string]interface{}{
|
||||||
"rpc_head": map[string]interface{}{
|
"rpc_head": map[string]interface{}{
|
||||||
"status": chainStatusFromProbe(p138),
|
"status": chainStatusFromProbe(p138),
|
||||||
@@ -194,6 +194,9 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface
|
|||||||
data["freshness"] = snapshot
|
data["freshness"] = snapshot
|
||||||
data["subsystems"] = subsystems
|
data["subsystems"] = subsystems
|
||||||
data["sampling"] = sampling
|
data["sampling"] = sampling
|
||||||
|
if diagnostics != nil {
|
||||||
|
data["diagnostics"] = diagnostics
|
||||||
|
}
|
||||||
data["mode"] = map[string]interface{}{
|
data["mode"] = map[string]interface{}{
|
||||||
"kind": modeKind,
|
"kind": modeKind,
|
||||||
"updated_at": now,
|
"updated_at": now,
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
|||||||
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
t.Setenv("MISSION_CONTROL_CCIP_JSON", "")
|
||||||
|
|
||||||
s := &Server{
|
s := &Server{
|
||||||
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) {
|
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error) {
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
head := int64(16)
|
head := int64(16)
|
||||||
txBlock := int64(12)
|
txBlock := int64(12)
|
||||||
@@ -187,6 +187,14 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
|||||||
BlocksFeed: freshness.CompletenessComplete,
|
BlocksFeed: freshness.CompletenessComplete,
|
||||||
},
|
},
|
||||||
&freshness.Sampling{StatsGeneratedAt: &now},
|
&freshness.Sampling{StatsGeneratedAt: &now},
|
||||||
|
&freshness.Diagnostics{
|
||||||
|
TxVisibilityState: "lagging",
|
||||||
|
ActivityState: "fresh_head_stale_transaction_visibility",
|
||||||
|
Source: freshness.SourceReported,
|
||||||
|
Confidence: freshness.ConfidenceMedium,
|
||||||
|
Provenance: freshness.ProvenanceComposite,
|
||||||
|
Completeness: freshness.CompletenessPartial,
|
||||||
|
},
|
||||||
nil
|
nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -201,6 +209,7 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) {
|
|||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
require.Equal(t, true, probe["ok"])
|
require.Equal(t, true, probe["ok"])
|
||||||
require.Contains(t, got, "freshness")
|
require.Contains(t, got, "freshness")
|
||||||
|
require.Contains(t, got, "diagnostics")
|
||||||
require.Contains(t, got, "subsystems")
|
require.Contains(t, got, "subsystems")
|
||||||
require.Contains(t, got, "mode")
|
require.Contains(t, got, "mode")
|
||||||
}
|
}
|
||||||
@@ -245,8 +254,8 @@ func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) {
|
|||||||
t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz")
|
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) {
|
freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error) {
|
||||||
return nil, nil, nil, nil
|
return nil, nil, nil, nil, nil
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
got := s.BuildBridgeStatusData(context.Background())
|
got := s.BuildBridgeStatusData(context.Background())
|
||||||
|
|||||||
@@ -21,13 +21,13 @@ var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`)
|
|||||||
// Server handles Track 1 endpoints (uses RPC gateway from lib)
|
// Server handles Track 1 endpoints (uses RPC gateway from lib)
|
||||||
type Server struct {
|
type Server struct {
|
||||||
rpcGateway *gateway.RPCGateway
|
rpcGateway *gateway.RPCGateway
|
||||||
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error)
|
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewServer creates a new Track 1 server
|
// NewServer creates a new Track 1 server
|
||||||
func NewServer(
|
func NewServer(
|
||||||
rpcGateway *gateway.RPCGateway,
|
rpcGateway *gateway.RPCGateway,
|
||||||
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error),
|
freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, *freshness.Diagnostics, error),
|
||||||
) *Server {
|
) *Server {
|
||||||
return &Server{
|
return &Server{
|
||||||
rpcGateway: rpcGateway,
|
rpcGateway: rpcGateway,
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
summarizeFreshnessConfidence,
|
summarizeFreshnessConfidence,
|
||||||
} from '@/utils/explorerFreshness'
|
} from '@/utils/explorerFreshness'
|
||||||
import { formatRelativeAge } from '@/utils/format'
|
import { formatRelativeAge } from '@/utils/format'
|
||||||
|
import { useUiMode } from './UiModeContext'
|
||||||
|
|
||||||
function buildSummary(context: ChainActivityContext) {
|
function buildSummary(context: ChainActivityContext) {
|
||||||
if (context.transaction_visibility_unavailable) {
|
if (context.transaction_visibility_unavailable) {
|
||||||
@@ -27,7 +28,11 @@ function buildSummary(context: ChainActivityContext) {
|
|||||||
return 'Freshness context is based on the latest visible public explorer evidence.'
|
return 'Freshness context is based on the latest visible public explorer evidence.'
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildDetail(context: ChainActivityContext) {
|
function buildDetail(context: ChainActivityContext, diagnosticExplanation?: string | null) {
|
||||||
|
if (diagnosticExplanation) {
|
||||||
|
return diagnosticExplanation
|
||||||
|
}
|
||||||
|
|
||||||
if (context.transaction_visibility_unavailable) {
|
if (context.transaction_visibility_unavailable) {
|
||||||
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
return 'Use chain-head visibility and the last non-empty block as the current trust anchors.'
|
||||||
}
|
}
|
||||||
@@ -60,15 +65,38 @@ export default function FreshnessTrustNote({
|
|||||||
scopeLabel?: string
|
scopeLabel?: string
|
||||||
className?: string
|
className?: string
|
||||||
}) {
|
}) {
|
||||||
|
const { mode } = useUiMode()
|
||||||
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
|
const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus)
|
||||||
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
|
const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus)
|
||||||
|
const diagnosticExplanation = stats?.diagnostics?.explanation || bridgeStatus?.data?.diagnostics?.explanation || null
|
||||||
const normalizedClassName = className ? ` ${className}` : ''
|
const normalizedClassName = className ? ` ${className}` : ''
|
||||||
|
|
||||||
|
if (mode === 'expert') {
|
||||||
|
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="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div className="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||||
|
<div className="text-xs text-gray-600 dark:text-gray-400">{sourceLabel}</div>
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
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={`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="font-medium text-gray-900 dark:text-white">{buildSummary(context)}</div>
|
||||||
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||||
{buildDetail(context)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
|
{buildDetail(context, diagnosticExplanation)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
<div className="mt-2 flex flex-wrap gap-2 text-xs text-gray-500 dark:text-gray-400">
|
||||||
{confidenceBadges.map((badge) => (
|
{confidenceBadges.map((badge) => (
|
||||||
|
|||||||
@@ -133,6 +133,7 @@ export default function AnalyticsOperationsPage({
|
|||||||
latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null,
|
latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null,
|
||||||
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
||||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||||
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||||
}),
|
}),
|
||||||
[blocks, bridgeStatus, stats, transactions],
|
[blocks, bridgeStatus, stats, transactions],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -9,7 +9,12 @@ import {
|
|||||||
type MissionControlRelayPayload,
|
type MissionControlRelayPayload,
|
||||||
type MissionControlRelaySnapshot,
|
type MissionControlRelaySnapshot,
|
||||||
} from '@/services/api/missionControl'
|
} from '@/services/api/missionControl'
|
||||||
|
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||||
import { explorerFeaturePages } from '@/data/explorerOperations'
|
import { explorerFeaturePages } from '@/data/explorerOperations'
|
||||||
|
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||||
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||||
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||||
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||||
|
|
||||||
type FeedState = 'connecting' | 'live' | 'fallback'
|
type FeedState = 'connecting' | 'live' | 'fallback'
|
||||||
|
|
||||||
@@ -61,6 +66,9 @@ function relayPolicyCue(snapshot: MissionControlRelaySnapshot | null): string |
|
|||||||
if (snapshot.last_error?.scope === 'bridge_inventory') {
|
if (snapshot.last_error?.scope === 'bridge_inventory') {
|
||||||
return 'Queued release waiting on bridge inventory'
|
return 'Queued release waiting on bridge inventory'
|
||||||
}
|
}
|
||||||
|
if (snapshot.last_error?.scope === 'bridge_inventory_probe') {
|
||||||
|
return 'Bridge inventory check is temporarily unavailable'
|
||||||
|
}
|
||||||
if (String(snapshot.status || '').toLowerCase() === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
if (String(snapshot.status || '').toLowerCase() === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
||||||
return 'Delivery disabled by policy'
|
return 'Delivery disabled by policy'
|
||||||
}
|
}
|
||||||
@@ -130,10 +138,13 @@ function ActionLink({
|
|||||||
|
|
||||||
export default function BridgeMonitoringPage({
|
export default function BridgeMonitoringPage({
|
||||||
initialBridgeStatus = null,
|
initialBridgeStatus = null,
|
||||||
|
initialStats = null,
|
||||||
}: {
|
}: {
|
||||||
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||||
|
initialStats?: ExplorerStats | null
|
||||||
}) {
|
}) {
|
||||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||||
|
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||||
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
const [feedState, setFeedState] = useState<FeedState>(initialBridgeStatus ? 'fallback' : 'connecting')
|
||||||
const page = explorerFeaturePages.bridge
|
const page = explorerFeaturePages.bridge
|
||||||
|
|
||||||
@@ -142,9 +153,15 @@ export default function BridgeMonitoringPage({
|
|||||||
|
|
||||||
const loadSnapshot = async () => {
|
const loadSnapshot = async () => {
|
||||||
try {
|
try {
|
||||||
const snapshot = await missionControlApi.getBridgeStatus()
|
const [snapshot, latestStats] = await Promise.all([
|
||||||
|
missionControlApi.getBridgeStatus(),
|
||||||
|
statsApi.get().catch(() => null),
|
||||||
|
])
|
||||||
if (!cancelled) {
|
if (!cancelled) {
|
||||||
setBridgeStatus(snapshot)
|
setBridgeStatus(snapshot)
|
||||||
|
if (latestStats) {
|
||||||
|
setStats(latestStats)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
if (!cancelled && process.env.NODE_ENV !== 'production') {
|
||||||
@@ -178,6 +195,19 @@ export default function BridgeMonitoringPage({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
const activityContext = useMemo(
|
||||||
|
() =>
|
||||||
|
summarizeChainActivity({
|
||||||
|
blocks: [],
|
||||||
|
transactions: [],
|
||||||
|
latestBlockNumber: stats?.latest_block,
|
||||||
|
latestBlockTimestamp: null,
|
||||||
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||||
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||||
|
}),
|
||||||
|
[bridgeStatus, stats],
|
||||||
|
)
|
||||||
|
|
||||||
const relayLanes = useMemo((): RelayLaneCard[] => {
|
const relayLanes = useMemo((): RelayLaneCard[] => {
|
||||||
const relays = getMissionControlRelays(bridgeStatus)
|
const relays = getMissionControlRelays(bridgeStatus)
|
||||||
if (!relays) return []
|
if (!relays) return []
|
||||||
@@ -191,7 +221,12 @@ export default function BridgeMonitoringPage({
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label: getMissionControlRelayLabel(key),
|
label: getMissionControlRelayLabel(key),
|
||||||
status: snapshot?.last_error?.scope === 'bridge_inventory' ? 'underfunded' : status,
|
status:
|
||||||
|
snapshot?.last_error?.scope === 'bridge_inventory'
|
||||||
|
? 'underfunded'
|
||||||
|
: snapshot?.last_error?.scope === 'bridge_inventory_probe'
|
||||||
|
? 'warning'
|
||||||
|
: status,
|
||||||
profile: snapshot?.service?.profile || key,
|
profile: snapshot?.service?.profile || key,
|
||||||
sourceChain: snapshot?.source?.chain_name || 'Unknown',
|
sourceChain: snapshot?.source?.chain_name || 'Unknown',
|
||||||
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
|
destinationChain: snapshot?.destination?.chain_name || 'Unknown',
|
||||||
@@ -244,6 +279,17 @@ export default function BridgeMonitoringPage({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ActivityContextPanel context={activityContext} title="Bridge Freshness Context" />
|
||||||
|
<FreshnessTrustNote
|
||||||
|
className="mt-3"
|
||||||
|
context={activityContext}
|
||||||
|
stats={stats}
|
||||||
|
bridgeStatus={bridgeStatus}
|
||||||
|
scopeLabel="Bridge relay posture is shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 grid gap-4 lg:grid-cols-3">
|
<div className="mb-6 grid gap-4 lg:grid-cols-3">
|
||||||
<Card className="border border-sky-200 bg-sky-50/70 dark:border-sky-900/50 dark:bg-sky-950/20">
|
<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">
|
<div className="text-sm font-semibold uppercase tracking-wide text-sky-800 dark:text-sky-100">
|
||||||
|
|||||||
@@ -15,6 +15,12 @@ import {
|
|||||||
} from '@/services/api/liquidity'
|
} from '@/services/api/liquidity'
|
||||||
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
import { plannerApi, type InternalExecutionPlanResponse, type PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||||
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
import { routesApi, type MissionControlLiquidityPool, type RouteMatrixResponse } from '@/services/api/routes'
|
||||||
|
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
|
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||||
|
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||||
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||||
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||||
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||||
import {
|
import {
|
||||||
formatCurrency,
|
formatCurrency,
|
||||||
formatNumber,
|
formatNumber,
|
||||||
@@ -43,6 +49,8 @@ interface LiquidityOperationsPageProps {
|
|||||||
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
|
initialPlannerCapabilities?: PlannerCapabilitiesResponse | null
|
||||||
initialInternalPlan?: InternalExecutionPlanResponse | null
|
initialInternalPlan?: InternalExecutionPlanResponse | null
|
||||||
initialTokenPoolRecords?: TokenPoolRecord[]
|
initialTokenPoolRecords?: TokenPoolRecord[]
|
||||||
|
initialStats?: ExplorerStats | null
|
||||||
|
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
|
function routePairLabel(routeId: string, routeLabel: string, tokenIn?: string, tokenOut?: string): string {
|
||||||
@@ -55,12 +63,16 @@ export default function LiquidityOperationsPage({
|
|||||||
initialPlannerCapabilities = null,
|
initialPlannerCapabilities = null,
|
||||||
initialInternalPlan = null,
|
initialInternalPlan = null,
|
||||||
initialTokenPoolRecords = [],
|
initialTokenPoolRecords = [],
|
||||||
|
initialStats = null,
|
||||||
|
initialBridgeStatus = null,
|
||||||
}: LiquidityOperationsPageProps) {
|
}: LiquidityOperationsPageProps) {
|
||||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
||||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||||
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
const [plannerCapabilities, setPlannerCapabilities] = useState<PlannerCapabilitiesResponse | null>(initialPlannerCapabilities)
|
||||||
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
const [internalPlan, setInternalPlan] = useState<InternalExecutionPlanResponse | null>(initialInternalPlan)
|
||||||
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>(initialTokenPoolRecords)
|
const [tokenPoolRecords, setTokenPoolRecords] = useState<TokenPoolRecord[]>(initialTokenPoolRecords)
|
||||||
|
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||||
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||||
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
|
const [copiedEndpoint, setCopiedEndpoint] = useState<string | null>(null)
|
||||||
|
|
||||||
@@ -72,7 +84,9 @@ export default function LiquidityOperationsPage({
|
|||||||
initialRouteMatrix &&
|
initialRouteMatrix &&
|
||||||
initialPlannerCapabilities &&
|
initialPlannerCapabilities &&
|
||||||
initialInternalPlan &&
|
initialInternalPlan &&
|
||||||
initialTokenPoolRecords.length > 0
|
initialTokenPoolRecords.length > 0 &&
|
||||||
|
initialStats &&
|
||||||
|
initialBridgeStatus
|
||||||
) {
|
) {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
@@ -80,12 +94,14 @@ export default function LiquidityOperationsPage({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult] =
|
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] =
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
configApi.getTokenList(),
|
configApi.getTokenList(),
|
||||||
routesApi.getRouteMatrix(),
|
routesApi.getRouteMatrix(),
|
||||||
plannerApi.getCapabilities(),
|
plannerApi.getCapabilities(),
|
||||||
plannerApi.getInternalExecutionPlan(),
|
plannerApi.getInternalExecutionPlan(),
|
||||||
|
statsApi.get(),
|
||||||
|
missionControlApi.getBridgeStatus(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -94,6 +110,8 @@ export default function LiquidityOperationsPage({
|
|||||||
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
|
if (routeMatrixResult.status === 'fulfilled') setRouteMatrix(routeMatrixResult.value)
|
||||||
if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value)
|
if (plannerCapabilitiesResult.status === 'fulfilled') setPlannerCapabilities(plannerCapabilitiesResult.value)
|
||||||
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
if (planResult.status === 'fulfilled') setInternalPlan(planResult.value)
|
||||||
|
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||||||
|
if (bridgeResult.status === 'fulfilled') setBridgeStatus(bridgeResult.value)
|
||||||
|
|
||||||
if (tokenListResult.status === 'fulfilled') {
|
if (tokenListResult.status === 'fulfilled') {
|
||||||
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
|
const featuredTokens = selectFeaturedLiquidityTokens(tokenListResult.value.tokens || [])
|
||||||
@@ -113,14 +131,10 @@ export default function LiquidityOperationsPage({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const failedCount = [
|
const results = [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, planResult, statsResult, bridgeResult] as const
|
||||||
tokenListResult,
|
const failedCount = results.filter((result) => result.status === 'rejected').length
|
||||||
routeMatrixResult,
|
|
||||||
plannerCapabilitiesResult,
|
|
||||||
planResult,
|
|
||||||
].filter((result) => result.status === 'rejected').length
|
|
||||||
|
|
||||||
if (failedCount === 4) {
|
if (failedCount === results.length) {
|
||||||
setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.')
|
setLoadingError('Live liquidity data is temporarily unavailable from the public explorer APIs.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -137,9 +151,11 @@ export default function LiquidityOperationsPage({
|
|||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [
|
}, [
|
||||||
|
initialBridgeStatus,
|
||||||
initialInternalPlan,
|
initialInternalPlan,
|
||||||
initialPlannerCapabilities,
|
initialPlannerCapabilities,
|
||||||
initialRouteMatrix,
|
initialRouteMatrix,
|
||||||
|
initialStats,
|
||||||
initialTokenList,
|
initialTokenList,
|
||||||
initialTokenPoolRecords,
|
initialTokenPoolRecords,
|
||||||
])
|
])
|
||||||
@@ -168,6 +184,18 @@ export default function LiquidityOperationsPage({
|
|||||||
() => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size,
|
() => new Set(aggregatedPools.map((pool) => pool.dex).filter(Boolean)).size,
|
||||||
[aggregatedPools]
|
[aggregatedPools]
|
||||||
)
|
)
|
||||||
|
const activityContext = useMemo(
|
||||||
|
() =>
|
||||||
|
summarizeChainActivity({
|
||||||
|
blocks: [],
|
||||||
|
transactions: [],
|
||||||
|
latestBlockNumber: stats?.latest_block,
|
||||||
|
latestBlockTimestamp: null,
|
||||||
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||||
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||||
|
}),
|
||||||
|
[bridgeStatus, stats],
|
||||||
|
)
|
||||||
|
|
||||||
const insightLines = useMemo(
|
const insightLines = useMemo(
|
||||||
() => [
|
() => [
|
||||||
@@ -240,6 +268,17 @@ export default function LiquidityOperationsPage({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ActivityContextPanel context={activityContext} title="Liquidity Freshness Context" />
|
||||||
|
<FreshnessTrustNote
|
||||||
|
className="mt-3"
|
||||||
|
context={activityContext}
|
||||||
|
stats={stats}
|
||||||
|
bridgeStatus={bridgeStatus}
|
||||||
|
scopeLabel="Liquidity inventory and planner posture are shown alongside the same explorer freshness model used on the homepage and core explorer routes"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mb-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { summarizeChainActivity } from '@/utils/activityContext'
|
|||||||
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||||
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||||
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||||
|
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||||
|
|
||||||
function relativeAge(isoString?: string): string {
|
function relativeAge(isoString?: string): string {
|
||||||
if (!isoString) return 'Unknown'
|
if (!isoString) return 'Unknown'
|
||||||
@@ -56,6 +57,7 @@ interface OperationsHubPageProps {
|
|||||||
initialNetworksConfig?: NetworksConfigResponse | null
|
initialNetworksConfig?: NetworksConfigResponse | null
|
||||||
initialTokenList?: TokenListResponse | null
|
initialTokenList?: TokenListResponse | null
|
||||||
initialCapabilities?: CapabilitiesResponse | null
|
initialCapabilities?: CapabilitiesResponse | null
|
||||||
|
initialStats?: ExplorerStats | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OperationsHubPage({
|
export default function OperationsHubPage({
|
||||||
@@ -64,6 +66,7 @@ export default function OperationsHubPage({
|
|||||||
initialNetworksConfig = null,
|
initialNetworksConfig = null,
|
||||||
initialTokenList = null,
|
initialTokenList = null,
|
||||||
initialCapabilities = null,
|
initialCapabilities = null,
|
||||||
|
initialStats = null,
|
||||||
}: OperationsHubPageProps) {
|
}: OperationsHubPageProps) {
|
||||||
const { mode } = useUiMode()
|
const { mode } = useUiMode()
|
||||||
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||||
@@ -71,6 +74,7 @@ export default function OperationsHubPage({
|
|||||||
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
|
const [networksConfig, setNetworksConfig] = useState<NetworksConfigResponse | null>(initialNetworksConfig)
|
||||||
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
const [tokenList, setTokenList] = useState<TokenListResponse | null>(initialTokenList)
|
||||||
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
|
const [capabilities, setCapabilities] = useState<CapabilitiesResponse | null>(initialCapabilities)
|
||||||
|
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||||
const page = explorerFeaturePages.operations
|
const page = explorerFeaturePages.operations
|
||||||
|
|
||||||
@@ -78,13 +82,14 @@ export default function OperationsHubPage({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] =
|
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult, statsResult] =
|
||||||
await Promise.allSettled([
|
await Promise.allSettled([
|
||||||
missionControlApi.getBridgeStatus(),
|
missionControlApi.getBridgeStatus(),
|
||||||
routesApi.getRouteMatrix(),
|
routesApi.getRouteMatrix(),
|
||||||
configApi.getNetworks(),
|
configApi.getNetworks(),
|
||||||
configApi.getTokenList(),
|
configApi.getTokenList(),
|
||||||
configApi.getCapabilities(),
|
configApi.getCapabilities(),
|
||||||
|
statsApi.get(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -94,6 +99,7 @@ export default function OperationsHubPage({
|
|||||||
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
|
if (networksResult.status === 'fulfilled') setNetworksConfig(networksResult.value)
|
||||||
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
if (tokenListResult.status === 'fulfilled') setTokenList(tokenListResult.value)
|
||||||
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
|
if (capabilitiesResult.status === 'fulfilled') setCapabilities(capabilitiesResult.value)
|
||||||
|
if (statsResult.status === 'fulfilled') setStats(statsResult.value)
|
||||||
|
|
||||||
const failedCount = [
|
const failedCount = [
|
||||||
bridgeResult,
|
bridgeResult,
|
||||||
@@ -101,9 +107,10 @@ export default function OperationsHubPage({
|
|||||||
networksResult,
|
networksResult,
|
||||||
tokenListResult,
|
tokenListResult,
|
||||||
capabilitiesResult,
|
capabilitiesResult,
|
||||||
|
statsResult,
|
||||||
].filter((result) => result.status === 'rejected').length
|
].filter((result) => result.status === 'rejected').length
|
||||||
|
|
||||||
if (failedCount === 5) {
|
if (failedCount === 6) {
|
||||||
setLoadingError('Public explorer operations data is temporarily unavailable.')
|
setLoadingError('Public explorer operations data is temporarily unavailable.')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -153,9 +160,10 @@ export default function OperationsHubPage({
|
|||||||
? Number(bridgeStatus.data.chains['138'].block_number)
|
? Number(bridgeStatus.data.chains['138'].block_number)
|
||||||
: null,
|
: null,
|
||||||
latestBlockTimestamp: null,
|
latestBlockTimestamp: null,
|
||||||
freshness: resolveEffectiveFreshness(null, bridgeStatus),
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||||
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||||
}),
|
}),
|
||||||
[bridgeStatus],
|
[bridgeStatus, stats],
|
||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -191,6 +199,7 @@ export default function OperationsHubPage({
|
|||||||
<FreshnessTrustNote
|
<FreshnessTrustNote
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
context={activityContext}
|
context={activityContext}
|
||||||
|
stats={stats}
|
||||||
bridgeStatus={bridgeStatus}
|
bridgeStatus={bridgeStatus}
|
||||||
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
|
scopeLabel="This page reflects mission-control freshness, public bridge status, and explorer-served config surfaces."
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,11 +9,19 @@ import {
|
|||||||
type RouteMatrixRoute,
|
type RouteMatrixRoute,
|
||||||
type RouteMatrixResponse,
|
type RouteMatrixResponse,
|
||||||
} from '@/services/api/routes'
|
} from '@/services/api/routes'
|
||||||
|
import { missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
|
import { statsApi, type ExplorerStats } from '@/services/api/stats'
|
||||||
|
import { summarizeChainActivity } from '@/utils/activityContext'
|
||||||
|
import ActivityContextPanel from '@/components/common/ActivityContextPanel'
|
||||||
|
import FreshnessTrustNote from '@/components/common/FreshnessTrustNote'
|
||||||
|
import { resolveEffectiveFreshness } from '@/utils/explorerFreshness'
|
||||||
|
|
||||||
interface RoutesMonitoringPageProps {
|
interface RoutesMonitoringPageProps {
|
||||||
initialRouteMatrix?: RouteMatrixResponse | null
|
initialRouteMatrix?: RouteMatrixResponse | null
|
||||||
initialNetworks?: ExplorerNetwork[]
|
initialNetworks?: ExplorerNetwork[]
|
||||||
initialPools?: MissionControlLiquidityPool[]
|
initialPools?: MissionControlLiquidityPool[]
|
||||||
|
initialStats?: ExplorerStats | null
|
||||||
|
initialBridgeStatus?: MissionControlBridgeStatusResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
||||||
@@ -90,10 +98,14 @@ export default function RoutesMonitoringPage({
|
|||||||
initialRouteMatrix = null,
|
initialRouteMatrix = null,
|
||||||
initialNetworks = [],
|
initialNetworks = [],
|
||||||
initialPools = [],
|
initialPools = [],
|
||||||
|
initialStats = null,
|
||||||
|
initialBridgeStatus = null,
|
||||||
}: RoutesMonitoringPageProps) {
|
}: RoutesMonitoringPageProps) {
|
||||||
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
const [routeMatrix, setRouteMatrix] = useState<RouteMatrixResponse | null>(initialRouteMatrix)
|
||||||
const [networks, setNetworks] = useState<ExplorerNetwork[]>(initialNetworks)
|
const [networks, setNetworks] = useState<ExplorerNetwork[]>(initialNetworks)
|
||||||
const [pools, setPools] = useState<MissionControlLiquidityPool[]>(initialPools)
|
const [pools, setPools] = useState<MissionControlLiquidityPool[]>(initialPools)
|
||||||
|
const [stats, setStats] = useState<ExplorerStats | null>(initialStats)
|
||||||
|
const [bridgeStatus, setBridgeStatus] = useState<MissionControlBridgeStatusResponse | null>(initialBridgeStatus)
|
||||||
const [loadingError, setLoadingError] = useState<string | null>(null)
|
const [loadingError, setLoadingError] = useState<string | null>(null)
|
||||||
const page = explorerFeaturePages.routes
|
const page = explorerFeaturePages.routes
|
||||||
|
|
||||||
@@ -101,10 +113,12 @@ export default function RoutesMonitoringPage({
|
|||||||
let cancelled = false
|
let cancelled = false
|
||||||
|
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [matrixResult, networksResult, poolsResult] = await Promise.allSettled([
|
const [matrixResult, networksResult, poolsResult, statsResult, bridgeResult] = await Promise.allSettled([
|
||||||
routesApi.getRouteMatrix(),
|
routesApi.getRouteMatrix(),
|
||||||
routesApi.getNetworks(),
|
routesApi.getNetworks(),
|
||||||
routesApi.getTokenPools(canonicalLiquidityToken),
|
routesApi.getTokenPools(canonicalLiquidityToken),
|
||||||
|
statsApi.get(),
|
||||||
|
missionControlApi.getBridgeStatus(),
|
||||||
])
|
])
|
||||||
|
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
@@ -118,11 +132,19 @@ export default function RoutesMonitoringPage({
|
|||||||
if (poolsResult.status === 'fulfilled') {
|
if (poolsResult.status === 'fulfilled') {
|
||||||
setPools(poolsResult.value.pools || [])
|
setPools(poolsResult.value.pools || [])
|
||||||
}
|
}
|
||||||
|
if (statsResult.status === 'fulfilled') {
|
||||||
|
setStats(statsResult.value)
|
||||||
|
}
|
||||||
|
if (bridgeResult.status === 'fulfilled') {
|
||||||
|
setBridgeStatus(bridgeResult.value)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
matrixResult.status === 'rejected' &&
|
matrixResult.status === 'rejected' &&
|
||||||
networksResult.status === 'rejected' &&
|
networksResult.status === 'rejected' &&
|
||||||
poolsResult.status === 'rejected'
|
poolsResult.status === 'rejected' &&
|
||||||
|
statsResult.status === 'rejected' &&
|
||||||
|
bridgeResult.status === 'rejected'
|
||||||
) {
|
) {
|
||||||
setLoadingError('Live route inventory is temporarily unavailable.')
|
setLoadingError('Live route inventory is temporarily unavailable.')
|
||||||
}
|
}
|
||||||
@@ -166,6 +188,18 @@ export default function RoutesMonitoringPage({
|
|||||||
.filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0))
|
.filter((network) => [138, 1, 651940, 56, 43114].includes(network.chainIdDecimal || 0))
|
||||||
.sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0))
|
.sort((left, right) => (left.chainIdDecimal || 0) - (right.chainIdDecimal || 0))
|
||||||
}, [networks])
|
}, [networks])
|
||||||
|
const activityContext = useMemo(
|
||||||
|
() =>
|
||||||
|
summarizeChainActivity({
|
||||||
|
blocks: [],
|
||||||
|
transactions: [],
|
||||||
|
latestBlockNumber: stats?.latest_block,
|
||||||
|
latestBlockTimestamp: null,
|
||||||
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||||
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||||
|
}),
|
||||||
|
[bridgeStatus, stats],
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||||
@@ -195,6 +229,17 @@ export default function RoutesMonitoringPage({
|
|||||||
</Card>
|
</Card>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div className="mb-6">
|
||||||
|
<ActivityContextPanel context={activityContext} title="Routes Freshness Context" />
|
||||||
|
<FreshnessTrustNote
|
||||||
|
className="mt-3"
|
||||||
|
context={activityContext}
|
||||||
|
stats={stats}
|
||||||
|
bridgeStatus={bridgeStatus}
|
||||||
|
scopeLabel="Route availability reflects the current public route matrix and the same explorer freshness model used on the core explorer pages"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
<div className="mb-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||||
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
<Card className="border border-emerald-200 bg-emerald-50/70 dark:border-emerald-900/50 dark:bg-emerald-950/20">
|
||||||
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
<div className="text-sm font-semibold uppercase tracking-wide text-emerald-800 dark:text-emerald-100">
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ function formatGasPriceGwei(value: number) {
|
|||||||
return `${value.toFixed(3)} gwei`
|
return `${value.toFixed(3)} gwei`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compactStatNote(guided: string, expert: string, mode: 'guided' | 'expert') {
|
||||||
|
return mode === 'guided' ? guided : expert
|
||||||
|
}
|
||||||
|
|
||||||
export default function Home({
|
export default function Home({
|
||||||
initialStats = null,
|
initialStats = null,
|
||||||
initialRecentBlocks = [],
|
initialRecentBlocks = [],
|
||||||
@@ -311,6 +315,7 @@ export default function Home({
|
|||||||
latestBlockNumber: latestBlock,
|
latestBlockNumber: latestBlock,
|
||||||
latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null,
|
latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null,
|
||||||
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
freshness: resolveEffectiveFreshness(stats, bridgeStatus),
|
||||||
|
diagnostics: stats?.diagnostics ?? bridgeStatus?.data?.diagnostics ?? null,
|
||||||
})
|
})
|
||||||
const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null
|
const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null
|
||||||
const blockCompleteness = stats?.completeness?.blocks_feed || null
|
const blockCompleteness = stats?.completeness?.blocks_feed || null
|
||||||
@@ -358,6 +363,56 @@ export default function Home({
|
|||||||
const missionCollapsedSummary = relaySummary
|
const missionCollapsedSummary = relaySummary
|
||||||
? `${missionHeadline} · ${relayOperationalCount} operational`
|
? `${missionHeadline} · ${relayOperationalCount} operational`
|
||||||
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
|
: `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}`
|
||||||
|
const primaryMetricCards = [
|
||||||
|
{
|
||||||
|
label: 'Latest Block',
|
||||||
|
value: latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable',
|
||||||
|
note: activityContext.latest_block_timestamp
|
||||||
|
? compactStatNote(
|
||||||
|
`Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`,
|
||||||
|
formatRelativeAge(activityContext.latest_block_timestamp),
|
||||||
|
mode,
|
||||||
|
)
|
||||||
|
: compactStatNote('Head freshness unavailable.', 'Unavailable', mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Blocks',
|
||||||
|
value: stats ? stats.total_blocks.toLocaleString() : 'Unavailable',
|
||||||
|
note: compactStatNote('Visible public explorer block count.', 'Explorer block count', mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Transactions',
|
||||||
|
value: stats ? stats.total_transactions.toLocaleString() : 'Unavailable',
|
||||||
|
note: compactStatNote('Visible indexed explorer transaction count.', 'Indexed tx count', mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Total Addresses',
|
||||||
|
value: stats ? stats.total_addresses.toLocaleString() : 'Unavailable',
|
||||||
|
note: compactStatNote('Current public explorer address count.', 'Address count', mode),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
const secondaryMetricCards = [
|
||||||
|
{
|
||||||
|
label: 'Avg Block Time',
|
||||||
|
value: avgBlockTimeSummary.value,
|
||||||
|
note: compactStatNote(avgBlockTimeSummary.note, averageBlockTimeSeconds != null ? 'Reported' : 'Unavailable', mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Avg Gas Price',
|
||||||
|
value: avgGasPriceSummary.value,
|
||||||
|
note: compactStatNote(avgGasPriceSummary.note, averageGasPriceGwei != null ? 'Reported' : 'Unavailable', mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Transactions Today',
|
||||||
|
value: transactionsTodaySummary.value,
|
||||||
|
note: compactStatNote(transactionsTodaySummary.note, transactionsToday != null ? 'Reported' : 'Unavailable', mode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Network Utilization',
|
||||||
|
value: networkUtilizationSummary.value,
|
||||||
|
note: compactStatNote(networkUtilizationSummary.note, networkUtilization != null ? 'Latest stats sample' : 'Unavailable', mode),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setRelayPage(1)
|
setRelayPage(1)
|
||||||
@@ -617,64 +672,63 @@ export default function Home({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{stats && (
|
{stats && (
|
||||||
<div className="mb-8 grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
<div className="mb-8 space-y-4">
|
||||||
<Card>
|
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Latest Block</div>
|
{primaryMetricCards.map((card) => (
|
||||||
<div className="text-xl font-bold sm:text-2xl">
|
<Card key={card.label}>
|
||||||
{latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
|
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||||
|
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{mode === 'guided' ? (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:gap-4 md:grid-cols-4">
|
||||||
|
{secondaryMetricCards.map((card) => (
|
||||||
|
<Card key={card.label}>
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">{card.label}</div>
|
||||||
|
<div className="text-xl font-bold sm:text-2xl">{card.value}</div>
|
||||||
|
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">
|
) : (
|
||||||
{activityContext.latest_block_timestamp
|
<Card>
|
||||||
? `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}`
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
: 'Head freshness unavailable.'}
|
<div>
|
||||||
</div>
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">Telemetry Snapshot</div>
|
||||||
</Card>
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">
|
||||||
<Card>
|
Secondary public stats in a denser expert layout.
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Blocks</div>
|
</div>
|
||||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_blocks.toLocaleString()}</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Visible public explorer block count.</div>
|
<div className="grid flex-1 gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
</Card>
|
{secondaryMetricCards.map((card) => (
|
||||||
<Card>
|
<div key={card.label} className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Transactions</div>
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">{card.label}</div>
|
||||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_transactions.toLocaleString()}</div>
|
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">{card.value}</div>
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Latest visible tx {latestTransactionAgeLabel}.</div>
|
<div className="mt-1 text-xs text-gray-500 dark:text-gray-400">{card.note}</div>
|
||||||
</Card>
|
</div>
|
||||||
<Card>
|
))}
|
||||||
<div className="text-sm text-gray-600 dark:text-gray-400">Total Addresses</div>
|
</div>
|
||||||
<div className="text-xl font-bold sm:text-2xl">{stats.total_addresses.toLocaleString()}</div>
|
</div>
|
||||||
<div className="mt-2 text-xs text-gray-500 dark:text-gray-400">Current public explorer address count.</div>
|
</Card>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="mb-8">
|
<div className="mb-8">
|
||||||
<ActivityContextPanel context={activityContext} />
|
<ActivityContextPanel context={activityContext} title={mode === 'guided' ? 'Chain Activity Context' : 'Freshness & Activity'} />
|
||||||
<FreshnessTrustNote
|
<FreshnessTrustNote
|
||||||
className="mt-3"
|
className="mt-3"
|
||||||
context={activityContext}
|
context={activityContext}
|
||||||
stats={stats}
|
stats={stats}
|
||||||
bridgeStatus={bridgeStatus}
|
bridgeStatus={bridgeStatus}
|
||||||
scopeLabel="Homepage status combines chain freshness, transaction visibility, and mission-control posture."
|
scopeLabel={
|
||||||
|
mode === 'guided'
|
||||||
|
? 'Homepage status combines chain freshness, transaction visibility, and mission-control posture.'
|
||||||
|
: 'Homepage freshness view aligns chain, transaction, and mission-control posture.'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useEffect, useState } from 'react'
|
||||||
import type {
|
import type {
|
||||||
CapabilitiesCatalog,
|
CapabilitiesCatalog,
|
||||||
FetchMetadata,
|
FetchMetadata,
|
||||||
@@ -7,6 +8,15 @@ import type {
|
|||||||
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
import { AddToMetaMask } from '@/components/wallet/AddToMetaMask'
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||||
|
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||||
|
import EntityBadge from '@/components/common/EntityBadge'
|
||||||
|
import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
|
||||||
|
import {
|
||||||
|
isWatchlistEntry,
|
||||||
|
readWatchlistFromStorage,
|
||||||
|
toggleWatchlistEntry,
|
||||||
|
writeWatchlistToStorage,
|
||||||
|
} from '@/utils/watchlist'
|
||||||
|
|
||||||
interface WalletPageProps {
|
interface WalletPageProps {
|
||||||
initialNetworks?: NetworksCatalog | null
|
initialNetworks?: NetworksCatalog | null
|
||||||
@@ -17,8 +27,111 @@ interface WalletPageProps {
|
|||||||
initialCapabilitiesMeta?: FetchMetadata | null
|
initialCapabilitiesMeta?: FetchMetadata | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function shortAddress(value?: string | null): string {
|
||||||
|
if (!value) return 'Unknown'
|
||||||
|
if (value.length <= 14) return value
|
||||||
|
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function WalletPage(props: WalletPageProps) {
|
export default function WalletPage(props: WalletPageProps) {
|
||||||
const { mode } = useUiMode()
|
const { mode } = useUiMode()
|
||||||
|
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||||
|
const [connectingWallet, setConnectingWallet] = useState(false)
|
||||||
|
const [walletError, setWalletError] = useState<string | null>(null)
|
||||||
|
const [copiedAddress, setCopiedAddress] = useState(false)
|
||||||
|
const [watchlistEntries, setWatchlistEntries] = useState<string[]>([])
|
||||||
|
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
|
||||||
|
const [recentAddressTransactions, setRecentAddressTransactions] = useState<TransactionSummary[]>([])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return
|
||||||
|
|
||||||
|
const syncSession = () => {
|
||||||
|
setWalletSession(accessApi.getStoredWalletSession())
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncWatchlist = () => {
|
||||||
|
setWatchlistEntries(readWatchlistFromStorage(window.localStorage))
|
||||||
|
}
|
||||||
|
|
||||||
|
syncSession()
|
||||||
|
syncWatchlist()
|
||||||
|
window.addEventListener('explorer-access-session-changed', syncSession)
|
||||||
|
window.addEventListener('storage', syncWatchlist)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('explorer-access-session-changed', syncSession)
|
||||||
|
window.removeEventListener('storage', syncWatchlist)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleConnectWallet = async () => {
|
||||||
|
setConnectingWallet(true)
|
||||||
|
setWalletError(null)
|
||||||
|
try {
|
||||||
|
const session = await accessApi.connectWalletSession()
|
||||||
|
setWalletSession(session)
|
||||||
|
} catch (error) {
|
||||||
|
setWalletError(error instanceof Error ? error.message : 'Wallet connection failed.')
|
||||||
|
} finally {
|
||||||
|
setConnectingWallet(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleDisconnectWallet = () => {
|
||||||
|
accessApi.clearSession()
|
||||||
|
accessApi.clearWalletSession()
|
||||||
|
setWalletSession(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCopyAddress = async () => {
|
||||||
|
if (!walletSession?.address || typeof navigator === 'undefined' || !navigator.clipboard) return
|
||||||
|
await navigator.clipboard.writeText(walletSession.address)
|
||||||
|
setCopiedAddress(true)
|
||||||
|
window.setTimeout(() => setCopiedAddress(false), 1500)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleToggleWatchlist = () => {
|
||||||
|
if (!walletSession?.address || typeof window === 'undefined') return
|
||||||
|
const nextEntries = toggleWatchlistEntry(watchlistEntries, walletSession.address)
|
||||||
|
writeWatchlistToStorage(window.localStorage, nextEntries)
|
||||||
|
setWatchlistEntries(nextEntries)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isSavedToWatchlist = walletSession?.address
|
||||||
|
? isWatchlistEntry(watchlistEntries, walletSession.address)
|
||||||
|
: false
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
if (!walletSession?.address) {
|
||||||
|
setAddressInfo(null)
|
||||||
|
setRecentAddressTransactions([])
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
addressesApi.getSafe(138, walletSession.address),
|
||||||
|
addressesApi.getTransactionsSafe(138, walletSession.address, 1, 3),
|
||||||
|
])
|
||||||
|
.then(([infoResponse, transactionsResponse]) => {
|
||||||
|
if (cancelled) return
|
||||||
|
setAddressInfo(infoResponse.ok ? infoResponse.data : null)
|
||||||
|
setRecentAddressTransactions(transactionsResponse.ok ? transactionsResponse.data : [])
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setAddressInfo(null)
|
||||||
|
setRecentAddressTransactions([])
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [walletSession?.address])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="container mx-auto px-4 py-6 sm:py-8">
|
<main className="container mx-auto px-4 py-6 sm:py-8">
|
||||||
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
|
<h1 className="mb-4 text-2xl font-bold sm:text-3xl">Wallet Tools</h1>
|
||||||
@@ -27,6 +140,189 @@ export default function WalletPage(props: WalletPageProps) {
|
|||||||
? '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 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.'}
|
: 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}
|
||||||
</p>
|
</p>
|
||||||
|
<div className="mb-6 rounded-2xl border border-sky-200 bg-sky-50/60 p-5 dark:border-sky-900/40 dark:bg-sky-950/20">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-lg font-semibold text-gray-900 dark:text-white">Wallet session</div>
|
||||||
|
<p className="mt-2 text-sm leading-6 text-gray-600 dark:text-gray-400">
|
||||||
|
{walletSession
|
||||||
|
? mode === 'guided'
|
||||||
|
? 'This wallet is connected to the same account/access session used by the header. You can jump straight into your explorer address view or the access console from here.'
|
||||||
|
: 'Connected wallet session is active for explorer and access surfaces.'
|
||||||
|
: mode === 'guided'
|
||||||
|
? 'Connect a browser wallet to make this page useful beyond setup: copy your address, open your on-explorer address page, and continue into the access console with the same session.'
|
||||||
|
: 'Connect a wallet to activate account-linked explorer actions.'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<EntityBadge label={walletSession ? 'connected' : 'disconnected'} tone={walletSession ? 'success' : 'neutral'} />
|
||||||
|
{walletSession ? <EntityBadge label={walletSession.track} tone="info" /> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-4 lg:grid-cols-[1.2fr_0.8fr]">
|
||||||
|
<div className="rounded-2xl border border-white/60 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">Current wallet</div>
|
||||||
|
<div className="mt-2 text-xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{walletSession ? shortAddress(walletSession.address) : 'No wallet connected'}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{walletSession?.address || 'Use Connect Wallet to start a browser-wallet session.'}
|
||||||
|
</div>
|
||||||
|
{walletSession?.expiresAt ? (
|
||||||
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Session expires {new Date(walletSession.expiresAt).toLocaleString()}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-2xl border border-white/60 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">Quick actions</div>
|
||||||
|
<div className="mt-3 flex flex-wrap gap-2">
|
||||||
|
{walletSession ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCopyAddress}
|
||||||
|
className="rounded-lg border border-primary-300 px-3 py-2 text-sm font-medium text-primary-700 hover:bg-primary-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-primary-800 dark:text-primary-300 dark:hover:bg-primary-950/20"
|
||||||
|
>
|
||||||
|
{copiedAddress ? 'Address copied' : 'Copy address'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/addresses/${walletSession.address}`}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Open address
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
href="/access"
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Open access console
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleToggleWatchlist}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
{isSavedToWatchlist ? 'Remove from watchlist' : 'Save to watchlist'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/watchlist"
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Open watchlist
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleDisconnectWallet}
|
||||||
|
className="rounded-lg border border-red-200 px-3 py-2 text-sm font-medium text-red-700 hover:bg-red-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-red-500 dark:border-red-900/60 dark:text-red-200 dark:hover:bg-red-950/30"
|
||||||
|
>
|
||||||
|
Disconnect wallet
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleConnectWallet}
|
||||||
|
disabled={connectingWallet}
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
|
>
|
||||||
|
{connectingWallet ? 'Connecting wallet…' : 'Connect wallet'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href="/access"
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Open access console
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{walletError ? (
|
||||||
|
<div className="mt-3 text-sm text-red-700 dark:text-red-300">{walletError}</div>
|
||||||
|
) : null}
|
||||||
|
{walletSession ? (
|
||||||
|
<div className="mt-3 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{isSavedToWatchlist
|
||||||
|
? 'This wallet is already saved in the shared explorer watchlist.'
|
||||||
|
: 'Save this wallet into the shared explorer watchlist to revisit it from addresses and transaction workflows.'}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{walletSession ? (
|
||||||
|
<div className="mt-4 rounded-2xl border border-white/60 bg-white/70 p-4 dark:border-white/10 dark:bg-black/10">
|
||||||
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
|
||||||
|
Connected Address Snapshot
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{mode === 'guided'
|
||||||
|
? 'A quick explorer view of the connected wallet so you can jump from connection into browsing and monitoring.'
|
||||||
|
: 'Current explorer snapshot for the connected wallet.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Link href={`/addresses/${walletSession.address}`} className="text-sm font-medium text-primary-600 hover:underline">
|
||||||
|
Open full address page →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Transactions</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{addressInfo ? addressInfo.transaction_count.toLocaleString() : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token Holdings</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{addressInfo ? addressInfo.token_count.toLocaleString() : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Address Type</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{addressInfo ? (addressInfo.is_contract ? 'Contract' : 'EOA') : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 dark:border-gray-800 dark:bg-gray-900/40">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent Indexed Tx</div>
|
||||||
|
<div className="mt-1 text-lg font-semibold text-gray-900 dark:text-white">
|
||||||
|
{recentAddressTransactions[0] ? `#${recentAddressTransactions[0].block_number}` : 'None visible'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 grid gap-3 lg:grid-cols-3">
|
||||||
|
{recentAddressTransactions.length === 0 ? (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400 lg:col-span-3">
|
||||||
|
No recent indexed transactions are currently visible for this connected wallet.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
recentAddressTransactions.map((transaction) => (
|
||||||
|
<Link
|
||||||
|
key={transaction.hash}
|
||||||
|
href={`/transactions/${transaction.hash}`}
|
||||||
|
className="rounded-2xl border border-gray-200 bg-gray-50/70 px-4 py-3 text-sm hover:border-primary-300 hover:bg-primary-50/60 dark:border-gray-800 dark:bg-gray-900/40 dark:hover:border-primary-700 dark:hover:bg-primary-950/20"
|
||||||
|
>
|
||||||
|
<div className="font-semibold text-gray-900 dark:text-white">
|
||||||
|
{transaction.hash.slice(0, 10)}...{transaction.hash.slice(-8)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-gray-600 dark:text-gray-400">
|
||||||
|
Block #{transaction.block_number.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
<AddToMetaMask {...props} />
|
<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">
|
<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">
|
||||||
<Explain>
|
<Explain>
|
||||||
|
|||||||
@@ -69,6 +69,7 @@ export default function AddressesPage({
|
|||||||
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
||||||
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
||||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||||
|
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
||||||
}),
|
}),
|
||||||
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
|
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { GetServerSideProps } from 'next'
|
|||||||
import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage'
|
import AnalyticsOperationsPage from '@/components/explorer/AnalyticsOperationsPage'
|
||||||
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
|
import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout'
|
||||||
import {
|
import {
|
||||||
normalizeExplorerStats,
|
|
||||||
normalizeTransactionTrend,
|
normalizeTransactionTrend,
|
||||||
summarizeRecentTransactions,
|
summarizeRecentTransactions,
|
||||||
type ExplorerRecentActivitySnapshot,
|
type ExplorerRecentActivitySnapshot,
|
||||||
@@ -13,6 +12,7 @@ import type { Block } from '@/services/api/blocks'
|
|||||||
import type { Transaction } from '@/services/api/transactions'
|
import type { Transaction } from '@/services/api/transactions'
|
||||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||||
|
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||||
|
|
||||||
interface AnalyticsPageProps {
|
interface AnalyticsPageProps {
|
||||||
initialStats: ExplorerStats | null
|
initialStats: ExplorerStats | null
|
||||||
@@ -63,18 +63,17 @@ export default function AnalyticsPage(props: AnalyticsPageProps) {
|
|||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async () => {
|
||||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||||
const [statsResult, trendResult, activityResult, blocksResult, transactionsResult, bridgeResult] = await Promise.allSettled([
|
const [truthContextResult, trendResult, activityResult, blocksResult, transactionsResult] = await Promise.allSettled([
|
||||||
fetchPublicJson('/api/v2/stats'),
|
fetchExplorerTruthContext(),
|
||||||
fetchPublicJson('/api/v2/stats/charts/transactions'),
|
fetchPublicJson('/api/v2/stats/charts/transactions'),
|
||||||
fetchPublicJson('/api/v2/main-page/transactions'),
|
fetchPublicJson('/api/v2/main-page/transactions'),
|
||||||
fetchPublicJson('/api/v2/blocks?page=1&page_size=5'),
|
fetchPublicJson('/api/v2/blocks?page=1&page_size=5'),
|
||||||
fetchPublicJson('/api/v2/transactions?page=1&page_size=5'),
|
fetchPublicJson('/api/v2/transactions?page=1&page_size=5'),
|
||||||
fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value as never) : null,
|
initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null,
|
||||||
initialTransactionTrend:
|
initialTransactionTrend:
|
||||||
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [],
|
trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value as never) : [],
|
||||||
initialActivitySnapshot:
|
initialActivitySnapshot:
|
||||||
@@ -96,7 +95,7 @@ export const getServerSideProps: GetServerSideProps<AnalyticsPageProps> = async
|
|||||||
)
|
)
|
||||||
: [],
|
: [],
|
||||||
initialBridgeStatus:
|
initialBridgeStatus:
|
||||||
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
|
truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ export default function BlocksPage({
|
|||||||
latestBlockNumber: blocks[0]?.number ?? null,
|
latestBlockNumber: blocks[0]?.number ?? null,
|
||||||
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
latestBlockTimestamp: blocks[0]?.timestamp ?? null,
|
||||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||||
|
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
||||||
}),
|
}),
|
||||||
[blocks, initialBridgeStatus, initialStats, recentTransactions],
|
[blocks, initialBridgeStatus, initialStats, recentTransactions],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import type { GetStaticProps } from 'next'
|
import type { GetStaticProps } from 'next'
|
||||||
import BridgeMonitoringPage from '@/components/explorer/BridgeMonitoringPage'
|
import BridgeMonitoringPage from '@/components/explorer/BridgeMonitoringPage'
|
||||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
import type { ExplorerStats } from '@/services/api/stats'
|
||||||
|
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||||
|
|
||||||
interface BridgePageProps {
|
interface BridgePageProps {
|
||||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||||
|
initialStats: ExplorerStats | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BridgePage(props: BridgePageProps) {
|
export default function BridgePage(props: BridgePageProps) {
|
||||||
@@ -12,13 +14,12 @@ export default function BridgePage(props: BridgePageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<BridgePageProps> = async () => {
|
export const getStaticProps: GetStaticProps<BridgePageProps> = async () => {
|
||||||
const bridgeResult = await fetchPublicJson<MissionControlBridgeStatusResponse>(
|
const truthContext = await fetchExplorerTruthContext()
|
||||||
'/explorer-api/v1/track1/bridge/status'
|
|
||||||
).catch(() => null)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
initialBridgeStatus: bridgeResult,
|
initialBridgeStatus: truthContext.initialBridgeStatus,
|
||||||
|
initialStats: truthContext.initialStats,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import type { GetServerSideProps } from 'next'
|
|||||||
import HomePage from '@/components/home/HomePage'
|
import HomePage from '@/components/home/HomePage'
|
||||||
import { normalizeBlock } from '@/services/api/blockscout'
|
import { normalizeBlock } from '@/services/api/blockscout'
|
||||||
import {
|
import {
|
||||||
normalizeExplorerStats,
|
|
||||||
normalizeTransactionTrend,
|
normalizeTransactionTrend,
|
||||||
summarizeRecentTransactions,
|
summarizeRecentTransactions,
|
||||||
type ExplorerRecentActivitySnapshot,
|
type ExplorerRecentActivitySnapshot,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
import type { Block } from '@/services/api/blocks'
|
import type { Block } from '@/services/api/blocks'
|
||||||
import type { Transaction } from '@/services/api/transactions'
|
import type { Transaction } from '@/services/api/transactions'
|
||||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||||
|
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||||
import { normalizeTransaction } from '@/services/api/blockscout'
|
import { normalizeTransaction } from '@/services/api/blockscout'
|
||||||
|
|
||||||
interface IndexPageProps {
|
interface IndexPageProps {
|
||||||
@@ -54,13 +54,8 @@ function serializeTransactions(transactions: Transaction[]): Transaction[] {
|
|||||||
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () => {
|
||||||
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
|
||||||
|
|
||||||
const [statsResult, blocksResult, transactionsResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([
|
const [truthContextResult, blocksResult, transactionsResult, trendResult, activityResult] = await Promise.allSettled([
|
||||||
fetchPublicJson<{
|
fetchExplorerTruthContext(),
|
||||||
total_blocks?: number | string | null
|
|
||||||
total_transactions?: number | string | null
|
|
||||||
total_addresses?: number | string | null
|
|
||||||
latest_block?: number | string | null
|
|
||||||
}>('/api/v2/stats'),
|
|
||||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
|
fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'),
|
||||||
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
|
fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'),
|
||||||
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
|
fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>(
|
||||||
@@ -74,12 +69,11 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
|
|||||||
fee?: { value?: string | number | null } | string | null
|
fee?: { value?: string | number | null } | string | null
|
||||||
}>
|
}>
|
||||||
>('/api/v2/main-page/transactions'),
|
>('/api/v2/main-page/transactions'),
|
||||||
fetchPublicJson('/explorer-api/v1/track1/bridge/status'),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
initialStats: statsResult.status === 'fulfilled' ? normalizeExplorerStats(statsResult.value) : null,
|
initialStats: truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialStats : null,
|
||||||
initialRecentBlocks:
|
initialRecentBlocks:
|
||||||
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
|
blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items)
|
||||||
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
|
? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId))
|
||||||
@@ -95,9 +89,11 @@ export const getServerSideProps: GetServerSideProps<IndexPageProps> = async () =
|
|||||||
initialActivitySnapshot:
|
initialActivitySnapshot:
|
||||||
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
|
activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null,
|
||||||
initialBridgeStatus:
|
initialBridgeStatus:
|
||||||
bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null,
|
truthContextResult.status === 'fulfilled' ? truthContextResult.value.initialBridgeStatus : null,
|
||||||
initialRelaySummary:
|
initialRelaySummary:
|
||||||
bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null,
|
truthContextResult.status === 'fulfilled' && truthContextResult.value.initialBridgeStatus
|
||||||
|
? summarizeMissionControlRelay(truthContextResult.value.initialBridgeStatus as never)
|
||||||
|
: null,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import LiquidityOperationsPage from '@/components/explorer/LiquidityOperationsPa
|
|||||||
import type { TokenListResponse } from '@/services/api/config'
|
import type { TokenListResponse } from '@/services/api/config'
|
||||||
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
|
import type { InternalExecutionPlanResponse, PlannerCapabilitiesResponse } from '@/services/api/planner'
|
||||||
import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes'
|
import type { MissionControlLiquidityPool, RouteMatrixResponse } from '@/services/api/routes'
|
||||||
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
|
import type { ExplorerStats } from '@/services/api/stats'
|
||||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||||
|
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||||
|
|
||||||
interface TokenPoolRecord {
|
interface TokenPoolRecord {
|
||||||
symbol: string
|
symbol: string
|
||||||
@@ -16,6 +19,8 @@ interface LiquidityPageProps {
|
|||||||
initialPlannerCapabilities: PlannerCapabilitiesResponse | null
|
initialPlannerCapabilities: PlannerCapabilitiesResponse | null
|
||||||
initialInternalPlan: InternalExecutionPlanResponse | null
|
initialInternalPlan: InternalExecutionPlanResponse | null
|
||||||
initialTokenPoolRecords: TokenPoolRecord[]
|
initialTokenPoolRecords: TokenPoolRecord[]
|
||||||
|
initialStats: ExplorerStats | null
|
||||||
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'])
|
const featuredTokenSymbols = new Set(['cUSDT', 'cUSDC', 'USDT', 'USDC', 'cXAUC', 'cXAUT'])
|
||||||
@@ -39,7 +44,7 @@ export default function LiquidityPage(props: LiquidityPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
|
export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async () => {
|
||||||
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult] =
|
const [tokenListResult, routeMatrixResult, plannerCapabilitiesResult, internalPlanResult, truthContext] =
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||||
@@ -52,6 +57,7 @@ export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async
|
|||||||
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
tokenOut: '0x004b63A7B5b0E06f6bB6adb4a5F9f590BF3182D1',
|
||||||
amountIn: '100000000000000000',
|
amountIn: '100000000000000000',
|
||||||
}).catch(() => null),
|
}).catch(() => null),
|
||||||
|
fetchExplorerTruthContext(),
|
||||||
])
|
])
|
||||||
|
|
||||||
const featuredTokens = (tokenListResult?.tokens || []).filter(
|
const featuredTokens = (tokenListResult?.tokens || []).filter(
|
||||||
@@ -79,6 +85,8 @@ export const getServerSideProps: GetServerSideProps<LiquidityPageProps> = async
|
|||||||
initialPlannerCapabilities: plannerCapabilitiesResult,
|
initialPlannerCapabilities: plannerCapabilitiesResult,
|
||||||
initialInternalPlan: internalPlanResult,
|
initialInternalPlan: internalPlanResult,
|
||||||
initialTokenPoolRecords: tokenPoolsResults,
|
initialTokenPoolRecords: tokenPoolsResults,
|
||||||
|
initialStats: truthContext.initialStats,
|
||||||
|
initialBridgeStatus: truthContext.initialBridgeStatus,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ import OperationsHubPage from '@/components/explorer/OperationsHubPage'
|
|||||||
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
import type { RouteMatrixResponse } from '@/services/api/routes'
|
import type { RouteMatrixResponse } from '@/services/api/routes'
|
||||||
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
|
import type { CapabilitiesResponse, NetworksConfigResponse, TokenListResponse } from '@/services/api/config'
|
||||||
|
import type { ExplorerStats } from '@/services/api/stats'
|
||||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||||
|
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||||
|
|
||||||
interface OperationsPageProps {
|
interface OperationsPageProps {
|
||||||
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||||
@@ -11,6 +13,7 @@ interface OperationsPageProps {
|
|||||||
initialNetworksConfig: NetworksConfigResponse | null
|
initialNetworksConfig: NetworksConfigResponse | null
|
||||||
initialTokenList: TokenListResponse | null
|
initialTokenList: TokenListResponse | null
|
||||||
initialCapabilities: CapabilitiesResponse | null
|
initialCapabilities: CapabilitiesResponse | null
|
||||||
|
initialStats: ExplorerStats | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function OperationsPage(props: OperationsPageProps) {
|
export default function OperationsPage(props: OperationsPageProps) {
|
||||||
@@ -18,21 +21,22 @@ export default function OperationsPage(props: OperationsPageProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const getStaticProps: GetStaticProps<OperationsPageProps> = async () => {
|
export const getStaticProps: GetStaticProps<OperationsPageProps> = async () => {
|
||||||
const [bridgeResult, routesResult, networksResult, tokenListResult, capabilitiesResult] = await Promise.all([
|
const [routesResult, networksResult, tokenListResult, capabilitiesResult, truthContext] = await Promise.all([
|
||||||
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
|
||||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||||
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
fetchPublicJson<NetworksConfigResponse>('/api/config/networks').catch(() => null),
|
||||||
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
fetchPublicJson<TokenListResponse>('/api/config/token-list').catch(() => null),
|
||||||
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
fetchPublicJson<CapabilitiesResponse>('/api/config/capabilities').catch(() => null),
|
||||||
|
fetchExplorerTruthContext(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
props: {
|
props: {
|
||||||
initialBridgeStatus: bridgeResult,
|
initialBridgeStatus: truthContext.initialBridgeStatus,
|
||||||
initialRouteMatrix: routesResult,
|
initialRouteMatrix: routesResult,
|
||||||
initialNetworksConfig: networksResult,
|
initialNetworksConfig: networksResult,
|
||||||
initialTokenList: tokenListResult,
|
initialTokenList: tokenListResult,
|
||||||
initialCapabilities: capabilitiesResult,
|
initialCapabilities: capabilitiesResult,
|
||||||
|
initialStats: truthContext.initialStats,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import type { GetStaticProps } from 'next'
|
import type { GetStaticProps } from 'next'
|
||||||
import RoutesMonitoringPage from '@/components/explorer/RoutesMonitoringPage'
|
import RoutesMonitoringPage from '@/components/explorer/RoutesMonitoringPage'
|
||||||
|
import type { ExplorerStats } from '@/services/api/stats'
|
||||||
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
import { fetchPublicJson } from '@/utils/publicExplorer'
|
import { fetchPublicJson } from '@/utils/publicExplorer'
|
||||||
|
import { fetchExplorerTruthContext } from '@/utils/serverExplorerContext'
|
||||||
import type {
|
import type {
|
||||||
ExplorerNetwork,
|
ExplorerNetwork,
|
||||||
MissionControlLiquidityPool,
|
MissionControlLiquidityPool,
|
||||||
@@ -11,6 +14,8 @@ interface RoutesPageProps {
|
|||||||
initialRouteMatrix: RouteMatrixResponse | null
|
initialRouteMatrix: RouteMatrixResponse | null
|
||||||
initialNetworks: ExplorerNetwork[]
|
initialNetworks: ExplorerNetwork[]
|
||||||
initialPools: MissionControlLiquidityPool[]
|
initialPools: MissionControlLiquidityPool[]
|
||||||
|
initialStats: ExplorerStats | null
|
||||||
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function RoutesPage(props: RoutesPageProps) {
|
export default function RoutesPage(props: RoutesPageProps) {
|
||||||
@@ -19,12 +24,13 @@ export default function RoutesPage(props: RoutesPageProps) {
|
|||||||
|
|
||||||
export const getStaticProps: GetStaticProps<RoutesPageProps> = async () => {
|
export const getStaticProps: GetStaticProps<RoutesPageProps> = async () => {
|
||||||
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
const canonicalLiquidityToken = '0x93E66202A11B1772E55407B32B44e5Cd8eda7f22'
|
||||||
const [matrixResult, networksResult, poolsResult] = await Promise.all([
|
const [matrixResult, networksResult, poolsResult, truthContext] = await Promise.all([
|
||||||
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
fetchPublicJson<RouteMatrixResponse>('/token-aggregation/api/v1/routes/matrix?includeNonLive=true').catch(() => null),
|
||||||
fetchPublicJson<{ networks?: ExplorerNetwork[] }>('/token-aggregation/api/v1/networks').catch(() => null),
|
fetchPublicJson<{ networks?: ExplorerNetwork[] }>('/token-aggregation/api/v1/networks').catch(() => null),
|
||||||
fetchPublicJson<{ pools?: MissionControlLiquidityPool[] }>(
|
fetchPublicJson<{ pools?: MissionControlLiquidityPool[] }>(
|
||||||
`/token-aggregation/api/v1/tokens/${canonicalLiquidityToken}/pools`,
|
`/token-aggregation/api/v1/tokens/${canonicalLiquidityToken}/pools`,
|
||||||
).catch(() => null),
|
).catch(() => null),
|
||||||
|
fetchExplorerTruthContext(),
|
||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -32,6 +38,8 @@ export const getStaticProps: GetStaticProps<RoutesPageProps> = async () => {
|
|||||||
initialRouteMatrix: matrixResult,
|
initialRouteMatrix: matrixResult,
|
||||||
initialNetworks: networksResult?.networks || [],
|
initialNetworks: networksResult?.networks || [],
|
||||||
initialPools: poolsResult?.pools || [],
|
initialPools: poolsResult?.pools || [],
|
||||||
|
initialStats: truthContext.initialStats,
|
||||||
|
initialBridgeStatus: truthContext.initialBridgeStatus,
|
||||||
},
|
},
|
||||||
revalidate: 60,
|
revalidate: 60,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ export default function TransactionsPage({
|
|||||||
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
latestBlockNumber: initialLatestBlocks[0]?.number ?? null,
|
||||||
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null,
|
||||||
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus),
|
||||||
|
diagnostics: initialStats?.diagnostics ?? initialBridgeStatus?.data?.diagnostics ?? null,
|
||||||
}),
|
}),
|
||||||
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
|
[chainId, initialBridgeStatus, initialLatestBlocks, initialStats, transactions],
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,30 +1,134 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
import { Card, Address } from '@/libs/frontend-ui-primitives'
|
||||||
import {
|
|
||||||
readWatchlistFromStorage,
|
|
||||||
writeWatchlistToStorage,
|
|
||||||
sanitizeWatchlistEntries,
|
|
||||||
} from '@/utils/watchlist'
|
|
||||||
import PageIntro from '@/components/common/PageIntro'
|
import PageIntro from '@/components/common/PageIntro'
|
||||||
|
import { Explain, useUiMode } from '@/components/common/UiModeContext'
|
||||||
|
import { accessApi, type WalletAccessSession } from '@/services/api/access'
|
||||||
|
import { addressesApi, type AddressInfo, type TransactionSummary } from '@/services/api/addresses'
|
||||||
|
import {
|
||||||
|
isWatchlistEntry,
|
||||||
|
normalizeWatchlistAddress,
|
||||||
|
readWatchlistFromStorage,
|
||||||
|
sanitizeWatchlistEntries,
|
||||||
|
toggleWatchlistEntry,
|
||||||
|
writeWatchlistToStorage,
|
||||||
|
} from '@/utils/watchlist'
|
||||||
|
|
||||||
|
type TrackedAddressSnapshot = {
|
||||||
|
address: string
|
||||||
|
info: AddressInfo | null
|
||||||
|
recentTransaction: TransactionSummary | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function shortAddress(value?: string | null): string {
|
||||||
|
if (!value) return 'Unknown'
|
||||||
|
if (value.length <= 14) return value
|
||||||
|
return `${value.slice(0, 6)}...${value.slice(-4)}`
|
||||||
|
}
|
||||||
|
|
||||||
export default function WatchlistPage() {
|
export default function WatchlistPage() {
|
||||||
|
const { mode } = useUiMode()
|
||||||
const [entries, setEntries] = useState<string[]>([])
|
const [entries, setEntries] = useState<string[]>([])
|
||||||
|
const [walletSession, setWalletSession] = useState<WalletAccessSession | null>(null)
|
||||||
|
const [snapshots, setSnapshots] = useState<Record<string, TrackedAddressSnapshot>>({})
|
||||||
|
const [loadingSnapshots, setLoadingSnapshots] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window === 'undefined') {
|
if (typeof window === 'undefined') return
|
||||||
return
|
|
||||||
|
const syncSession = () => setWalletSession(accessApi.getStoredWalletSession())
|
||||||
|
const syncWatchlist = () => {
|
||||||
|
try {
|
||||||
|
setEntries(readWatchlistFromStorage(window.localStorage))
|
||||||
|
} catch {
|
||||||
|
setEntries([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
syncSession()
|
||||||
setEntries(readWatchlistFromStorage(window.localStorage))
|
syncWatchlist()
|
||||||
} catch {
|
window.addEventListener('explorer-access-session-changed', syncSession)
|
||||||
setEntries([])
|
window.addEventListener('storage', syncWatchlist)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('explorer-access-session-changed', syncSession)
|
||||||
|
window.removeEventListener('storage', syncWatchlist)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
setSnapshots({})
|
||||||
|
setLoadingSnapshots(false)
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setLoadingSnapshots(true)
|
||||||
|
Promise.all(
|
||||||
|
entries.map(async (address) => {
|
||||||
|
const [infoResponse, transactionsResponse] = await Promise.all([
|
||||||
|
addressesApi.getSafe(138, address),
|
||||||
|
addressesApi.getTransactionsSafe(138, address, 1, 1),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
address,
|
||||||
|
info: infoResponse.ok ? infoResponse.data : null,
|
||||||
|
recentTransaction: transactionsResponse.ok ? transactionsResponse.data[0] ?? null : null,
|
||||||
|
} satisfies TrackedAddressSnapshot
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.then((results) => {
|
||||||
|
if (cancelled) return
|
||||||
|
const next: Record<string, TrackedAddressSnapshot> = {}
|
||||||
|
for (const result of results) {
|
||||||
|
next[result.address.toLowerCase()] = result
|
||||||
|
}
|
||||||
|
setSnapshots(next)
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setSnapshots({})
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (cancelled) return
|
||||||
|
setLoadingSnapshots(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [entries])
|
||||||
|
|
||||||
|
const connectedWalletEntry = useMemo(
|
||||||
|
() => normalizeWatchlistAddress(walletSession?.address || ''),
|
||||||
|
[walletSession?.address],
|
||||||
|
)
|
||||||
|
const connectedWalletTracked = connectedWalletEntry
|
||||||
|
? isWatchlistEntry(entries, connectedWalletEntry)
|
||||||
|
: false
|
||||||
|
|
||||||
|
const trackedSummaries = useMemo(() => {
|
||||||
|
const values = entries
|
||||||
|
.map((entry) => snapshots[entry.toLowerCase()])
|
||||||
|
.filter((value): value is TrackedAddressSnapshot => Boolean(value))
|
||||||
|
|
||||||
|
return {
|
||||||
|
contracts: values.filter((value) => value.info?.is_contract).length,
|
||||||
|
eoas: values.filter((value) => value.info && !value.info.is_contract).length,
|
||||||
|
withRecentTransactions: values.filter((value) => value.recentTransaction).length,
|
||||||
|
totalTransactions: values.reduce(
|
||||||
|
(sum, value) => sum + (value.info?.transaction_count || 0),
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}, [entries, snapshots])
|
||||||
|
|
||||||
const removeEntry = (address: string) => {
|
const removeEntry = (address: string) => {
|
||||||
setEntries((current) => {
|
setEntries((current) => {
|
||||||
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
|
const next = current.filter((entry) => entry.toLowerCase() !== address.toLowerCase())
|
||||||
@@ -36,9 +140,7 @@ export default function WatchlistPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const exportWatchlist = () => {
|
const exportWatchlist = () => {
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) return
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
const blob = new Blob([JSON.stringify(entries, null, 2)], { type: 'application/json' })
|
||||||
@@ -55,23 +157,36 @@ export default function WatchlistPage() {
|
|||||||
const file = event.target.files?.[0]
|
const file = event.target.files?.[0]
|
||||||
if (!file) return
|
if (!file) return
|
||||||
|
|
||||||
file.text().then((text) => {
|
file.text()
|
||||||
try {
|
.then((text) => {
|
||||||
const next = sanitizeWatchlistEntries(JSON.parse(text))
|
try {
|
||||||
setEntries(next)
|
const next = sanitizeWatchlistEntries(JSON.parse(text))
|
||||||
writeWatchlistToStorage(window.localStorage, next)
|
setEntries(next)
|
||||||
} catch {}
|
writeWatchlistToStorage(window.localStorage, next)
|
||||||
}).catch(() => {})
|
} catch {}
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
|
||||||
event.target.value = ''
|
event.target.value = ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const toggleConnectedWallet = () => {
|
||||||
|
if (!connectedWalletEntry || typeof window === 'undefined') return
|
||||||
|
const next = toggleWatchlistEntry(entries, connectedWalletEntry)
|
||||||
|
writeWatchlistToStorage(window.localStorage, next)
|
||||||
|
setEntries(next)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto px-4 py-6 sm:py-8">
|
<div className="container mx-auto px-4 py-6 sm:py-8">
|
||||||
<PageIntro
|
<PageIntro
|
||||||
eyebrow="Saved Shortcuts"
|
eyebrow="Tracked Entities"
|
||||||
title="Watchlist"
|
title="Watchlist"
|
||||||
description="Keep frequently referenced Chain 138 addresses close at hand, then move back into address detail, search, or exported team handoff files from one place."
|
description={
|
||||||
|
mode === 'guided'
|
||||||
|
? 'Track the addresses that matter most, review a quick explorer snapshot for each one, and move directly into address detail or recent indexed transactions from one shared workflow surface.'
|
||||||
|
: 'Track important addresses, review quick snapshots, and jump directly into explorer workflows.'
|
||||||
|
}
|
||||||
actions={[
|
actions={[
|
||||||
{ href: '/addresses', label: 'Browse addresses' },
|
{ href: '/addresses', label: 'Browse addresses' },
|
||||||
{ href: '/search', label: 'Search explorer' },
|
{ href: '/search', label: 'Search explorer' },
|
||||||
@@ -79,10 +194,81 @@ export default function WatchlistPage() {
|
|||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<Card title="Tracked Entities Overview">
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Tracked addresses</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">{entries.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent indexed activity</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">{trackedSummaries.withRecentTransactions}</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Entries with at least one visible recent transaction.</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">EOAs / contracts</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{trackedSummaries.eoas} / {trackedSummaries.contracts}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-200 p-4 dark:border-gray-700">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Visible tx volume</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-gray-900 dark:text-white">
|
||||||
|
{trackedSummaries.totalTransactions.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-600 dark:text-gray-400">Aggregate indexed transaction count across tracked entries.</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Explain>
|
||||||
|
This view keeps tracked-address shortcuts and quick explorer evidence in one place so Guided mode can explain what each entity represents while Expert mode stays denser.
|
||||||
|
</Explain>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card title="Connected Wallet">
|
||||||
|
{walletSession ? (
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-semibold text-gray-900 dark:text-white">
|
||||||
|
{shortAddress(walletSession.address)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 break-all text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{walletSession.address}
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
{connectedWalletTracked
|
||||||
|
? 'This connected wallet is already part of your tracked entity set.'
|
||||||
|
: 'Add this connected wallet into the tracked entity set for faster monitoring and navigation.'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleConnectedWallet}
|
||||||
|
className="rounded-lg bg-primary-600 px-3 py-2 text-sm font-semibold text-white hover:bg-primary-700"
|
||||||
|
>
|
||||||
|
{connectedWalletTracked ? 'Remove from watchlist' : 'Track connected wallet'}
|
||||||
|
</button>
|
||||||
|
<Link
|
||||||
|
href={`/addresses/${walletSession.address}`}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Open address
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
|
Connect a wallet from the <Link href="/wallet" className="text-primary-600 hover:underline">wallet tools page</Link> to pin your own address into tracked workflows.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
<Card title="Saved Addresses">
|
<Card title="Saved Addresses">
|
||||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||||
{entries.length === 0 ? 'No saved entries yet.' : `${entries.length} saved ${entries.length === 1 ? 'address' : 'addresses'}.`}
|
{entries.length === 0
|
||||||
|
? 'No saved entries yet.'
|
||||||
|
: `${entries.length} saved ${entries.length === 1 ? 'address' : 'addresses'}.`}
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<label className="cursor-pointer rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
|
<label className="cursor-pointer rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700">
|
||||||
@@ -113,20 +299,76 @@ export default function WatchlistPage() {
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
{entries.map((entry) => (
|
{entries.map((entry) => {
|
||||||
<div key={entry} className="flex flex-col gap-2 rounded-lg border border-gray-200 p-3 dark:border-gray-700 md:flex-row md:items-center md:justify-between">
|
const snapshot = snapshots[entry.toLowerCase()]
|
||||||
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
|
const info = snapshot?.info || null
|
||||||
<Address address={entry} showCopy={false} />
|
const recentTransaction = snapshot?.recentTransaction || null
|
||||||
</Link>
|
|
||||||
<button
|
return (
|
||||||
type="button"
|
<div
|
||||||
onClick={() => removeEntry(entry)}
|
key={entry}
|
||||||
className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
className="rounded-lg border border-gray-200 p-4 dark:border-gray-700"
|
||||||
>
|
>
|
||||||
Remove
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
|
||||||
</button>
|
<div className="min-w-0">
|
||||||
</div>
|
<Link href={`/addresses/${entry}`} className="text-primary-600 hover:underline">
|
||||||
))}
|
<Address address={entry} showCopy={false} />
|
||||||
|
</Link>
|
||||||
|
<div className="mt-2 grid gap-3 sm:grid-cols-2 xl:grid-cols-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Type</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
{info ? (info.is_contract ? 'Contract' : 'EOA') : loadingSnapshots ? 'Loading…' : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Indexed txs</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
{info ? info.transaction_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Token holdings</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
{info ? info.token_count.toLocaleString() : loadingSnapshots ? 'Loading…' : 'Unknown'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">Recent visible tx</div>
|
||||||
|
<div className="mt-1 text-sm text-gray-900 dark:text-white">
|
||||||
|
{recentTransaction ? `#${recentTransaction.block_number.toLocaleString()}` : loadingSnapshots ? 'Loading…' : 'None visible'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/addresses/${entry}`}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Open address
|
||||||
|
</Link>
|
||||||
|
{recentTransaction ? (
|
||||||
|
<Link
|
||||||
|
href={`/transactions/${recentTransaction.hash}`}
|
||||||
|
className="rounded-lg border border-gray-300 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-900/60"
|
||||||
|
>
|
||||||
|
Latest tx
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => removeEntry(entry)}
|
||||||
|
className="rounded-lg bg-gray-100 px-3 py-1.5 text-sm text-gray-700 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-200 dark:hover:bg-gray-700"
|
||||||
|
>
|
||||||
|
Remove
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { getExplorerApiBase } from './blockscout'
|
import { getExplorerApiBase } from './blockscout'
|
||||||
|
import type { ExplorerFreshnessDiagnostics } from './stats'
|
||||||
|
|
||||||
export interface MissionControlRelaySummary {
|
export interface MissionControlRelaySummary {
|
||||||
text: string
|
text: string
|
||||||
@@ -99,6 +100,7 @@ export interface MissionControlBridgeStatusResponse {
|
|||||||
status?: string
|
status?: string
|
||||||
checked_at?: string
|
checked_at?: string
|
||||||
freshness?: unknown
|
freshness?: unknown
|
||||||
|
diagnostics?: ExplorerFreshnessDiagnostics | null
|
||||||
sampling?: {
|
sampling?: {
|
||||||
stats_generated_at?: string | null
|
stats_generated_at?: string | null
|
||||||
rpc_probe_at?: string | null
|
rpc_probe_at?: string | null
|
||||||
@@ -150,6 +152,11 @@ function describeRelayStatus(snapshot: MissionControlRelaySnapshot, status: stri
|
|||||||
? 'underfunded queued release'
|
? 'underfunded queued release'
|
||||||
: 'underfunded release'
|
: 'underfunded release'
|
||||||
}
|
}
|
||||||
|
if (snapshot.last_error?.scope === 'bridge_inventory_probe') {
|
||||||
|
return snapshot.queue?.size && snapshot.queue.size > 0
|
||||||
|
? 'inventory check deferred'
|
||||||
|
: 'inventory check pending'
|
||||||
|
}
|
||||||
if (status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
if (status === 'paused' && snapshot.monitoring?.delivery_enabled === false) {
|
||||||
return snapshot.queue?.size && snapshot.queue.size > 0 ? 'delivery paused (queueing)' : 'delivery paused'
|
return snapshot.queue?.size && snapshot.queue.size > 0 ? 'delivery paused (queueing)' : 'delivery paused'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ describe('normalizeExplorerStats', () => {
|
|||||||
freshness: null,
|
freshness: null,
|
||||||
completeness: null,
|
completeness: null,
|
||||||
sampling: null,
|
sampling: null,
|
||||||
|
diagnostics: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -48,6 +49,7 @@ describe('normalizeExplorerStats', () => {
|
|||||||
freshness: null,
|
freshness: null,
|
||||||
completeness: null,
|
completeness: null,
|
||||||
sampling: null,
|
sampling: null,
|
||||||
|
diagnostics: null,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ export interface ExplorerStats {
|
|||||||
freshness: ExplorerFreshnessSnapshot | null
|
freshness: ExplorerFreshnessSnapshot | null
|
||||||
completeness: ExplorerStatsCompleteness | null
|
completeness: ExplorerStatsCompleteness | null
|
||||||
sampling: ExplorerStatsSampling | null
|
sampling: ExplorerStatsSampling | null
|
||||||
|
diagnostics: ExplorerFreshnessDiagnostics | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ExplorerFreshnessReference {
|
export interface ExplorerFreshnessReference {
|
||||||
@@ -33,6 +34,22 @@ export interface ExplorerFreshnessSnapshot {
|
|||||||
latest_non_empty_block: ExplorerFreshnessReference
|
latest_non_empty_block: ExplorerFreshnessReference
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ExplorerFreshnessDiagnostics {
|
||||||
|
tx_visibility_state?: string | null
|
||||||
|
activity_state?: string | null
|
||||||
|
explanation?: string | null
|
||||||
|
tx_lag_blocks?: number | null
|
||||||
|
tx_lag_seconds?: number | null
|
||||||
|
recent_block_sample_size?: number | null
|
||||||
|
recent_non_empty_blocks?: number | null
|
||||||
|
recent_transactions?: number | null
|
||||||
|
latest_non_empty_block_from_block_feed?: ExplorerFreshnessReference | null
|
||||||
|
source?: string | null
|
||||||
|
confidence?: string | null
|
||||||
|
provenance?: string | null
|
||||||
|
completeness?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ExplorerStatsCompleteness {
|
export interface ExplorerStatsCompleteness {
|
||||||
transactions_feed?: string | null
|
transactions_feed?: string | null
|
||||||
blocks_feed?: string | null
|
blocks_feed?: string | null
|
||||||
@@ -87,6 +104,21 @@ interface RawExplorerStats {
|
|||||||
} | null
|
} | null
|
||||||
completeness?: ExplorerStatsCompleteness | null
|
completeness?: ExplorerStatsCompleteness | null
|
||||||
sampling?: ExplorerStatsSampling | null
|
sampling?: ExplorerStatsSampling | null
|
||||||
|
diagnostics?: {
|
||||||
|
tx_visibility_state?: string | null
|
||||||
|
activity_state?: string | null
|
||||||
|
explanation?: string | null
|
||||||
|
tx_lag_blocks?: number | string | null
|
||||||
|
tx_lag_seconds?: number | string | null
|
||||||
|
recent_block_sample_size?: number | string | null
|
||||||
|
recent_non_empty_blocks?: number | string | null
|
||||||
|
recent_transactions?: number | string | null
|
||||||
|
latest_non_empty_block_from_block_feed?: RawExplorerFreshnessReference | null
|
||||||
|
source?: string | null
|
||||||
|
confidence?: string | null
|
||||||
|
provenance?: string | null
|
||||||
|
completeness?: string | null
|
||||||
|
} | null
|
||||||
}
|
}
|
||||||
|
|
||||||
interface RawExplorerFreshnessReference {
|
interface RawExplorerFreshnessReference {
|
||||||
@@ -135,6 +167,34 @@ function normalizeFreshnessSnapshot(raw?: RawExplorerStats['freshness'] | null):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeFreshnessDiagnostics(raw?: RawExplorerStats['diagnostics'] | null): ExplorerFreshnessDiagnostics | null {
|
||||||
|
if (!raw) return null
|
||||||
|
return {
|
||||||
|
tx_visibility_state: raw.tx_visibility_state || null,
|
||||||
|
activity_state: raw.activity_state || null,
|
||||||
|
explanation: raw.explanation || null,
|
||||||
|
tx_lag_blocks: raw.tx_lag_blocks == null || raw.tx_lag_blocks === '' ? null : toNumber(raw.tx_lag_blocks),
|
||||||
|
tx_lag_seconds: raw.tx_lag_seconds == null || raw.tx_lag_seconds === '' ? null : toNumber(raw.tx_lag_seconds),
|
||||||
|
recent_block_sample_size:
|
||||||
|
raw.recent_block_sample_size == null || raw.recent_block_sample_size === ''
|
||||||
|
? null
|
||||||
|
: toNumber(raw.recent_block_sample_size),
|
||||||
|
recent_non_empty_blocks:
|
||||||
|
raw.recent_non_empty_blocks == null || raw.recent_non_empty_blocks === ''
|
||||||
|
? null
|
||||||
|
: toNumber(raw.recent_non_empty_blocks),
|
||||||
|
recent_transactions:
|
||||||
|
raw.recent_transactions == null || raw.recent_transactions === ''
|
||||||
|
? null
|
||||||
|
: toNumber(raw.recent_transactions),
|
||||||
|
latest_non_empty_block_from_block_feed: normalizeFreshnessReference(raw.latest_non_empty_block_from_block_feed),
|
||||||
|
source: raw.source || null,
|
||||||
|
confidence: raw.confidence || null,
|
||||||
|
provenance: raw.provenance || null,
|
||||||
|
completeness: raw.completeness || null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
||||||
const latestBlockValue = raw.latest_block
|
const latestBlockValue = raw.latest_block
|
||||||
const averageBlockTimeValue = raw.average_block_time
|
const averageBlockTimeValue = raw.average_block_time
|
||||||
@@ -169,6 +229,7 @@ export function normalizeExplorerStats(raw: RawExplorerStats): ExplorerStats {
|
|||||||
freshness: normalizeFreshnessSnapshot(raw.freshness),
|
freshness: normalizeFreshnessSnapshot(raw.freshness),
|
||||||
completeness: raw.completeness || null,
|
completeness: raw.completeness || null,
|
||||||
sampling: raw.sampling || null,
|
sampling: raw.sampling || null,
|
||||||
|
diagnostics: normalizeFreshnessDiagnostics(raw.diagnostics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { Block } from '@/services/api/blocks'
|
import type { Block } from '@/services/api/blocks'
|
||||||
import type { Transaction } from '@/services/api/transactions'
|
import type { Transaction } from '@/services/api/transactions'
|
||||||
import type { ExplorerFreshnessSnapshot } from '@/services/api/stats'
|
import type { ExplorerFreshnessDiagnostics, ExplorerFreshnessSnapshot } from '@/services/api/stats'
|
||||||
|
|
||||||
export type ChainActivityState = 'active' | 'low' | 'inactive' | 'unknown'
|
export type ChainActivityState = 'active' | 'low' | 'inactive' | 'unknown'
|
||||||
|
|
||||||
@@ -34,8 +34,10 @@ export function summarizeChainActivity(input: {
|
|||||||
latestBlockNumber?: number | null
|
latestBlockNumber?: number | null
|
||||||
latestBlockTimestamp?: string | null
|
latestBlockTimestamp?: string | null
|
||||||
freshness?: ExplorerFreshnessSnapshot | null
|
freshness?: ExplorerFreshnessSnapshot | null
|
||||||
|
diagnostics?: ExplorerFreshnessDiagnostics | null
|
||||||
}): ChainActivityContext {
|
}): ChainActivityContext {
|
||||||
const freshness = input.freshness || null
|
const freshness = input.freshness || null
|
||||||
|
const diagnostics = input.diagnostics || null
|
||||||
const blocks = Array.isArray(input.blocks) ? input.blocks : []
|
const blocks = Array.isArray(input.blocks) ? input.blocks : []
|
||||||
const transactions = Array.isArray(input.transactions) ? input.transactions : []
|
const transactions = Array.isArray(input.transactions) ? input.transactions : []
|
||||||
|
|
||||||
@@ -55,9 +57,11 @@ export function summarizeChainActivity(input: {
|
|||||||
transactions.find((transaction) => transaction.block_number === latestTransaction) ?? transactions[0] ?? null
|
transactions.find((transaction) => transaction.block_number === latestTransaction) ?? transactions[0] ?? null
|
||||||
|
|
||||||
const nonEmptyBlock =
|
const nonEmptyBlock =
|
||||||
|
diagnostics?.latest_non_empty_block_from_block_feed?.block_number ??
|
||||||
freshness?.latest_non_empty_block.block_number ??
|
freshness?.latest_non_empty_block.block_number ??
|
||||||
sortDescending(blocks.filter((block) => block.transaction_count > 0).map((block) => block.number))[0] ?? latestTransaction
|
sortDescending(blocks.filter((block) => block.transaction_count > 0).map((block) => block.number))[0] ?? latestTransaction
|
||||||
const nonEmptyBlockTimestamp =
|
const nonEmptyBlockTimestamp =
|
||||||
|
diagnostics?.latest_non_empty_block_from_block_feed?.timestamp ??
|
||||||
freshness?.latest_non_empty_block.timestamp ??
|
freshness?.latest_non_empty_block.timestamp ??
|
||||||
blocks.find((block) => block.number === nonEmptyBlock)?.timestamp ??
|
blocks.find((block) => block.number === nonEmptyBlock)?.timestamp ??
|
||||||
latestTransactionRecord?.created_at ??
|
latestTransactionRecord?.created_at ??
|
||||||
@@ -76,24 +80,39 @@ export function summarizeChainActivity(input: {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
const gap = freshness?.latest_non_empty_block.distance_from_head ??
|
const gap = freshness?.latest_non_empty_block.distance_from_head ??
|
||||||
|
diagnostics?.tx_lag_blocks ??
|
||||||
(latestBlock != null && latestTransaction != null
|
(latestBlock != null && latestTransaction != null
|
||||||
? Math.max(0, latestBlock - latestTransaction)
|
? Math.max(0, latestBlock - latestTransaction)
|
||||||
: null)
|
: null)
|
||||||
|
|
||||||
const state: ChainActivityState =
|
const state: ChainActivityState = (() => {
|
||||||
latestTransactionAgeSeconds == null
|
switch (diagnostics?.activity_state) {
|
||||||
? 'unknown'
|
case 'active':
|
||||||
: latestTransactionAgeSeconds <= 15 * 60
|
return 'active'
|
||||||
? 'active'
|
case 'sparse_activity':
|
||||||
: latestTransactionAgeSeconds <= 3 * 60 * 60
|
case 'quiet_chain':
|
||||||
? 'low'
|
return 'low'
|
||||||
: 'inactive'
|
case 'fresh_head_stale_transaction_visibility':
|
||||||
|
return 'inactive'
|
||||||
|
case 'limited_observability':
|
||||||
|
return 'unknown'
|
||||||
|
default:
|
||||||
|
return latestTransactionAgeSeconds == null
|
||||||
|
? 'unknown'
|
||||||
|
: latestTransactionAgeSeconds <= 15 * 60
|
||||||
|
? 'active'
|
||||||
|
: latestTransactionAgeSeconds <= 3 * 60 * 60
|
||||||
|
? 'low'
|
||||||
|
: 'inactive'
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
|
||||||
const headIsIdle =
|
const headIsIdle =
|
||||||
gap != null &&
|
diagnostics?.activity_state === 'quiet_chain' ||
|
||||||
gap > 0 &&
|
(gap != null &&
|
||||||
latestTransactionAgeSeconds != null &&
|
gap > 0 &&
|
||||||
latestTransactionAgeSeconds > 0
|
latestTransactionAgeSeconds != null &&
|
||||||
|
latestTransactionAgeSeconds > 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
latest_block_number: latestBlock,
|
latest_block_number: latestBlock,
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { describe, expect, it } from 'vitest'
|
import { describe, expect, it } from 'vitest'
|
||||||
import { resolveEffectiveFreshness, summarizeFreshnessConfidence } from './explorerFreshness'
|
import {
|
||||||
|
resolveEffectiveFreshness,
|
||||||
|
resolveFreshnessSourceLabel,
|
||||||
|
summarizeFreshnessConfidence,
|
||||||
|
} from './explorerFreshness'
|
||||||
|
|
||||||
describe('resolveEffectiveFreshness', () => {
|
describe('resolveEffectiveFreshness', () => {
|
||||||
it('prefers stats freshness when it is present', () => {
|
it('prefers stats freshness when it is present', () => {
|
||||||
@@ -129,4 +133,43 @@ describe('resolveEffectiveFreshness', () => {
|
|||||||
'Feed: snapshot',
|
'Feed: snapshot',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('describes whether freshness comes from stats or mission-control fallback', () => {
|
||||||
|
expect(
|
||||||
|
resolveFreshnessSourceLabel(
|
||||||
|
{
|
||||||
|
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 },
|
||||||
|
latest_indexed_block: { block_number: 10 },
|
||||||
|
latest_indexed_transaction: { block_number: 9 },
|
||||||
|
latest_non_empty_block: { block_number: 9 },
|
||||||
|
},
|
||||||
|
completeness: null,
|
||||||
|
sampling: null,
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
),
|
||||||
|
).toBe('Based on public stats and indexed explorer freshness.')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
resolveFreshnessSourceLabel(
|
||||||
|
null,
|
||||||
|
{
|
||||||
|
data: {
|
||||||
|
freshness: {
|
||||||
|
chain_head: { block_number: 20 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
).toBe('Based on mission-control freshness and latest visible public data.')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
20
frontend/src/utils/serverExplorerContext.ts
Normal file
20
frontend/src/utils/serverExplorerContext.ts
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl'
|
||||||
|
import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats'
|
||||||
|
import { fetchPublicJson } from './publicExplorer'
|
||||||
|
|
||||||
|
export interface ExplorerTruthContext {
|
||||||
|
initialStats: ExplorerStats | null
|
||||||
|
initialBridgeStatus: MissionControlBridgeStatusResponse | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchExplorerTruthContext(): Promise<ExplorerTruthContext> {
|
||||||
|
const [statsResult, bridgeResult] = await Promise.all([
|
||||||
|
fetchPublicJson('/api/v2/stats').catch(() => null),
|
||||||
|
fetchPublicJson<MissionControlBridgeStatusResponse>('/explorer-api/v1/track1/bridge/status').catch(() => null),
|
||||||
|
])
|
||||||
|
|
||||||
|
return {
|
||||||
|
initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null,
|
||||||
|
initialBridgeStatus: bridgeResult,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import {
|
|||||||
normalizeWatchlistAddress,
|
normalizeWatchlistAddress,
|
||||||
parseStoredWatchlist,
|
parseStoredWatchlist,
|
||||||
sanitizeWatchlistEntries,
|
sanitizeWatchlistEntries,
|
||||||
|
toggleWatchlistEntry,
|
||||||
} from './watchlist'
|
} from './watchlist'
|
||||||
|
|
||||||
describe('watchlist utils', () => {
|
describe('watchlist utils', () => {
|
||||||
@@ -39,4 +40,10 @@ describe('watchlist utils', () => {
|
|||||||
isWatchlistEntry(entries, '0x1234567890123456789012345678901234567890'.toUpperCase()),
|
isWatchlistEntry(entries, '0x1234567890123456789012345678901234567890'.toUpperCase()),
|
||||||
).toBe(true)
|
).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('toggles watchlist entries on and off case-insensitively', () => {
|
||||||
|
const address = '0x1234567890123456789012345678901234567890'
|
||||||
|
expect(toggleWatchlistEntry([], address)).toEqual([address])
|
||||||
|
expect(toggleWatchlistEntry([address], address.toUpperCase())).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -71,3 +71,16 @@ export function isWatchlistEntry(entries: string[], address: string) {
|
|||||||
|
|
||||||
return entries.some((entry) => entry.toLowerCase() === normalized.toLowerCase())
|
return entries.some((entry) => entry.toLowerCase() === normalized.toLowerCase())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function toggleWatchlistEntry(entries: string[], address: string) {
|
||||||
|
const normalized = normalizeWatchlistAddress(address)
|
||||||
|
if (!normalized) {
|
||||||
|
return sanitizeWatchlistEntries(entries)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isWatchlistEntry(entries, normalized)) {
|
||||||
|
return entries.filter((entry) => entry.toLowerCase() !== normalized.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
return sanitizeWatchlistEntries([...entries, normalized])
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user