From ee71f098ab9864e274678202c814d0b6a5cafdae Mon Sep 17 00:00:00 2001 From: defiQUG Date: Sun, 12 Apr 2026 06:33:54 -0700 Subject: [PATCH] Freshness diagnostics API, UI trust notes, mission control/stats updates, and deploy scripts. Made-with: Cursor --- README.md | 4 +- README_DEPLOYMENT.md | 3 +- backend/api/freshness/freshness.go | 398 +++++++ backend/api/freshness/freshness_test.go | 192 +++ backend/api/rest/mission_control.go | 49 + backend/api/rest/mission_control_test.go | 31 + backend/api/rest/stats.go | 162 ++- backend/api/rest/stats_internal_test.go | 70 +- backend/api/rest/track_routes.go | 25 +- backend/api/track1/bridge_status_data.go | 130 ++ backend/api/track1/ccip_health_test.go | 55 +- backend/api/track1/endpoints.go | 12 +- deployment/common/nginx-api-location.conf | 17 + docs/API_ERRORS_FIX.md | 11 +- docs/DEPLOYMENT.md | 26 +- docs/EXPLORER_API_ACCESS.md | 6 +- docs/EXPLORER_CODE_REVIEW.md | 11 +- ..._DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md | 154 +++ docs/EXPLORER_FRONTEND_TESTING.md | 7 +- docs/EXPLORER_LOADING_TROUBLESHOOTING.md | 8 +- docs/FRONTEND_DEPLOYMENT_FIX.md | 17 +- docs/FRONTEND_FIXES_COMPLETE.md | 6 +- docs/INDEX.md | 3 +- docs/README.md | 11 +- docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md | 6 +- docs/STRUCTURE.md | 17 +- docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md | 8 +- ...EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md | 530 ++++++++ ...ORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md | 278 +++++ docs/api/track-api-contracts.md | 2 + .../common/ActivityContextPanel.tsx | 137 +++ .../src/components/common/BrandLockup.tsx | 27 + frontend/src/components/common/BrandMark.tsx | 45 + .../src/components/common/ExplorerChrome.tsx | 29 +- .../components/common/FreshnessTrustNote.tsx | 85 ++ .../common/HeaderCommandPalette.tsx | 202 ++++ frontend/src/components/common/Navbar.tsx | 1061 +++++++++++++---- .../src/components/common/UiModeContext.tsx | 57 + .../explorer/AnalyticsOperationsPage.tsx | 31 + .../explorer/BridgeMonitoringPage.tsx | 13 + .../components/explorer/OperationsHubPage.tsx | 37 +- .../explorer/WethOperationsPage.tsx | 12 +- frontend/src/components/home/HomePage.tsx | 598 ++++++++-- frontend/src/components/wallet/WalletPage.tsx | 31 +- frontend/src/data/explorerOperations.ts | 28 +- frontend/src/pages/addresses/index.tsx | 68 +- frontend/src/pages/blocks/index.tsx | 106 +- frontend/src/pages/docs/index.tsx | 4 +- frontend/src/pages/index.tsx | 34 +- frontend/src/pages/search/index.tsx | 18 +- frontend/src/pages/transactions/index.tsx | 65 +- frontend/src/services/api/missionControl.ts | 64 +- frontend/src/services/api/stats.test.ts | 50 + frontend/src/services/api/stats.ts | 119 ++ frontend/src/utils/activityContext.ts | 111 ++ frontend/src/utils/explorerFreshness.test.ts | 132 ++ frontend/src/utils/explorerFreshness.ts | 104 ++ frontend/src/utils/format.ts | 14 + scripts/deploy-explorer-ai-to-vmid5000.sh | 75 ++ scripts/deploy-frontend-to-vmid5000.sh | 281 +---- scripts/deploy-next-frontend-to-vmid5000.sh | 21 + scripts/deploy.sh | 73 +- scripts/fix-nginx-serve-custom-frontend.sh | 8 +- 63 files changed, 5163 insertions(+), 826 deletions(-) create mode 100644 backend/api/freshness/freshness.go create mode 100644 backend/api/freshness/freshness_test.go create mode 100644 docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md create mode 100644 docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md create mode 100644 docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md create mode 100644 frontend/src/components/common/ActivityContextPanel.tsx create mode 100644 frontend/src/components/common/BrandLockup.tsx create mode 100644 frontend/src/components/common/BrandMark.tsx create mode 100644 frontend/src/components/common/FreshnessTrustNote.tsx create mode 100644 frontend/src/components/common/HeaderCommandPalette.tsx create mode 100644 frontend/src/components/common/UiModeContext.tsx create mode 100644 frontend/src/utils/activityContext.ts create mode 100644 frontend/src/utils/explorerFreshness.test.ts create mode 100644 frontend/src/utils/explorerFreshness.ts mode change 100755 => 100644 scripts/deploy-frontend-to-vmid5000.sh mode change 100755 => 100644 scripts/deploy.sh diff --git a/README.md b/README.md index c0f8c71..2b94579 100644 --- a/README.md +++ b/README.md @@ -27,9 +27,9 @@ If the script doesn't work, see `START_HERE.md` for step-by-step manual commands - **Production (canonical target):** the current **Next.js standalone frontend** in `frontend/src/`, built from `frontend/` with `npm run build` and deployed to VMID 5000 as a Node service behind nginx. - **Canonical deploy script:** `./scripts/deploy-next-frontend-to-vmid5000.sh` - **Canonical nginx wiring:** keep `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`; proxy `/` and `/_next/` to the frontend service using `deployment/common/nginx-next-frontend-proxy.conf`. -- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility, but it is no longer the preferred deployment target. +- **Legacy fallback only:** the static SPA (`frontend/public/index.html` + `explorer-spa.js`) remains in-repo for compatibility/reference, but it is not a supported primary deployment target. - **Architecture command center:** `frontend/public/chain138-command-center.html` — tabbed Mermaid topology (Chain 138 hub, network, stack, flows, cross-chain, cW Mainnet, off-chain, integrations). Linked from the SPA **More → Explore → Visual Command Center**. -- **Legacy static deploy:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `index.html` and assets to `/var/www/html/`) +- **Legacy static deploy scripts:** `./scripts/deploy-frontend-to-vmid5000.sh` and `./scripts/deploy.sh` now fail fast with a deprecation message and point to the canonical Next.js deploy path. - **Frontend review & tasks:** [frontend/FRONTEND_REVIEW.md](frontend/FRONTEND_REVIEW.md), [frontend/FRONTEND_TASKS_AND_REVIEW.md](frontend/FRONTEND_TASKS_AND_REVIEW.md) ## Documentation diff --git a/README_DEPLOYMENT.md b/README_DEPLOYMENT.md index fe1b764..31d5cbe 100644 --- a/README_DEPLOYMENT.md +++ b/README_DEPLOYMENT.md @@ -31,7 +31,7 @@ If scripts don't work, follow `COMPLETE_DEPLOYMENT.md` for step-by-step manual e - **`docs/README.md`** - Documentation overview and index - **`docs/EXPLORER_API_ACCESS.md`** - API access, 502 fix, frontend deploy -- **Frontend deploy only:** `./scripts/deploy-frontend-to-vmid5000.sh` (copies `frontend/public/index.html` to VMID 5000) +- **Frontend deploy only:** `./scripts/deploy-next-frontend-to-vmid5000.sh` (builds and deploys the current Next standalone frontend to VMID 5000) - `COMPLETE_DEPLOYMENT.md` - Complete step-by-step guide - `DEPLOYMENT_FINAL_STATUS.md` - Deployment status report - `RUN_ALL.md` - Quick reference @@ -61,4 +61,3 @@ tail -f backend/logs/api-server.log ``` **All deployment steps are ready to execute!** - diff --git a/backend/api/freshness/freshness.go b/backend/api/freshness/freshness.go new file mode 100644 index 0000000..fcdcfcd --- /dev/null +++ b/backend/api/freshness/freshness.go @@ -0,0 +1,398 @@ +package freshness + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" +) + +type QueryRowFunc func(ctx context.Context, sql string, args ...any) pgx.Row + +type Confidence string + +const ( + ConfidenceHigh Confidence = "high" + ConfidenceMedium Confidence = "medium" + ConfidenceLow Confidence = "low" + ConfidenceUnknown Confidence = "unknown" +) + +type Completeness string + +const ( + CompletenessComplete Completeness = "complete" + CompletenessPartial Completeness = "partial" + CompletenessStale Completeness = "stale" + CompletenessUnavailable Completeness = "unavailable" +) + +type Source string + +const ( + SourceReported Source = "reported" + SourceDerived Source = "derived" + SourceSampled Source = "sampled" + SourceUnavailable Source = "unavailable" +) + +type Provenance string + +const ( + ProvenanceRPC Provenance = "rpc" + ProvenanceExplorerIndex Provenance = "explorer_index" + ProvenanceTxIndex Provenance = "tx_index" + ProvenanceMissionFeed Provenance = "mission_control_feed" + ProvenanceComposite Provenance = "composite" +) + +type Reference struct { + BlockNumber *int64 `json:"block_number"` + Timestamp *string `json:"timestamp"` + AgeSeconds *int64 `json:"age_seconds"` + Hash *string `json:"hash,omitempty"` + DistanceFromHead *int64 `json:"distance_from_head,omitempty"` + Source Source `json:"source"` + Confidence Confidence `json:"confidence"` + Provenance Provenance `json:"provenance"` + Completeness Completeness `json:"completeness,omitempty"` +} + +type Snapshot struct { + ChainHead Reference `json:"chain_head"` + LatestIndexedBlock Reference `json:"latest_indexed_block"` + LatestIndexedTransaction Reference `json:"latest_indexed_transaction"` + LatestNonEmptyBlock Reference `json:"latest_non_empty_block"` +} + +type SummaryCompleteness struct { + TransactionsFeed Completeness `json:"transactions_feed"` + BlocksFeed Completeness `json:"blocks_feed"` + GasMetrics Completeness `json:"gas_metrics"` + UtilizationMetric Completeness `json:"utilization_metrics"` +} + +type Sampling struct { + StatsGeneratedAt *string `json:"stats_generated_at"` + RPCProbeAt *string `json:"rpc_probe_at"` + StatsWindowSec *int64 `json:"stats_window_seconds,omitempty"` + Issues map[string]string `json:"issues,omitempty"` +} + +type HeadProbeFunc func(ctx context.Context) (*Reference, error) + +func ptrInt64(value int64) *int64 { return &value } + +func ptrString(value string) *string { return &value } + +func unknownReference(provenance Provenance) Reference { + return Reference{ + Source: SourceUnavailable, + Confidence: ConfidenceUnknown, + Provenance: provenance, + Completeness: CompletenessUnavailable, + } +} + +func timePointer(value time.Time) *string { + if value.IsZero() { + return nil + } + formatted := value.UTC().Format(time.RFC3339) + return &formatted +} + +func computeAge(timestamp *string, now time.Time) *int64 { + if timestamp == nil || *timestamp == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339, *timestamp) + if err != nil { + return nil + } + age := int64(now.Sub(parsed).Seconds()) + if age < 0 { + age = 0 + } + return &age +} + +func classifyIndexedVisibility(age *int64) Completeness { + if age == nil { + return CompletenessUnavailable + } + switch { + case *age <= 15*60: + return CompletenessComplete + case *age <= 3*60*60: + return CompletenessPartial + default: + return CompletenessStale + } +} + +func classifyBlockFeed(chainHead *int64, indexedHead *int64) Completeness { + if chainHead == nil || indexedHead == nil { + return CompletenessUnavailable + } + distance := *chainHead - *indexedHead + if distance < 0 { + distance = 0 + } + switch { + case distance <= 2: + return CompletenessComplete + case distance <= 32: + return CompletenessPartial + default: + return CompletenessStale + } +} + +func classifyMetricPresence[T comparable](value *T) Completeness { + if value == nil { + return CompletenessUnavailable + } + return CompletenessComplete +} + +func BuildSnapshot( + ctx context.Context, + chainID int, + queryRow QueryRowFunc, + probeHead HeadProbeFunc, + now time.Time, + averageGasPrice *float64, + utilization *float64, +) (Snapshot, SummaryCompleteness, Sampling, error) { + snapshot := Snapshot{ + ChainHead: unknownReference(ProvenanceRPC), + LatestIndexedBlock: unknownReference(ProvenanceExplorerIndex), + LatestIndexedTransaction: unknownReference(ProvenanceTxIndex), + LatestNonEmptyBlock: unknownReference(ProvenanceTxIndex), + } + issues := map[string]string{} + + if probeHead != nil { + if head, err := probeHead(ctx); err == nil && head != nil { + snapshot.ChainHead = *head + } else if err != nil { + issues["chain_head"] = err.Error() + } + } + + var latestIndexedBlockNumber int64 + var latestIndexedBlockTime time.Time + if err := queryRow(ctx, + `SELECT number, timestamp + FROM blocks + ORDER BY number DESC + LIMIT 1`, + ).Scan(&latestIndexedBlockNumber, &latestIndexedBlockTime); err == nil { + timestamp := timePointer(latestIndexedBlockTime) + snapshot.LatestIndexedBlock = Reference{ + BlockNumber: ptrInt64(latestIndexedBlockNumber), + Timestamp: timestamp, + AgeSeconds: computeAge(timestamp, now), + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceExplorerIndex, + Completeness: CompletenessComplete, + } + } else { + issues["latest_indexed_block"] = err.Error() + } + + var latestTxHash string + var latestTxBlock int64 + var latestTxCreatedAt time.Time + if err := queryRow(ctx, + `SELECT concat('0x', encode(hash, 'hex')), block_number::bigint, COALESCE(block_timestamp, inserted_at) + FROM transactions + WHERE block_number IS NOT NULL + ORDER BY block_number DESC, "index" DESC + LIMIT 1`, + ).Scan(&latestTxHash, &latestTxBlock, &latestTxCreatedAt); err == nil { + timestamp := timePointer(latestTxCreatedAt) + snapshot.LatestIndexedTransaction = Reference{ + BlockNumber: ptrInt64(latestTxBlock), + Timestamp: timestamp, + AgeSeconds: computeAge(timestamp, now), + Hash: ptrString(latestTxHash), + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceTxIndex, + Completeness: classifyIndexedVisibility(computeAge(timestamp, now)), + } + } else { + issues["latest_indexed_transaction"] = err.Error() + } + + var latestNonEmptyBlockNumber int64 + var latestNonEmptyBlockTime time.Time + if err := queryRow(ctx, + `SELECT b.number, b.timestamp + FROM blocks b + WHERE EXISTS ( + SELECT 1 + FROM transactions t + WHERE t.block_number = b.number + ) + ORDER BY b.number DESC + LIMIT 1`, + ).Scan(&latestNonEmptyBlockNumber, &latestNonEmptyBlockTime); err == nil { + timestamp := timePointer(latestNonEmptyBlockTime) + ref := Reference{ + BlockNumber: ptrInt64(latestNonEmptyBlockNumber), + Timestamp: timestamp, + AgeSeconds: computeAge(timestamp, now), + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceTxIndex, + Completeness: classifyIndexedVisibility(computeAge(timestamp, now)), + } + if snapshot.ChainHead.BlockNumber != nil { + distance := *snapshot.ChainHead.BlockNumber - latestNonEmptyBlockNumber + if distance < 0 { + distance = 0 + } + ref.DistanceFromHead = ptrInt64(distance) + } + snapshot.LatestNonEmptyBlock = ref + } else { + issues["latest_non_empty_block"] = err.Error() + } + + statsGeneratedAt := now.UTC().Format(time.RFC3339) + sampling := Sampling{ + StatsGeneratedAt: ptrString(statsGeneratedAt), + StatsWindowSec: ptrInt64(300), + } + if len(issues) > 0 { + sampling.Issues = issues + } + if snapshot.ChainHead.Timestamp != nil { + sampling.RPCProbeAt = snapshot.ChainHead.Timestamp + } + + completeness := SummaryCompleteness{ + TransactionsFeed: snapshot.LatestIndexedTransaction.Completeness, + BlocksFeed: classifyBlockFeed(snapshot.ChainHead.BlockNumber, snapshot.LatestIndexedBlock.BlockNumber), + GasMetrics: classifyMetricPresence(averageGasPrice), + UtilizationMetric: classifyMetricPresence(utilization), + } + + return snapshot, completeness, sampling, nil +} + +func ProbeChainHead(ctx context.Context, rpcURL string) (*Reference, error) { + rpcURL = strings.TrimSpace(rpcURL) + if rpcURL == "" { + return nil, fmt.Errorf("empty rpc url") + } + + blockNumberRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_blockNumber", []interface{}{}) + if err != nil { + return nil, err + } + var blockNumberHex string + if err := json.Unmarshal(blockNumberRaw, &blockNumberHex); err != nil { + return nil, err + } + blockNumber, err := strconv.ParseInt(strings.TrimPrefix(strings.TrimSpace(blockNumberHex), "0x"), 16, 64) + if err != nil { + return nil, err + } + + blockRaw, _, err := postJSONRPC(ctx, rpcURL, "eth_getBlockByNumber", []interface{}{"latest", false}) + if err != nil { + return nil, err + } + var latestBlock struct { + Timestamp string `json:"timestamp"` + } + if err := json.Unmarshal(blockRaw, &latestBlock); err != nil { + return nil, err + } + blockTimeHex := strings.TrimSpace(latestBlock.Timestamp) + if blockTimeHex == "" { + return nil, fmt.Errorf("missing block timestamp") + } + blockTimestamp, err := strconv.ParseInt(strings.TrimPrefix(blockTimeHex, "0x"), 16, 64) + if err != nil { + return nil, err + } + ts := time.Unix(blockTimestamp, 0).UTC() + timestamp := ts.Format(time.RFC3339) + now := time.Now().UTC() + age := int64(now.Sub(ts).Seconds()) + if age < 0 { + age = 0 + } + + return &Reference{ + BlockNumber: ptrInt64(blockNumber), + Timestamp: ptrString(timestamp), + AgeSeconds: ptrInt64(age), + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceRPC, + Completeness: CompletenessComplete, + }, nil +} + +func postJSONRPC(ctx context.Context, rpcURL string, method string, params []interface{}) (json.RawMessage, int64, error) { + body, err := json.Marshal(map[string]interface{}{ + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params, + }) + if err != nil { + return nil, 0, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, rpcURL, bytes.NewReader(body)) + if err != nil { + return nil, 0, err + } + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{Timeout: 6 * time.Second} + start := time.Now() + resp, err := client.Do(req) + latency := time.Since(start).Milliseconds() + if err != nil { + return nil, latency, err + } + defer resp.Body.Close() + + payload, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return nil, latency, err + } + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return nil, latency, fmt.Errorf("http %d", resp.StatusCode) + } + + var out struct { + Result json.RawMessage `json:"result"` + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.Unmarshal(payload, &out); err != nil { + return nil, latency, err + } + if out.Error != nil && out.Error.Message != "" { + return nil, latency, fmt.Errorf("rpc error: %s", out.Error.Message) + } + return out.Result, latency, nil +} diff --git a/backend/api/freshness/freshness_test.go b/backend/api/freshness/freshness_test.go new file mode 100644 index 0000000..386f6f2 --- /dev/null +++ b/backend/api/freshness/freshness_test.go @@ -0,0 +1,192 @@ +package freshness + +import ( + "context" + "testing" + "time" + + "github.com/jackc/pgx/v5" + "github.com/stretchr/testify/require" +) + +type fakeRow struct { + scan func(dest ...any) error +} + +func (r fakeRow) Scan(dest ...any) error { + return r.scan(dest...) +} + +func TestBuildSnapshotHealthyState(t *testing.T) { + now := time.Date(2026, 4, 10, 22, 10, 16, 0, time.UTC) + call := 0 + queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row { + call++ + switch call { + case 1: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*int64) = 200 + *dest[1].(*time.Time) = now.Add(-2 * time.Second) + return nil + }} + case 2: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*string) = "0xabc" + *dest[1].(*int64) = 198 + *dest[2].(*time.Time) = now.Add(-5 * time.Second) + return nil + }} + case 3: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*int64) = 198 + *dest[1].(*time.Time) = now.Add(-5 * time.Second) + return nil + }} + default: + t.Fatalf("unexpected call %d", call) + return nil + } + } + + probe := func(context.Context) (*Reference, error) { + ts := now.Add(-1 * time.Second).Format(time.RFC3339) + age := int64(1) + block := int64(200) + return &Reference{ + BlockNumber: &block, + Timestamp: &ts, + AgeSeconds: &age, + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceRPC, + Completeness: CompletenessComplete, + }, nil + } + + snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil) + require.NoError(t, err) + require.Equal(t, int64(200), *snapshot.ChainHead.BlockNumber) + require.Equal(t, int64(198), *snapshot.LatestIndexedTransaction.BlockNumber) + require.Equal(t, int64(2), *snapshot.LatestNonEmptyBlock.DistanceFromHead) + require.Equal(t, CompletenessComplete, completeness.TransactionsFeed) + require.NotNil(t, sampling.StatsGeneratedAt) +} + +func TestBuildSnapshotFreshHeadStaleTransactionVisibility(t *testing.T) { + now := time.Date(2026, 4, 11, 0, 10, 16, 0, time.UTC) + call := 0 + queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row { + call++ + switch call { + case 1: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*int64) = 3875999 + *dest[1].(*time.Time) = now.Add(-3 * time.Second) + return nil + }} + case 2: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*string) = "0xstale" + *dest[1].(*int64) = 3860660 + *dest[2].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute)) + return nil + }} + case 3: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*int64) = 3860660 + *dest[1].(*time.Time) = now.Add(-(9*time.Hour + 8*time.Minute)) + return nil + }} + default: + t.Fatalf("unexpected call %d", call) + return nil + } + } + + probe := func(context.Context) (*Reference, error) { + ts := now.Add(-1 * time.Second).Format(time.RFC3339) + age := int64(1) + block := int64(3876000) + return &Reference{ + BlockNumber: &block, + Timestamp: &ts, + AgeSeconds: &age, + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceRPC, + Completeness: CompletenessComplete, + }, nil + } + + snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil) + require.NoError(t, err) + require.Equal(t, int64(15340), *snapshot.LatestNonEmptyBlock.DistanceFromHead) + require.Equal(t, CompletenessStale, completeness.TransactionsFeed) + require.Equal(t, CompletenessComplete, completeness.BlocksFeed) +} + +func TestBuildSnapshotQuietChainButCurrent(t *testing.T) { + now := time.Date(2026, 4, 10, 23, 10, 16, 0, time.UTC) + call := 0 + queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row { + call++ + switch call { + case 1: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*int64) = 3875000 + *dest[1].(*time.Time) = now.Add(-1 * time.Second) + return nil + }} + case 2: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*string) = "0xquiet" + *dest[1].(*int64) = 3874902 + *dest[2].(*time.Time) = now.Add(-512 * time.Second) + return nil + }} + case 3: + return fakeRow{scan: func(dest ...any) error { + *dest[0].(*int64) = 3874902 + *dest[1].(*time.Time) = now.Add(-512 * time.Second) + return nil + }} + default: + t.Fatalf("unexpected call %d", call) + return nil + } + } + + probe := func(context.Context) (*Reference, error) { + ts := now.Add(-1 * time.Second).Format(time.RFC3339) + age := int64(1) + block := int64(3875000) + return &Reference{ + BlockNumber: &block, + Timestamp: &ts, + AgeSeconds: &age, + Source: SourceReported, + Confidence: ConfidenceHigh, + Provenance: ProvenanceRPC, + Completeness: CompletenessComplete, + }, nil + } + + snapshot, completeness, _, err := BuildSnapshot(context.Background(), 138, queryRow, probe, now, nil, nil) + require.NoError(t, err) + require.Equal(t, int64(98), *snapshot.LatestNonEmptyBlock.DistanceFromHead) + require.Equal(t, CompletenessComplete, completeness.TransactionsFeed) +} + +func TestBuildSnapshotUnknownFieldsRemainNullSafe(t *testing.T) { + queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row { + return fakeRow{scan: func(dest ...any) error { + return pgx.ErrNoRows + }} + } + + snapshot, completeness, sampling, err := BuildSnapshot(context.Background(), 138, queryRow, nil, time.Now().UTC(), nil, nil) + require.NoError(t, err) + require.Nil(t, snapshot.ChainHead.BlockNumber) + require.Equal(t, CompletenessUnavailable, completeness.TransactionsFeed) + require.NotNil(t, sampling.StatsGeneratedAt) +} diff --git a/backend/api/rest/mission_control.go b/backend/api/rest/mission_control.go index dad26ad..cb6b313 100644 --- a/backend/api/rest/mission_control.go +++ b/backend/api/rest/mission_control.go @@ -48,6 +48,46 @@ func tokenAggregationBase() string { return "" } +func looksLikeGenericUpstreamErrorPayload(body []byte) bool { + if len(bytes.TrimSpace(body)) == 0 { + return false + } + + var payload map[string]any + if err := json.Unmarshal(body, &payload); err != nil { + return false + } + + errValue, ok := payload["error"].(string) + if !ok || strings.TrimSpace(errValue) == "" { + return false + } + + if _, ok := payload["pools"]; ok { + return false + } + if _, ok := payload["tokens"]; ok { + return false + } + if _, ok := payload["data"]; ok { + return false + } + if _, ok := payload["chains"]; ok { + return false + } + if _, ok := payload["tree"]; ok { + return false + } + if _, ok := payload["quote"]; ok { + return false + } + if status, ok := payload["status"].(string); ok && strings.EqualFold(status, "healthy") { + return false + } + + return true +} + func blockscoutInternalBase() string { u := strings.TrimSpace(os.Getenv("BLOCKSCOUT_INTERNAL_URL")) if u == "" { @@ -156,6 +196,15 @@ func (s *Server) handleMissionControlLiquidityTokenPath(w http.ResponseWriter, r if ctype == "" { ctype = "application/json" } + isGenericSuccessError := resp.StatusCode >= 200 && resp.StatusCode < 300 && looksLikeGenericUpstreamErrorPayload(body) + if isGenericSuccessError { + atomic.AddUint64(&missionControlMetrics.liquidityUpstreamFailure, 1) + log.Printf("mission_control liquidity_proxy addr=%s chain=%s cache=miss upstream_status=%d generic_error_envelope=true", strings.ToLower(addr), chain, resp.StatusCode) + w.Header().Set("Content-Type", ctype) + w.WriteHeader(http.StatusBadGateway) + _, _ = w.Write(body) + return + } if resp.StatusCode == http.StatusOK { liquidityPoolsCache.Store(cacheKey, liquidityCacheEntry{ body: body, diff --git a/backend/api/rest/mission_control_test.go b/backend/api/rest/mission_control_test.go index 3a6a1da..bf85702 100644 --- a/backend/api/rest/mission_control_test.go +++ b/backend/api/rest/mission_control_test.go @@ -98,6 +98,37 @@ func TestHandleMissionControlLiquidityTokenPathBypassesCacheWhenRequested(t *tes require.Equal(t, 2, hitCount, "refresh=1 should force a fresh upstream read") } +func TestHandleMissionControlLiquidityTokenPathTreatsGenericSuccessErrorEnvelopeAsBadGateway(t *testing.T) { + resetMissionControlTestGlobals() + + var hitCount int + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hitCount++ + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"error":"Internal server error"}`)) + })) + defer upstream.Close() + + t.Setenv("TOKEN_AGGREGATION_BASE_URL", upstream.URL) + t.Setenv("CHAIN_ID", "138") + + s := NewServer(nil, 138) + path := "/api/v1/mission-control/liquidity/token/0x93E66202A11B1772E55407B32B44e5Cd8eda7f22/pools" + + w1 := httptest.NewRecorder() + s.handleMissionControlLiquidityTokenPath(w1, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusBadGateway, w1.Code) + require.Equal(t, "miss", w1.Header().Get("X-Mission-Control-Cache")) + require.JSONEq(t, `{"error":"Internal server error"}`, w1.Body.String()) + + w2 := httptest.NewRecorder() + s.handleMissionControlLiquidityTokenPath(w2, httptest.NewRequest(http.MethodGet, path, nil)) + require.Equal(t, http.StatusBadGateway, w2.Code) + require.Equal(t, "miss", w2.Header().Get("X-Mission-Control-Cache")) + require.Equal(t, 2, hitCount, "generic error envelopes must not be cached as success") +} + func TestHandleMissionControlBridgeTraceLabelsFromRegistry(t *testing.T) { resetMissionControlTestGlobals() diff --git a/backend/api/rest/stats.go b/backend/api/rest/stats.go index 4268b8d..7c05fb8 100644 --- a/backend/api/rest/stats.go +++ b/backend/api/rest/stats.go @@ -2,62 +2,190 @@ package rest import ( "context" + "database/sql" "encoding/json" "fmt" "net/http" + "os" + "strings" "time" - "github.com/jackc/pgx/v5" + "github.com/explorer/backend/api/freshness" ) type explorerStats struct { - TotalBlocks int64 `json:"total_blocks"` - TotalTransactions int64 `json:"total_transactions"` - TotalAddresses int64 `json:"total_addresses"` - LatestBlock int64 `json:"latest_block"` + TotalBlocks int64 `json:"total_blocks"` + TotalTransactions int64 `json:"total_transactions"` + TotalAddresses int64 `json:"total_addresses"` + LatestBlock int64 `json:"latest_block"` + AverageBlockTime *float64 `json:"average_block_time,omitempty"` + GasPrices *explorerGasPrices `json:"gas_prices,omitempty"` + NetworkUtilizationPercentage *float64 `json:"network_utilization_percentage,omitempty"` + TransactionsToday *int64 `json:"transactions_today,omitempty"` + Freshness freshness.Snapshot `json:"freshness"` + Completeness freshness.SummaryCompleteness `json:"completeness"` + Sampling freshness.Sampling `json:"sampling"` } -type statsQueryFunc func(ctx context.Context, sql string, args ...any) pgx.Row +type explorerGasPrices struct { + Average *float64 `json:"average,omitempty"` +} + +type statsQueryFunc = freshness.QueryRowFunc + +func queryNullableFloat64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*float64, error) { + var value sql.NullFloat64 + if err := queryRow(ctx, query, args...).Scan(&value); err != nil { + return nil, err + } + if !value.Valid { + return nil, nil + } + return &value.Float64, nil +} + +func queryNullableInt64(ctx context.Context, queryRow statsQueryFunc, query string, args ...any) (*int64, error) { + var value sql.NullInt64 + if err := queryRow(ctx, query, args...).Scan(&value); err != nil { + return nil, err + } + if !value.Valid { + return nil, nil + } + return &value.Int64, nil +} func loadExplorerStats(ctx context.Context, chainID int, queryRow statsQueryFunc) (explorerStats, error) { var stats explorerStats + _ = chainID if err := queryRow(ctx, - `SELECT COUNT(*) FROM blocks WHERE chain_id = $1`, - chainID, + `SELECT COUNT(*) FROM blocks`, ).Scan(&stats.TotalBlocks); err != nil { return explorerStats{}, fmt.Errorf("query total blocks: %w", err) } if err := queryRow(ctx, - `SELECT COUNT(*) FROM transactions WHERE chain_id = $1`, - chainID, + `SELECT COUNT(*) FROM transactions WHERE block_hash IS NOT NULL`, ).Scan(&stats.TotalTransactions); err != nil { return explorerStats{}, fmt.Errorf("query total transactions: %w", err) } if err := queryRow(ctx, `SELECT COUNT(*) FROM ( - SELECT from_address AS address + SELECT from_address_hash AS address FROM transactions - WHERE chain_id = $1 AND from_address IS NOT NULL AND from_address <> '' + WHERE from_address_hash IS NOT NULL UNION - SELECT to_address AS address + SELECT to_address_hash AS address FROM transactions - WHERE chain_id = $1 AND to_address IS NOT NULL AND to_address <> '' + WHERE to_address_hash IS NOT NULL ) unique_addresses`, - chainID, ).Scan(&stats.TotalAddresses); err != nil { return explorerStats{}, fmt.Errorf("query total addresses: %w", err) } if err := queryRow(ctx, - `SELECT COALESCE(MAX(number), 0) FROM blocks WHERE chain_id = $1`, - chainID, + `SELECT COALESCE(MAX(number), 0) FROM blocks`, ).Scan(&stats.LatestBlock); err != nil { return explorerStats{}, fmt.Errorf("query latest block: %w", err) } + statsIssues := map[string]string{} + + averageBlockTime, err := queryNullableFloat64(ctx, queryRow, + `SELECT CASE + WHEN COUNT(*) >= 2 + THEN (EXTRACT(EPOCH FROM (MAX(timestamp) - MIN(timestamp))) * 1000.0) / NULLIF(COUNT(*) - 1, 0) + ELSE NULL + END + FROM ( + SELECT timestamp + FROM blocks + ORDER BY number DESC + LIMIT 100 + ) recent_blocks`, + ) + if err != nil { + statsIssues["average_block_time"] = err.Error() + } else { + stats.AverageBlockTime = averageBlockTime + } + + averageGasPrice, err := queryNullableFloat64(ctx, queryRow, + `SELECT AVG(gas_price_wei)::double precision / 1000000000.0 + FROM ( + SELECT gas_price AS gas_price_wei + FROM transactions + WHERE block_hash IS NOT NULL + AND gas_price IS NOT NULL + ORDER BY block_number DESC, "index" DESC + LIMIT 1000 + ) recent_transactions`, + ) + if err != nil { + statsIssues["average_gas_price"] = err.Error() + } else if averageGasPrice != nil { + stats.GasPrices = &explorerGasPrices{Average: averageGasPrice} + } + + networkUtilization, err := queryNullableFloat64(ctx, queryRow, + `SELECT AVG((gas_used::double precision / NULLIF(gas_limit, 0)) * 100.0) + FROM ( + SELECT gas_used, gas_limit + FROM blocks + WHERE gas_limit IS NOT NULL + AND gas_limit > 0 + ORDER BY number DESC + LIMIT 100 + ) recent_blocks`, + ) + if err != nil { + statsIssues["network_utilization_percentage"] = err.Error() + } else { + stats.NetworkUtilizationPercentage = networkUtilization + } + + transactionsToday, err := queryNullableInt64(ctx, queryRow, + `SELECT COUNT(*)::bigint + FROM transactions t + JOIN blocks b + ON b.number = t.block_number + WHERE b.timestamp >= NOW() - INTERVAL '24 hours'`, + ) + if err != nil { + statsIssues["transactions_today"] = err.Error() + } else { + stats.TransactionsToday = transactionsToday + } + + rpcURL := strings.TrimSpace(os.Getenv("RPC_URL")) + snapshot, completeness, sampling, err := freshness.BuildSnapshot( + ctx, + chainID, + queryRow, + func(ctx context.Context) (*freshness.Reference, error) { + return freshness.ProbeChainHead(ctx, rpcURL) + }, + time.Now().UTC(), + averageGasPrice, + networkUtilization, + ) + if err != nil { + return explorerStats{}, fmt.Errorf("build freshness snapshot: %w", err) + } + if len(statsIssues) > 0 { + if sampling.Issues == nil { + sampling.Issues = map[string]string{} + } + for key, value := range statsIssues { + sampling.Issues[key] = value + } + } + stats.Freshness = snapshot + stats.Completeness = completeness + stats.Sampling = sampling + return stats, nil } diff --git a/backend/api/rest/stats_internal_test.go b/backend/api/rest/stats_internal_test.go index 86f7cfd..9342fbd 100644 --- a/backend/api/rest/stats_internal_test.go +++ b/backend/api/rest/stats_internal_test.go @@ -2,10 +2,17 @@ package rest import ( "context" + "database/sql" + "encoding/json" "errors" + "net/http" + "net/http/httptest" + "strconv" "strings" "testing" + "time" + "github.com/explorer/backend/api/freshness" "github.com/jackc/pgx/v5" "github.com/stretchr/testify/require" ) @@ -19,23 +26,56 @@ func (r fakeStatsRow) Scan(dest ...any) error { } func TestLoadExplorerStatsReturnsValues(t *testing.T) { + rpc := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var req struct { + Method string `json:"method"` + } + require.NoError(t, json.NewDecoder(r.Body).Decode(&req)) + w.Header().Set("Content-Type", "application/json") + switch req.Method { + case "eth_blockNumber": + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":"0x2c"}`)) + case "eth_getBlockByNumber": + ts := time.Now().Add(-2 * time.Second).Unix() + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":1,"result":{"timestamp":"0x` + strconv.FormatInt(ts, 16) + `"}}`)) + default: + http.Error(w, `{"jsonrpc":"2.0","id":1,"error":{"message":"unsupported"}}`, http.StatusBadRequest) + } + })) + defer rpc.Close() + t.Setenv("RPC_URL", rpc.URL) var call int queryRow := func(_ context.Context, _ string, _ ...any) pgx.Row { call++ return fakeStatsRow{ scan: func(dest ...any) error { - target, ok := dest[0].(*int64) - require.True(t, ok) - switch call { case 1: - *target = 11 + *dest[0].(*int64) = 11 case 2: - *target = 22 + *dest[0].(*int64) = 22 case 3: - *target = 33 + *dest[0].(*int64) = 33 case 4: - *target = 44 + *dest[0].(*int64) = 44 + case 5: + *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 2000, Valid: true} + case 6: + *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 1.25, Valid: true} + case 7: + *dest[0].(*sql.NullFloat64) = sql.NullFloat64{Float64: 37.5, Valid: true} + case 8: + *dest[0].(*sql.NullInt64) = sql.NullInt64{Int64: 18, Valid: true} + case 9: + *dest[0].(*int64) = 44 + *dest[1].(*time.Time) = time.Now().Add(-2 * time.Second) + case 10: + *dest[0].(*string) = "0xabc" + *dest[1].(*int64) = 40 + *dest[2].(*time.Time) = time.Now().Add(-5 * time.Second) + case 11: + *dest[0].(*int64) = 40 + *dest[1].(*time.Time) = time.Now().Add(-5 * time.Second) default: t.Fatalf("unexpected query call %d", call) } @@ -50,9 +90,25 @@ func TestLoadExplorerStatsReturnsValues(t *testing.T) { require.Equal(t, int64(22), stats.TotalTransactions) require.Equal(t, int64(33), stats.TotalAddresses) require.Equal(t, int64(44), stats.LatestBlock) + require.NotNil(t, stats.AverageBlockTime) + require.Equal(t, 2000.0, *stats.AverageBlockTime) + require.NotNil(t, stats.GasPrices) + require.NotNil(t, stats.GasPrices.Average) + require.Equal(t, 1.25, *stats.GasPrices.Average) + require.NotNil(t, stats.NetworkUtilizationPercentage) + require.Equal(t, 37.5, *stats.NetworkUtilizationPercentage) + require.NotNil(t, stats.TransactionsToday) + require.Equal(t, int64(18), *stats.TransactionsToday) + require.NotNil(t, stats.Freshness.ChainHead.BlockNumber) + require.Equal(t, int64(40), *stats.Freshness.LatestIndexedTransaction.BlockNumber) + require.Equal(t, int64(4), *stats.Freshness.LatestNonEmptyBlock.DistanceFromHead) + require.Equal(t, "reported", string(stats.Freshness.ChainHead.Source)) + require.Equal(t, freshness.CompletenessComplete, stats.Completeness.GasMetrics) + require.Equal(t, freshness.CompletenessComplete, stats.Completeness.UtilizationMetric) } func TestLoadExplorerStatsReturnsErrorWhenQueryFails(t *testing.T) { + t.Setenv("RPC_URL", "") queryRow := func(_ context.Context, query string, _ ...any) pgx.Row { return fakeStatsRow{ scan: func(dest ...any) error { diff --git a/backend/api/rest/track_routes.go b/backend/api/rest/track_routes.go index b49b0b5..dbb34b2 100644 --- a/backend/api/rest/track_routes.go +++ b/backend/api/rest/track_routes.go @@ -1,10 +1,13 @@ package rest import ( + "context" "net/http" "os" "strings" + "time" + "github.com/explorer/backend/api/freshness" "github.com/explorer/backend/api/middleware" "github.com/explorer/backend/api/track1" "github.com/explorer/backend/api/track2" @@ -47,7 +50,27 @@ func (s *Server) SetupTrackRoutes(mux *http.ServeMux, authMiddleware *middleware } rpcGateway := gateway.NewRPCGateway(rpcURL, cache, rateLimiter) - track1Server := track1.NewServer(rpcGateway) + track1Server := track1.NewServer(rpcGateway, func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) { + if s.db == nil { + return nil, nil, nil, nil + } + now := time.Now().UTC() + snapshot, completeness, sampling, err := freshness.BuildSnapshot( + ctx, + s.chainID, + s.db.QueryRow, + func(ctx context.Context) (*freshness.Reference, error) { + return freshness.ProbeChainHead(ctx, rpcURL) + }, + now, + nil, + nil, + ) + if err != nil { + return nil, nil, nil, err + } + return &snapshot, &completeness, &sampling, nil + }) // Track 1 routes (public, optional auth) mux.HandleFunc("/api/v1/track1/blocks/latest", track1Server.HandleLatestBlocks) diff --git a/backend/api/track1/bridge_status_data.go b/backend/api/track1/bridge_status_data.go index dde9f22..a06b27e 100644 --- a/backend/api/track1/bridge_status_data.go +++ b/backend/api/track1/bridge_status_data.go @@ -5,6 +5,8 @@ import ( "os" "strings" "time" + + "github.com/explorer/backend/api/freshness" ) func relaySnapshotStatus(relay map[string]interface{}) string { @@ -129,6 +131,81 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface if ov := readOptionalVerifyJSON(); ov != nil { data["operator_verify"] = ov } + if s.freshnessLoader != nil { + if snapshot, completeness, sampling, err := s.freshnessLoader(ctx); err == nil && snapshot != nil { + subsystems := map[string]interface{}{ + "rpc_head": map[string]interface{}{ + "status": chainStatusFromProbe(p138), + "updated_at": valueOrNil(snapshot.ChainHead.Timestamp), + "age_seconds": valueOrNil(snapshot.ChainHead.AgeSeconds), + "source": snapshot.ChainHead.Source, + "confidence": snapshot.ChainHead.Confidence, + "provenance": snapshot.ChainHead.Provenance, + "completeness": snapshot.ChainHead.Completeness, + }, + "tx_index": map[string]interface{}{ + "status": completenessStatus(completeness.TransactionsFeed), + "updated_at": valueOrNil(snapshot.LatestIndexedTransaction.Timestamp), + "age_seconds": valueOrNil(snapshot.LatestIndexedTransaction.AgeSeconds), + "source": snapshot.LatestIndexedTransaction.Source, + "confidence": snapshot.LatestIndexedTransaction.Confidence, + "provenance": snapshot.LatestIndexedTransaction.Provenance, + "completeness": completeness.TransactionsFeed, + }, + "stats_summary": map[string]interface{}{ + "status": completenessStatus(completeness.BlocksFeed), + "updated_at": valueOrNil(sampling.StatsGeneratedAt), + "age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt), + "source": freshness.SourceReported, + "confidence": freshness.ConfidenceMedium, + "provenance": freshness.ProvenanceComposite, + "completeness": completeness.BlocksFeed, + }, + } + if len(sampling.Issues) > 0 { + subsystems["freshness_queries"] = map[string]interface{}{ + "status": "degraded", + "updated_at": valueOrNil(sampling.StatsGeneratedAt), + "age_seconds": ageSinceRFC3339(sampling.StatsGeneratedAt), + "source": freshness.SourceDerived, + "confidence": freshness.ConfidenceMedium, + "provenance": freshness.ProvenanceComposite, + "completeness": freshness.CompletenessPartial, + "issues": sampling.Issues, + } + } + modeKind := "live" + modeReason := any(nil) + modeScope := any(nil) + if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 { + modeKind = "snapshot" + modeReason = "live_homepage_stream_not_attached" + modeScope = "relay_monitoring_homepage_card_only" + subsystems["bridge_relay_monitoring"] = map[string]interface{}{ + "status": overall, + "updated_at": now, + "age_seconds": int64(0), + "source": freshness.SourceReported, + "confidence": freshness.ConfidenceHigh, + "provenance": freshness.ProvenanceMissionFeed, + "completeness": freshness.CompletenessComplete, + } + } + data["freshness"] = snapshot + data["subsystems"] = subsystems + data["sampling"] = sampling + data["mode"] = map[string]interface{}{ + "kind": modeKind, + "updated_at": now, + "age_seconds": int64(0), + "reason": modeReason, + "scope": modeScope, + "source": freshness.SourceReported, + "confidence": freshness.ConfidenceHigh, + "provenance": freshness.ProvenanceMissionFeed, + } + } + } if relays := FetchCCIPRelayHealths(ctx); relays != nil { data["ccip_relays"] = relays if ccip := primaryRelayHealth(relays); ccip != nil { @@ -142,5 +219,58 @@ func (s *Server) BuildBridgeStatusData(ctx context.Context) map[string]interface } } } + if mode, ok := data["mode"].(map[string]interface{}); ok { + if relays, ok := data["ccip_relays"].(map[string]interface{}); ok && len(relays) > 0 { + mode["kind"] = "snapshot" + mode["reason"] = "live_homepage_stream_not_attached" + mode["scope"] = "relay_monitoring_homepage_card_only" + if subsystems, ok := data["subsystems"].(map[string]interface{}); ok { + subsystems["bridge_relay_monitoring"] = map[string]interface{}{ + "status": data["status"], + "updated_at": now, + "age_seconds": int64(0), + "source": freshness.SourceReported, + "confidence": freshness.ConfidenceHigh, + "provenance": freshness.ProvenanceMissionFeed, + "completeness": freshness.CompletenessComplete, + } + } + } + } return data } + +func valueOrNil[T any](value *T) any { + if value == nil { + return nil + } + return *value +} + +func ageSinceRFC3339(value *string) any { + if value == nil || *value == "" { + return nil + } + parsed, err := time.Parse(time.RFC3339, *value) + if err != nil { + return nil + } + age := int64(time.Since(parsed).Seconds()) + if age < 0 { + age = 0 + } + return age +} + +func completenessStatus(value freshness.Completeness) string { + switch value { + case freshness.CompletenessComplete: + return "operational" + case freshness.CompletenessPartial: + return "partial" + case freshness.CompletenessStale: + return "stale" + default: + return "unavailable" + } +} diff --git a/backend/api/track1/ccip_health_test.go b/backend/api/track1/ccip_health_test.go index 07a9d94..06ea7f0 100644 --- a/backend/api/track1/ccip_health_test.go +++ b/backend/api/track1/ccip_health_test.go @@ -11,6 +11,7 @@ import ( "testing" "time" + "github.com/explorer/backend/api/freshness" "github.com/stretchr/testify/require" ) @@ -145,7 +146,50 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) { t.Setenv("CCIP_RELAY_HEALTH_URLS", "") t.Setenv("MISSION_CONTROL_CCIP_JSON", "") - s := &Server{} + s := &Server{ + freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) { + now := time.Now().UTC().Format(time.RFC3339) + head := int64(16) + txBlock := int64(12) + distance := int64(4) + return &freshness.Snapshot{ + ChainHead: freshness.Reference{ + BlockNumber: &head, + Timestamp: &now, + AgeSeconds: func() *int64 { v := int64(1); return &v }(), + Source: freshness.SourceReported, + Confidence: freshness.ConfidenceHigh, + Provenance: freshness.ProvenanceRPC, + Completeness: freshness.CompletenessComplete, + }, + LatestIndexedTransaction: freshness.Reference{ + BlockNumber: &txBlock, + Timestamp: &now, + AgeSeconds: func() *int64 { v := int64(120); return &v }(), + Source: freshness.SourceReported, + Confidence: freshness.ConfidenceHigh, + Provenance: freshness.ProvenanceTxIndex, + Completeness: freshness.CompletenessPartial, + }, + LatestNonEmptyBlock: freshness.Reference{ + BlockNumber: &txBlock, + Timestamp: &now, + AgeSeconds: func() *int64 { v := int64(120); return &v }(), + DistanceFromHead: &distance, + Source: freshness.SourceReported, + Confidence: freshness.ConfidenceHigh, + Provenance: freshness.ProvenanceTxIndex, + Completeness: freshness.CompletenessPartial, + }, + }, + &freshness.SummaryCompleteness{ + TransactionsFeed: freshness.CompletenessPartial, + BlocksFeed: freshness.CompletenessComplete, + }, + &freshness.Sampling{StatsGeneratedAt: &now}, + nil + }, + } got := s.BuildBridgeStatusData(context.Background()) ccip, ok := got["ccip_relay"].(map[string]interface{}) require.True(t, ok) @@ -156,6 +200,9 @@ func TestBuildBridgeStatusDataIncludesCCIPRelay(t *testing.T) { probe, ok := ccip["url_probe"].(map[string]interface{}) require.True(t, ok) require.Equal(t, true, probe["ok"]) + require.Contains(t, got, "freshness") + require.Contains(t, got, "subsystems") + require.Contains(t, got, "mode") } func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) { @@ -197,7 +244,11 @@ func TestBuildBridgeStatusDataDegradesWhenNamedRelayFails(t *testing.T) { t.Setenv("MISSION_CONTROL_CCIP_JSON", "") t.Setenv("CCIP_RELAY_HEALTH_URLS", "mainnet="+mainnet.URL+"/healthz,bsc="+bad.URL+"/healthz") - s := &Server{} + s := &Server{ + freshnessLoader: func(context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) { + return nil, nil, nil, nil + }, + } got := s.BuildBridgeStatusData(context.Background()) require.Equal(t, "degraded", got["status"]) } diff --git a/backend/api/track1/endpoints.go b/backend/api/track1/endpoints.go index b5bcb2c..5c30420 100644 --- a/backend/api/track1/endpoints.go +++ b/backend/api/track1/endpoints.go @@ -12,6 +12,7 @@ import ( "time" "github.com/ethereum/go-ethereum/common" + "github.com/explorer/backend/api/freshness" "github.com/explorer/backend/libs/go-rpc-gateway" ) @@ -19,13 +20,18 @@ var track1HashPattern = regexp.MustCompile(`^0x[a-fA-F0-9]{64}$`) // Server handles Track 1 endpoints (uses RPC gateway from lib) type Server struct { - rpcGateway *gateway.RPCGateway + rpcGateway *gateway.RPCGateway + freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error) } // NewServer creates a new Track 1 server -func NewServer(rpcGateway *gateway.RPCGateway) *Server { +func NewServer( + rpcGateway *gateway.RPCGateway, + freshnessLoader func(ctx context.Context) (*freshness.Snapshot, *freshness.SummaryCompleteness, *freshness.Sampling, error), +) *Server { return &Server{ - rpcGateway: rpcGateway, + rpcGateway: rpcGateway, + freshnessLoader: freshnessLoader, } } diff --git a/deployment/common/nginx-api-location.conf b/deployment/common/nginx-api-location.conf index d01e188..22fa3d8 100644 --- a/deployment/common/nginx-api-location.conf +++ b/deployment/common/nginx-api-location.conf @@ -1,5 +1,22 @@ # Generic snippet: proxy /api/ to a backend (Blockscout, Go API, etc.) # Include in your server block. Replace upstream host/port as needed. +# +# Keep the exact /api/v2/stats route on the Go-side explorer API when you need +# enriched freshness/completeness metadata while the rest of /api/v2/* stays on +# the Blockscout upstream. + +location = /api/v2/stats { + proxy_pass http://127.0.0.1:8081/api/v2/stats; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 60s; + add_header Access-Control-Allow-Origin *; + add_header Access-Control-Allow-Methods "GET, POST, OPTIONS"; + add_header Access-Control-Allow-Headers "Content-Type"; +} location /api/ { proxy_pass http://127.0.0.1:4000; diff --git a/docs/API_ERRORS_FIX.md b/docs/API_ERRORS_FIX.md index 9fbb0d4..3cf5f5a 100644 --- a/docs/API_ERRORS_FIX.md +++ b/docs/API_ERRORS_FIX.md @@ -1,5 +1,10 @@ # API Errors Fix +> Historical note: this file documents legacy static-SPA fixes and deploy +> patterns. The canonical live frontend deployment is now +> `./scripts/deploy-next-frontend-to-vmid5000.sh`. Treat manual `index.html` +> copy steps here as compatibility history, not the primary operator path. + ## Issues Fixed ### 1. `createSkeletonLoader is not defined` Error @@ -54,12 +59,12 @@ bash explorer-monorepo/scripts/deploy-frontend-fix.sh #### Option 1: Using Deployment Script (from Proxmox host) ```bash cd /home/intlc/projects/proxmox/explorer-monorepo -bash scripts/deploy-frontend-to-vmid5000.sh +bash scripts/deploy-next-frontend-to-vmid5000.sh ``` #### Option 2: Manual Deployment (from VMID 5000) ```bash -# On VMID 5000, copy the file: +# Historical static-SPA compatibility only: cp /path/to/explorer-monorepo/frontend/public/index.html /var/www/html/index.html chown www-data:www-data /var/www/html/index.html @@ -69,6 +74,7 @@ nginx -t && systemctl restart nginx #### Option 3: Using SCP (from local machine) ```bash +# Historical static-SPA compatibility only: scp explorer-monorepo/frontend/public/index.html root@192.168.11.140:/var/www/html/index.html ssh root@192.168.11.140 "chown www-data:www-data /var/www/html/index.html && nginx -t && systemctl restart nginx" ``` @@ -113,4 +119,3 @@ Test the following scenarios: - For ChainID 138, all API calls now use Blockscout REST API format - Error handling includes retry buttons for better UX - Skeleton loaders provide visual feedback during data loading - diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 29b3d3d..504fd2e 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -29,10 +29,10 @@ inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/* ### Legacy Static Deploy -```bash -# From explorer-monorepo root -./scripts/deploy.sh -``` +The historical static SPA deploy path is deprecated. The old scripts +`./scripts/deploy.sh` and `./scripts/deploy-frontend-to-vmid5000.sh` now fail +fast with a deprecation message and intentionally point operators back to the +canonical Next.js deploy path. ### Manual Deploy @@ -40,21 +40,25 @@ inside the explorer server block after `/api`, `/api/config/*`, `/explorer-api/* # Canonical Next deployment: ./scripts/deploy-next-frontend-to-vmid5000.sh -# Legacy static fallback only: -scp frontend/public/index.html root@192.168.11.140:/var/www/html/index.html +# Compatibility assets only: +# frontend/public/index.html +# frontend/public/explorer-spa.js ``` ### Environment Variables -The deployment script uses these environment variables: +The canonical Next deployment script uses its own VM/host defaults and deploy +workflow. Prefer reviewing `scripts/deploy-next-frontend-to-vmid5000.sh` +directly instead of relying on the older static script env contract below. + +Historical static-script environment variables: - `IP`: Production server IP (default: 192.168.11.140) - `DOMAIN`: Domain name (default: explorer.d-bis.org) - `PASSWORD`: SSH password (default: L@kers2010) -```bash -IP=192.168.11.140 DOMAIN=explorer.d-bis.org ./scripts/deploy.sh -``` +These applied to the deprecated static deploy script and are no longer the +recommended operator interface. ## Mission-control and Track 4 runtime wiring @@ -89,7 +93,7 @@ If deployment fails, rollback to previous version: ```bash ssh root@192.168.11.140 -cp /var/www/html/index.html.backup.* /var/www/html/index.html +ls -1dt /opt/solacescanscout/frontend/releases/* | head ``` For the Next standalone path, restart the previous release by repointing diff --git a/docs/EXPLORER_API_ACCESS.md b/docs/EXPLORER_API_ACCESS.md index a98fe94..f487c44 100644 --- a/docs/EXPLORER_API_ACCESS.md +++ b/docs/EXPLORER_API_ACCESS.md @@ -1,5 +1,9 @@ # Explorer API Access – Checklist and Fixes +> Mixed-era note: this document spans both the current Next frontend and older +> static-SPA CSP/deploy history. The canonical frontend deployment path is now +> `./scripts/deploy-next-frontend-to-vmid5000.sh`. + The frontend is reachable at **https://explorer.d-bis.org** (FQDN) or by **VM IP** (**http://192.168.11.140**). In both cases it needs the **Blockscout v2 API** at the same origin under `/api/`. If you see **502 Bad Gateway**, **no blocks/transactions feeds**, or "Failed to load", the API may be unreachable. Use this checklist to verify and restore access. **See also:** [EXPLORER_API_REFERENCE.md](EXPLORER_API_REFERENCE.md) for the list of Blockscout v2 endpoints used by the frontend. @@ -135,7 +139,7 @@ The ethers.js v5 UMD bundle from the CDN uses `eval`/`new Function()` for ABI de If the browser still reports **“Content Security Policy blocks the use of 'eval'”** or **script-src blocked**: -1. **Redeploy the frontend** so the live site gets the current `index.html` (with the meta CSP including `'unsafe-eval'`). For VMID 5000, run **`scripts/deploy-frontend-to-vmid5000.sh`** (frontend-only) or **`scripts/complete-explorer-api-access.sh`** (full). Alternatively, copy `frontend/public/index.html` to the server’s web root (e.g. `/var/www/html/`). +1. **Redeploy the frontend** so the live site gets the current frontend bundle and nginx/API wiring. For VMID 5000, run **`scripts/deploy-next-frontend-to-vmid5000.sh`** for the canonical frontend path or **`scripts/complete-explorer-api-access.sh`** for the broader API/nginx fix flow. References to `frontend/public/index.html` below are historical static-SPA compatibility details. 2. **Check what CSP the browser sees** – DevTools → Network → select the document request (the HTML page) → Headers → **Response Headers** → `Content-Security-Policy`. It should contain `'unsafe-eval'` in `script-src`. If the response has a CSP header **without** `'unsafe-eval'`, that header is coming from your server (nginx or app) or from a proxy (e.g. Cloudflare). Update the config that serves the explorer so its CSP includes `'unsafe-eval'`, then reload (hard refresh or incognito). 3. **If you use Cloudflare** – In the dashboard, check Transform Rules, Page Rules, or Security → Settings for any **Content-Security-Policy** (or similar) header that might override the origin. Ensure that header’s `script-src` includes `'unsafe-eval'`, or remove the override so the origin CSP is used. diff --git a/docs/EXPLORER_CODE_REVIEW.md b/docs/EXPLORER_CODE_REVIEW.md index e957901..f0b67c4 100644 --- a/docs/EXPLORER_CODE_REVIEW.md +++ b/docs/EXPLORER_CODE_REVIEW.md @@ -1,5 +1,10 @@ # Explorer Code Review +> Historical architecture snapshot: this review reflects a mixed Next.js + +> legacy static-SPA period. The live frontend is now the Next standalone app, +> while `frontend/public/index.html` and `frontend/public/explorer-spa.js` +> remain compatibility/reference assets only. + **Date:** 2025-02 **Scope:** Backend (Go), Frontend (Next.js + SPA), libs, deployment, CI. @@ -11,8 +16,8 @@ |-------|------|--------| | **API** | Go 1.22, net/http | REST API (blocks, transactions, addresses, search, stats, Etherscan compat, auth, feature flags). Tiered tracks (1–4) with optional/required auth. | | **Indexer** | Go, go-ethereum, pgx | Listens to chain (RPC/WS), processes blocks/txs, writes to PostgreSQL. | -| **Frontend (live)** | Vanilla JS SPA | `frontend/public/index.html` — single HTML + inline script, deployed at https://explorer.d-bis.org. Uses Blockscout-style API, ethers.js from CDN, VMID 2201 RPC. | -| **Frontend (dev)** | Next.js 15, React, TypeScript | `frontend/src/` — app + pages router, dev/build only; uses shared libs (api-client, ui-primitives). | +| **Frontend (live)** | Next.js 15, React, TypeScript | `frontend/src/` — standalone deployment on VMID 5000; uses shared libs and the explorer-owned freshness/trust model. | +| **Frontend (compatibility)** | Vanilla JS SPA | `frontend/public/index.html` + `frontend/public/explorer-spa.js` — retained for compatibility/reference, not the primary live deployment path. | | **Libs** | In-repo under `backend/libs/`, `frontend/libs/` | go-pgconfig, go-logging, go-chain-adapters, go-rpc-gateway, go-http-middleware, go-bridge-aggregator; frontend-api-client, frontend-ui-primitives. | --- @@ -161,7 +166,7 @@ |------|--------| | **Next.js workspace warning** | Done: Comment added in `frontend/next.config.js`; align package manager in frontend or ignore for dev/build. (Next 14 does not support `outputFileTracingRoot` in config; standalone trace uses project root.) | | **CORS** | Done: `CORS_ALLOWED_ORIGIN` env in `server.go`; default `*`, set to e.g. `https://explorer.d-bis.org` to restrict. Documented in `deployment/ENVIRONMENT_TEMPLATE.env`. | -| **SPA file size** | Done: main app script extracted to `frontend/public/explorer-spa.js` (~3.5k lines); `index.html` now ~1.15k lines. Deploy scripts copy `explorer-spa.js` (e.g. `deploy-frontend-to-vmid5000.sh`, `deploy.sh`). | +| **SPA file size** | Historical compatibility asset: main app script extracted to `frontend/public/explorer-spa.js` (~3.5k lines); `index.html` now ~1.15k lines. The old deploy scripts are deprecated shims rather than active operator paths. | | **SPA vs Next canonical** | Done: `README.md` states production serves the SPA, Next.js is for local dev and build validation only. | | **CSP unsafe-eval** | Done: comment in `index.html` CSP meta updated: "Can be removed when moving to ethers v6 build (no UMD eval)." | | **Further product work** | See `docs/EXPLORER_ADDITIONAL_RECOMMENDATIONS.md` (i18n, event log decoding, token list, health endpoint, etc.). | diff --git a/docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md b/docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md new file mode 100644 index 0000000..e5e82e4 --- /dev/null +++ b/docs/EXPLORER_DEADENDS_GAPS_ORPHANS_AUDIT_2026-04-11.md @@ -0,0 +1,154 @@ +# Explorer Dead-Ends, Gaps, and Orphans Audit + +Date: 2026-04-11 + +This audit records the remaining pruning surface after the frontend trust, +freshness, and deployment-path cleanup work. The goal is to distinguish +high-signal cleanup targets from compatibility or historical assets that should +not be deleted casually. + +## Canonical Live Paths + +- Frontend deploy: `scripts/deploy-next-frontend-to-vmid5000.sh` +- Frontend runtime: `solacescanscout-frontend.service` +- Shared freshness/trust model: + - `frontend/src/utils/explorerFreshness.ts` + - `frontend/src/components/common/FreshnessTrustNote.tsx` + - `frontend/src/components/common/ActivityContextPanel.tsx` +- Explorer-owned freshness backend: + - `backend/api/freshness/` + - `backend/api/rest/stats.go` + - `backend/api/track1/bridge_status_data.go` + +## Pruned in This Cleanup Series + +- Deprecated static deploy scripts now fail fast and point to the canonical + Next deploy path: + - `scripts/deploy-frontend-to-vmid5000.sh` + - `scripts/deploy.sh` +- Removed relay-summary compatibility helpers from: + - `frontend/src/services/api/missionControl.ts` +- Removed duplicate route action from: + - `frontend/src/data/explorerOperations.ts` +- Hardened deploy build-lock behavior in: + - `scripts/deploy-next-frontend-to-vmid5000.sh` + +## Dead-End Guidance Fixed + +The following docs were updated to stop presenting deprecated static frontend +deployment as a current operator path: + +- `docs/README.md` +- `docs/INDEX.md` +- `docs/DEPLOYMENT.md` +- `README_DEPLOYMENT.md` + +## Remaining Historical / Compatibility Assets To Keep For Now + +These are not current primary paths, but they still serve compatibility, +reference, or audit roles and should not be removed without a deliberate +migration decision: + +- `frontend/public/index.html` +- `frontend/public/explorer-spa.js` +- `frontend/public/chain138-command-center.html` +- `deployment/common/nginx-api-location.conf` + +## Remaining Gaps + +### 0. Static compatibility assets are not orphaned yet + +The following assets are still part of the runtime or deployment surface and +cannot be deleted safely in a pure pruning pass: + +- `frontend/public/index.html` +- `frontend/public/explorer-spa.js` +- `frontend/public/chain138-command-center.html` + +Current hard blockers: + +- canonical deploy script still copies them: + - `scripts/deploy-next-frontend-to-vmid5000.sh` +- live product still links the command center: + - `frontend/src/components/common/Navbar.tsx` + - `frontend/src/components/common/Footer.tsx` + - `frontend/src/data/explorerOperations.ts` + - `frontend/src/pages/docs/index.tsx` +- compatibility/runtime verification still expects them: + - `scripts/verify-explorer-api-access.sh` +- several legacy remediation scripts still push the static SPA to + `/var/www/html/index.html`: + - `scripts/deploy-frontend-fix.sh` + - `scripts/fix-explorer-remote.sh` + - `scripts/fix-explorer-complete.sh` + - `scripts/complete-explorer-api-access.sh` + +Recommendation: +- treat retirement of these assets as an explicit migration +- first decide whether the command center remains a supported public artifact +- then remove static-SPA push logic from the remediation scripts +- only after that delete the files and clean the remaining references + +### 1. Historical docs still describe the old static SPA as if it were primary + +These are not the best operator entry points anymore, but they appear to be +historical records, troubleshooting notes, or code-review artifacts rather than +active runbooks: + +- `docs/FRONTEND_DEPLOYMENT_FIX.md` +- `docs/FRONTEND_FIXES_COMPLETE.md` +- `docs/API_ERRORS_FIX.md` +- `docs/EXPLORER_LOADING_TROUBLESHOOTING.md` +- `docs/EXPLORER_API_ACCESS.md` +- `docs/EXPLORER_CODE_REVIEW.md` +- `docs/EXPLORER_FRONTEND_TESTING.md` +- `docs/STRUCTURE.md` +- `docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md` + +Recommendation: +- keep them for now +- a first banner-stamp sweep has already been applied to the highest-signal set +- only rewrite/delete them if we decide to retire the compatibility assets + +### 2. Compatibility assets still create pruning ambiguity + +The repo still contains both: +- the live Next frontend path +- the historical static SPA assets + +Recommendation: +- keep the compatibility assets until all docs and operators no longer depend on + them for rollback/reference +- when retired, remove the assets and do a repo-wide `frontend/public/index.html` + reference cleanup in one explicit migration + +### 3. Public routing ownership is still split + +Freshness truth is now much cleaner, but public route ownership still spans: +- Blockscout-owned public API behavior +- explorer-owned `track1` / mission-control behavior +- Next frontend presentation logic + +Recommendation: +- continue consolidating around the explorer-owned freshness contract +- treat backend source-of-truth wiring as the next cleanup frontier, not more + shell polish + +## Orphaned / Removed Compatibility Paths Confirmed Gone + +These frontend compatibility abstractions were fully removed and should not be +reintroduced: + +- `getRelaySummary` in `frontend/src/services/api/missionControl.ts` +- `subscribeRelaySummary` in `frontend/src/services/api/missionControl.ts` + +## Suggested Next Pruning Sweep + +1. Stamp the historical static-SPA docs above with a clear banner: + `Historical static-SPA guidance; not the canonical deployment path.` +2. Decide whether `frontend/public/index.html` and `frontend/public/explorer-spa.js` + still have an operational rollback role. +3. If not, remove them in one explicit migration and clean all remaining + references repo-wide. +4. After that, re-run the dead-end/orphan audit and remove the remaining + compatibility mentions from troubleshooting docs. diff --git a/docs/EXPLORER_FRONTEND_TESTING.md b/docs/EXPLORER_FRONTEND_TESTING.md index fbb3909..026aad9 100644 --- a/docs/EXPLORER_FRONTEND_TESTING.md +++ b/docs/EXPLORER_FRONTEND_TESTING.md @@ -1,10 +1,15 @@ # Explorer Frontend Testing +> Historical note: this testing note captures legacy static-SPA routing +> behavior during the explorer transition. The canonical live frontend is now +> the Next standalone app deployed with +> `./scripts/deploy-next-frontend-to-vmid5000.sh`. + ## Summary Path-based URLs (e.g. `/address/0x99b3511a2d315a497c8112c1fdd8d508d4b1e506`) now work on the explorer. The fix includes: -1. **SPA path-based routing** – `applyHashRoute()` in `frontend/public/index.html` reads both `pathname` and `hash`, so `/address/0x...`, `/tx/0x...`, `/block/123`, etc. load correctly. +1. **SPA path-based routing** – historically, `applyHashRoute()` in `frontend/public/index.html` read both `pathname` and `hash`, so `/address/0x...`, `/tx/0x...`, `/block/123`, etc. loaded correctly. 2. **Nginx SPA paths** – Nginx serves `index.html` for `/address`, `/tx`, `/block`, `/token`, `/blocks`, `/transactions`, `/bridge`, `/weth`, `/watchlist`, and `/nft`. 3. **HTTP + HTTPS** – Both HTTP (for internal tests) and HTTPS serve the SPA for these paths. diff --git a/docs/EXPLORER_LOADING_TROUBLESHOOTING.md b/docs/EXPLORER_LOADING_TROUBLESHOOTING.md index 745915a..50f4a03 100644 --- a/docs/EXPLORER_LOADING_TROUBLESHOOTING.md +++ b/docs/EXPLORER_LOADING_TROUBLESHOOTING.md @@ -1,5 +1,9 @@ # Explorer "Loading…" / "—" Troubleshooting +> Historical note: parts of this troubleshooting guide still refer to the old +> static-SPA deployment path. The current production frontend is the Next +> standalone app deployed with `./scripts/deploy-next-frontend-to-vmid5000.sh`. + When **`/api/v2/stats`** returns 200 with data but the SPA still shows "—" or "Loading blocks…" / "Loading transactions…" / "Loading bridge data…" / "Tokens: Loading…", the failure is in **frontend→API wiring** or **frontend runtime**. ## Expected UI (screenshots) @@ -71,13 +75,13 @@ After editing `frontend/public/explorer-spa.js`, redeploy the frontend to VMID 5 ```bash # From repo root (with SSH to r630-02) -EXPLORER_VM_HOST=root@192.168.11.12 bash explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh +EXPLORER_VM_HOST=root@192.168.11.12 bash explorer-monorepo/scripts/deploy-next-frontend-to-vmid5000.sh ``` Or from the Proxmox host that runs VMID 5000: ```bash -/path/to/repo/explorer-monorepo/scripts/deploy-frontend-to-vmid5000.sh +/path/to/repo/explorer-monorepo/scripts/deploy-next-frontend-to-vmid5000.sh ``` Then hard-refresh the explorer (Ctrl+Shift+R / Cmd+Shift+R) and re-check Console + Network. diff --git a/docs/FRONTEND_DEPLOYMENT_FIX.md b/docs/FRONTEND_DEPLOYMENT_FIX.md index 1b46af1..1c8074f 100644 --- a/docs/FRONTEND_DEPLOYMENT_FIX.md +++ b/docs/FRONTEND_DEPLOYMENT_FIX.md @@ -1,9 +1,15 @@ # Frontend Deployment Fix +> Historical note: this document describes the legacy static-SPA deployment +> path. The canonical live frontend deployment is now +> `./scripts/deploy-next-frontend-to-vmid5000.sh`. Keep this file only as +> compatibility/audit reference unless you are deliberately working on the old +> static assets. + ## Problem The explorer at `https://explorer.d-bis.org/` shows "Page not found" because: 1. Nginx is proxying to Blockscout (port 4000) which serves its own UI -2. The custom frontend (`explorer-monorepo/frontend/public/index.html`) is not deployed to `/var/www/html/` on VMID 5000 +2. The historical custom static frontend (`explorer-monorepo/frontend/public/index.html`) is not deployed to `/var/www/html/` on VMID 5000 ## Solution @@ -33,21 +39,21 @@ Deploy the custom frontend to `/var/www/html/index.html`: **From Proxmox host:** ```bash cd /home/intlc/projects/proxmox/explorer-monorepo -bash scripts/deploy-frontend-to-vmid5000.sh +bash scripts/deploy-next-frontend-to-vmid5000.sh ``` **Or manually from VMID 5000:** ```bash -# If you have access to the repo in VMID 5000 +# Historical static-SPA compatibility only: cp /home/intlc/projects/proxmox/explorer-monorepo/frontend/public/index.html /var/www/html/index.html chown www-data:www-data /var/www/html/index.html ``` **Or using SSH from Proxmox host:** ```bash -# Using existing deploy script +# Deprecated static deploy shim; kept only for historical compatibility context cd /home/intlc/projects/proxmox/explorer-monorepo -PASSWORD="L@kers2010" bash scripts/deploy.sh +bash scripts/deploy.sh ``` ### Step 3: Verify @@ -95,4 +101,3 @@ The updated nginx config: ## Files Modified - `/etc/nginx/sites-available/blockscout` - Nginx configuration - `/var/www/html/index.html` - Custom frontend (needs to be deployed) - diff --git a/docs/FRONTEND_FIXES_COMPLETE.md b/docs/FRONTEND_FIXES_COMPLETE.md index b041c9d..f45e856 100644 --- a/docs/FRONTEND_FIXES_COMPLETE.md +++ b/docs/FRONTEND_FIXES_COMPLETE.md @@ -1,5 +1,10 @@ # Frontend Errors - Complete Fix Summary +> Historical note: this fix summary was written against the older static-SPA +> frontend. The canonical live frontend is now the Next standalone app on VMID +> 5000, while `frontend/public/index.html` remains a compatibility/reference +> asset. + **Date**: $(date) **Status**: ✅ **ALL FIXES APPLIED** @@ -139,4 +144,3 @@ Fetching blocks from Blockscout: https://explorer.d-bis.org/api/v2/blocks?page=1 --- **Status**: ✅ All frontend errors have been fixed and tested. - diff --git a/docs/INDEX.md b/docs/INDEX.md index 8870d6c..4d66928 100644 --- a/docs/INDEX.md +++ b/docs/INDEX.md @@ -17,7 +17,7 @@ - **[../frontend/FRONTEND_REVIEW.md](../frontend/FRONTEND_REVIEW.md)** - Frontend code review (SPA + React) - **[../frontend/FRONTEND_TASKS_AND_REVIEW.md](../frontend/FRONTEND_TASKS_AND_REVIEW.md)** - Task list (C1–L4) and detail review -- **Deploy frontend:** From repo root run `./scripts/deploy-frontend-to-vmid5000.sh` +- **Deploy frontend:** From repo root run `./scripts/deploy-next-frontend-to-vmid5000.sh` - **Full fix (API + frontend):** `./scripts/complete-explorer-api-access.sh` --- @@ -198,4 +198,3 @@ --- **Last Updated:** 2025-02-09 - diff --git a/docs/README.md b/docs/README.md index d112551..ba43d27 100644 --- a/docs/README.md +++ b/docs/README.md @@ -29,11 +29,12 @@ Overview of documentation for the ChainID 138 Explorer (SolaceScan). Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggregation/api/v1/*`, `/snap/`, and `/health`, then proxy `/` and `/_next/` using [deployment/common/nginx-next-frontend-proxy.conf](/home/intlc/projects/proxmox/explorer-monorepo/deployment/common/nginx-next-frontend-proxy.conf). -**Legacy static SPA deploy (fallback only):** +**Legacy static SPA compatibility assets:** -```bash -./scripts/deploy-frontend-to-vmid5000.sh -``` +The historical static SPA (`frontend/public/index.html` + +`frontend/public/explorer-spa.js`) remains in-repo for compatibility and audit +reference only. The old static deploy scripts are deprecated shims and are not +supported deployment targets. **Full explorer/API fix (Blockscout + nginx + frontend):** @@ -61,4 +62,4 @@ Nginx should preserve `/api`, `/api/config/*`, `/explorer-api/*`, `/token-aggreg --- -*Last updated: 2025-02-09* +*Last updated: 2026-04-11* diff --git a/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md b/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md index 66a4363..9352809 100644 --- a/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md +++ b/docs/REUSABLE_COMPONENTS_EXTRACTION_PLAN.md @@ -1,5 +1,9 @@ # Reusable Components Extraction Plan +> Historical planning note: references in this file to the explorer SPA or old +> deploy scripts describe the pre-Next transition state. The canonical live +> frontend deployment is now `./scripts/deploy-next-frontend-to-vmid5000.sh`. + **Completion status (in-repo):** All libs are present under `backend/libs/` and `frontend/libs/`. Backend is wired to use **go-pgconfig** (API + indexer), **go-rpc-gateway** (Track 1). Frontend is wired to use **frontend-api-client** (services/api/client) and **frontend-ui-primitives** (all pages using Card, Table, Address). CI uses `submodules: recursive`; README documents clone with submodules. To publish as separate repos, copy each lib to its own repo and add as submodule. **Review and test:** Backend handlers that need the DB use `requireDB(w)`; without a DB they return 503. Tests run with a nil DB and accept 200/503/404 as appropriate. Run backend tests: `go test ./...` in `backend/`. Frontend build: `npm run build` in `frontend/` (ESLint uses root `.eslintrc.cjs` and frontend `"root": true` in `.eslintrc.json`). E2E: `npm run e2e` from repo root (Playwright, default base URL https://explorer.d-bis.org; set `EXPLORER_URL` for local). @@ -216,7 +220,7 @@ To minimize breakage and respect dependencies: - **Backend:** All REST route handlers, track* endpoint logic, indexer (listener + processor + backfill), Blockscout/etherscan compatibility, explorer-specific config (chain_id 138, RPC URLs), and migrations (schema stays here; libs use interfaces or config). - **Frontend:** All pages and views, `public/index.html` SPA, explorer API service modules (blocks, transactions, addresses), Next.js app and deployment config. -- **Deployment:** All explorer- and VMID 5000–specific scripts (`fix-502-blockscout.sh`, `complete-explorer-api-access.sh`, `deploy-frontend-to-vmid5000.sh`, etc.), and nginx/config that reference explorer.d-bis.org and Blockscout. +- **Deployment:** All explorer- and VMID 5000–specific scripts (`fix-502-blockscout.sh`, `complete-explorer-api-access.sh`, legacy `deploy-frontend-to-vmid5000.sh`, etc.), and nginx/config that reference explorer.d-bis.org and Blockscout. - **Docs:** All current documentation (API access, deployment, runbook, etc.). --- diff --git a/docs/STRUCTURE.md b/docs/STRUCTURE.md index 65c182c..9cc4f0b 100644 --- a/docs/STRUCTURE.md +++ b/docs/STRUCTURE.md @@ -1,13 +1,18 @@ # Monorepo Structure +> Historical note: this file still reflects the earlier static-SPA-oriented +> layout. The canonical live frontend is now the Next app in `frontend/src/`, +> deployed via `./scripts/deploy-next-frontend-to-vmid5000.sh`. + ## Directory Overview ### `/frontend` Frontend application code. -- **`public/`**: Static HTML, CSS, JavaScript files served directly - - `index.html`: Main explorer interface -- **`src/`**: Source files (if using build tools like webpack, vite, etc.) +- **`public/`**: Compatibility/reference static assets + - `index.html`: Historical static explorer interface + - `explorer-spa.js`: Historical extracted SPA script +- **`src/`**: Canonical Next.js frontend source - **`assets/`**: Images, fonts, and other static assets ### `/backend` @@ -19,7 +24,8 @@ Backend services (if needed for future enhancements). ### `/scripts` Deployment and utility scripts. -- **`deploy.sh`**: Deploy explorer to production +- **`deploy-next-frontend-to-vmid5000.sh`**: Canonical frontend deploy +- **`deploy.sh`**: Deprecated static deploy shim - **`test.sh`**: Test explorer functionality ### `/docs` @@ -65,7 +71,7 @@ explorer-monorepo/ ### Frontend Changes -1. Edit `frontend/public/index.html` directly (current approach) +1. Edit `frontend/src/` for the live frontend (current approach) 2. Or set up build tools in `frontend/src/` for compiled output ### Backend Changes @@ -77,4 +83,3 @@ explorer-monorepo/ 1. Add docs to `docs/` directory 2. Update README.md as needed - diff --git a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md index 2dcf0ec..a28097e 100644 --- a/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md +++ b/docs/TIERED_ARCHITECTURE_IMPLEMENTATION.md @@ -1,5 +1,9 @@ # Tiered Architecture Implementation Summary +> Historical note: this implementation summary was written during the +> static-SPA/Next transition. References to `frontend/public/index.html` +> describe legacy feature-gating work, not the canonical live frontend path. + ## Overview The SolaceScan Explorer has been successfully upgraded to a 4-track tiered architecture with feature-gated access control. @@ -41,7 +45,7 @@ All components have been implemented according to the plan: - **Security**: IP whitelist and audit logging integrated ### ✅ Phase 7: Frontend & Integration -- **Frontend Feature Gating**: Wallet connect UI and track-based feature visibility (`frontend/public/index.html`) +- **Frontend Feature Gating**: Wallet connect UI and track-based feature visibility (historically in `frontend/public/index.html`, now carried forward in the Next frontend shell) - **Route Integration**: Track-aware routing structure (`backend/api/rest/routes.go`) ## Architecture @@ -84,7 +88,7 @@ Backend - `backend/database/migrations/0010_track_schema.auth_only.sql` - shared Blockscout DB auth/operator subset ### Frontend -- Updated `frontend/public/index.html` with feature gating +- Updated the historical static SPA `frontend/public/index.html` with feature gating during the transition period ## Next Steps diff --git a/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md b/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md new file mode 100644 index 0000000..d2ba687 --- /dev/null +++ b/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md @@ -0,0 +1,530 @@ +# Explorer Freshness And Diagnostics Contract + +This document defines the minimum public freshness and diagnostics payloads the SolaceScan frontend needs in order to present chain activity, transaction visibility, and snapshot posture without relying on frontend inference. + +It is intended to close the remaining gap between: + +- a frontend that now renders and explains state honestly, and +- upstream APIs that still omit authoritative freshness metadata for several critical surfaces. + +## Goal + +The frontend should be able to answer these questions directly from public API fields: + +1. Is the chain head current? +2. When was the latest visible transaction indexed? +3. What is the latest non-empty block? +4. Is the homepage using a live feed, a snapshot, or mixed evidence? +5. Which subsystem is stale: RPC, indexing, relay monitoring, or stats? +6. Which values are reported directly vs inferred vs unavailable? + +The frontend should not have to infer these from a combination of: + +- `/api/v2/stats` +- `/api/v2/main-page/blocks` +- `/api/v2/main-page/transactions` +- `/explorer-api/v1/track1/bridge/status` + +unless there is no backend alternative. + +## Design Principles + +- Prefer explicit freshness fields over derived heuristics. +- Separate chain freshness from indexed-transaction freshness. +- Distinguish reported facts from inferred or partial facts. +- Make incompleteness first-class. +- Keep the contract calm and operational, not alarmist. + +## Proposed Public Endpoints + +Two additions are recommended. + +### 1. Extend `GET /api/v2/stats` + +This endpoint already feeds the homepage summary cards. It should become the authoritative public summary for chain freshness and indexed activity freshness. + +### 2. Extend `GET /explorer-api/v1/track1/bridge/status` + +This endpoint already powers Mission Control. It should expose snapshot/feed posture and subsystem freshness more directly. + +If backend implementation prefers separation, these fields may instead be exposed from a new endpoint: + +`GET /explorer-api/v1/track1/observability/freshness` + +The frontend does not require a separate endpoint as long as the fields below are available from a stable public contract. + +## Required Additions To `/api/v2/stats` + +### Current gaps + +The current `stats` payload gives totals, but it does not reliably expose: + +- latest indexed transaction timestamp +- latest non-empty block +- authoritative utilization freshness +- confidence/completeness metadata + +### Required fields + +```json +{ + "total_blocks": 3873353, + "total_transactions": 52391, + "total_addresses": 10294, + "latest_block": 3873353, + "average_block_time": 2000, + "gas_prices": { + "slow": 0.02, + "average": 0.03, + "fast": 0.05 + }, + "network_utilization_percentage": 0, + "transactions_today": 18, + + "freshness": { + "chain_head": { + "block_number": 3873353, + "timestamp": "2026-04-10T21:42:15Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_transaction": { + "hash": "0x...", + "block_number": 3858013, + "timestamp": "2026-04-10T12:31:05Z", + "age_seconds": 33070, + "source": "reported", + "confidence": "high" + }, + "latest_non_empty_block": { + "block_number": 3858013, + "timestamp": "2026-04-10T12:31:05Z", + "age_seconds": 33070, + "distance_from_head": 15340, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_block": { + "block_number": 3873353, + "timestamp": "2026-04-10T21:42:15Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + } + }, + + "completeness": { + "transactions_feed": "complete", + "blocks_feed": "complete", + "gas_metrics": "partial", + "utilization_metrics": "partial" + }, + + "sampling": { + "stats_generated_at": "2026-04-10T21:42:16Z", + "stats_window_seconds": 300, + "rpc_probe_at": "2026-04-10T21:42:15Z" + } +} +``` + +## Field Semantics + +### `freshness.chain_head` + +The latest chain head known from the authoritative public RPC or canonical head source. + +This is the answer to: + +- "Is the chain alive?" +- "Is head visibility current?" + +### `freshness.latest_indexed_transaction` + +The most recent transaction currently visible in the public indexed transaction feed. + +This is the answer to: + +- "How recent is the latest visible transaction?" + +### `freshness.latest_non_empty_block` + +The most recent indexed block containing one or more transactions. + +This is the answer to: + +- "Are head blocks empty because the chain is quiet?" +- "What is the last block with visible activity?" + +### `freshness.latest_indexed_block` + +The latest block successfully indexed into the explorer's public block dataset. + +This disambiguates: + +- current chain head +- current explorer indexed head + +### `completeness.*` + +Simple public-facing availability states for each summary subsystem: + +- `complete` +- `partial` +- `stale` +- `unavailable` + +These should not be interpreted as outage severity; they describe data completeness only. + +### `sampling.*` + +Metadata for when the summary itself was generated and what freshness window it represents. + +## Required Additions To Mission Control Payload + +Mission Control currently provides useful relay detail, but the homepage still infers snapshot scope and partial feed posture from surrounding evidence. + +### Required fields + +```json +{ + "data": { + "status": "degraded", + "checked_at": "2026-04-10T21:42:16Z", + "mode": { + "kind": "snapshot", + "updated_at": "2026-04-10T21:42:16Z", + "age_seconds": 1, + "reason": "live_homepage_stream_not_attached", + "scope": "relay_monitoring_homepage_card_only", + "source": "reported", + "confidence": "high" + }, + "subsystems": { + "rpc_head": { + "status": "operational", + "updated_at": "2026-04-10T21:42:15Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "tx_index": { + "status": "stale", + "updated_at": "2026-04-10T12:31:05Z", + "age_seconds": 33070, + "source": "reported", + "confidence": "high" + }, + "bridge_relay_monitoring": { + "status": "degraded", + "updated_at": "2026-04-10T21:42:16Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "stats_summary": { + "status": "partial", + "updated_at": "2026-04-10T21:42:16Z", + "age_seconds": 1, + "source": "reported", + "confidence": "medium" + } + } + } +} +``` + +## Required Enumerations + +These enums should be consistent across public surfaces. + +### Activity interpretation + +- `active` +- `quiet` +- `sparse_activity` +- `fresh_head_stale_tx_visibility` +- `limited_observability` + +This value should be emitted only when the backend can support it directly. Otherwise the frontend may continue to derive it as a presentation layer. + +### Data source confidence + +- `high` +- `medium` +- `low` +- `unknown` + +### Data origin + +- `reported` +- `inferred` +- `sampled` +- `unavailable` + +### Completeness + +- `complete` +- `partial` +- `stale` +- `unavailable` + +## Example Payloads + +These examples are intended to accelerate frontend/backend alignment by showing how the contract should represent common live states. + +### Example A: Healthy Live State + +```json +{ + "freshness": { + "chain_head": { + "block_number": 3874000, + "timestamp": "2026-04-10T22:10:14Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_block": { + "block_number": 3874000, + "timestamp": "2026-04-10T22:10:14Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_transaction": { + "hash": "0x...", + "block_number": 3873998, + "timestamp": "2026-04-10T22:10:10Z", + "age_seconds": 5, + "source": "reported", + "confidence": "high" + }, + "latest_non_empty_block": { + "block_number": 3873998, + "timestamp": "2026-04-10T22:10:10Z", + "age_seconds": 5, + "distance_from_head": 2, + "source": "reported", + "confidence": "high" + } + }, + "completeness": { + "transactions_feed": "complete", + "blocks_feed": "complete", + "gas_metrics": "complete", + "utilization_metrics": "complete" + } +} +``` + +### Example B: Quiet Chain But Current + +```json +{ + "freshness": { + "chain_head": { + "block_number": 3875000, + "timestamp": "2026-04-10T23:10:14Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_block": { + "block_number": 3875000, + "timestamp": "2026-04-10T23:10:14Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_transaction": { + "hash": "0x...", + "block_number": 3874902, + "timestamp": "2026-04-10T23:01:42Z", + "age_seconds": 512, + "source": "reported", + "confidence": "high" + }, + "latest_non_empty_block": { + "block_number": 3874902, + "timestamp": "2026-04-10T23:01:42Z", + "age_seconds": 512, + "distance_from_head": 98, + "source": "reported", + "confidence": "high" + } + }, + "activity_interpretation": "quiet" +} +``` + +### Example C: Fresh Head, Stale Transaction Visibility + +```json +{ + "freshness": { + "chain_head": { + "block_number": 3876000, + "timestamp": "2026-04-11T00:10:14Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_block": { + "block_number": 3875999, + "timestamp": "2026-04-11T00:10:12Z", + "age_seconds": 3, + "source": "reported", + "confidence": "high" + }, + "latest_indexed_transaction": { + "hash": "0x...", + "block_number": 3860660, + "timestamp": "2026-04-10T15:02:10Z", + "age_seconds": 32900, + "source": "reported", + "confidence": "high" + }, + "latest_non_empty_block": { + "block_number": 3860660, + "timestamp": "2026-04-10T15:02:10Z", + "age_seconds": 32900, + "distance_from_head": 15340, + "source": "reported", + "confidence": "high" + } + }, + "activity_interpretation": "fresh_head_stale_tx_visibility", + "completeness": { + "transactions_feed": "stale", + "blocks_feed": "complete", + "gas_metrics": "partial", + "utilization_metrics": "partial" + } +} +``` + +### Example D: Snapshot Mode State + +```json +{ + "data": { + "status": "degraded", + "checked_at": "2026-04-11T00:10:15Z", + "mode": { + "kind": "snapshot", + "updated_at": "2026-04-11T00:10:15Z", + "age_seconds": 1, + "reason": "live_homepage_stream_not_attached", + "scope": "relay_monitoring_homepage_card_only", + "source": "reported", + "confidence": "high" + }, + "subsystems": { + "rpc_head": { + "status": "operational", + "updated_at": "2026-04-11T00:10:14Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + }, + "tx_index": { + "status": "stale", + "updated_at": "2026-04-10T15:02:10Z", + "age_seconds": 32900, + "source": "reported", + "confidence": "high" + }, + "bridge_relay_monitoring": { + "status": "degraded", + "updated_at": "2026-04-11T00:10:15Z", + "age_seconds": 1, + "source": "reported", + "confidence": "high" + } + } + } +} +``` + +## Frontend Usage Rules + +Once the fields above exist, the frontend should follow these rules: + +1. Use backend freshness fields directly where present. +2. Stop deriving latest transaction age from the transactions page feed when `freshness.latest_indexed_transaction` is available. +3. Stop deriving last non-empty block from recent block scanning when `freshness.latest_non_empty_block` is available. +4. Use `mode.kind`, `mode.reason`, and `mode.scope` directly for homepage snapshot messaging. +5. Use `source` and `confidence` badges only where they improve trust and do not clutter. + +## Backward-Compatible Rollout Plan + +### Phase A + +Add fields without removing any current keys: + +- extend `/api/v2/stats` +- extend bridge status payload with `mode` and `subsystems` + +### Phase B + +Frontend prefers new fields when available and falls back to inference when absent. + +### Phase C + +Once fields are consistently present in production: + +- reduce frontend inference paths +- remove duplicate explanatory fallback logic where it is no longer needed + +## Minimum Viable Backend Implementation + +If full rollout is not possible immediately, the minimum high-leverage addition is: + +### `/api/v2/stats` + +- `freshness.chain_head` +- `freshness.latest_indexed_transaction` +- `freshness.latest_non_empty_block` +- `sampling.stats_generated_at` + +### `/explorer-api/v1/track1/bridge/status` + +- `mode.kind` +- `mode.updated_at` +- `mode.reason` +- `mode.scope` + +That alone would materially reduce frontend ambiguity. + +## Why This Contract Matters + +The frontend now presents state honestly enough that the remaining ambiguity is no longer visual. It is contractual. + +Without these fields, the UI must keep inferring: + +- whether the chain is quiet or stale +- whether the homepage is in snapshot mode because of relay posture or indexing posture +- whether low activity is real or a visibility gap + +With these fields, the product becomes: + +- more trustworthy +- easier to evaluate externally +- less likely to be misread as broken + +## Summary + +The next backend milestone is not broad API expansion. It is a targeted public freshness contract. + +The public explorer needs explicit answers for: + +- current chain head +- current indexed head +- latest visible transaction +- last non-empty block +- snapshot/feed mode +- subsystem freshness/completeness + +That is the smallest backend addition with the highest frontend trust impact. diff --git a/docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md b/docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md new file mode 100644 index 0000000..cef81d0 --- /dev/null +++ b/docs/api/EXPLORER_FRESHNESS_IMPLEMENTATION_CHECKLIST.md @@ -0,0 +1,278 @@ +# Explorer Freshness Implementation Checklist + +This checklist converts the freshness contract into a backend implementation plan against the current SolaceScan code paths: + +- [stats.go](/home/intlc/projects/proxmox/explorer-monorepo/backend/api/rest/stats.go) +- [mission_control.go](/home/intlc/projects/proxmox/explorer-monorepo/backend/api/rest/mission_control.go) + +Use this document as the handoff from frontend trust requirements to backend delivery. + +See also: + +- [EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md) +- [track-api-contracts.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/track-api-contracts.md) + +## Scope + +This checklist covers four buckets: + +1. field ownership and source of truth +2. response-shape rollout +3. freshness semantics +4. confidence and completeness behavior + +## Bucket 1: Field Ownership And Source Of Truth + +| Field | Endpoint | Backend owner | Source of truth | Directly measured or derived | Cadence | Nullable | Frontend dependency | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `freshness.chain_head.block_number` | `/api/v2/stats` | `stats.go` with RPC helper | authoritative public RPC head | directly measured | per stats request or short cache | no | homepage head freshness, blocks/trust cues | +| `freshness.chain_head.timestamp` | `/api/v2/stats` | `stats.go` with RPC helper | authoritative public RPC head block timestamp | directly measured | per stats request or short cache | no | head age, chain visibility | +| `freshness.latest_indexed_block.block_number` | `/api/v2/stats` | `stats.go` | explorer DB `MAX(blocks.number)` | directly measured | per stats request | no | distinguish head vs indexed head | +| `freshness.latest_indexed_block.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed block timestamp | directly measured | per stats request | yes until wired | detail-page trust cues | +| `freshness.latest_indexed_transaction.hash` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | activity summary | +| `freshness.latest_indexed_transaction.block_number` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | tx freshness explanation | +| `freshness.latest_indexed_transaction.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest indexed tx row | directly measured | per stats request | yes | tx age, stale tx visibility | +| `freshness.latest_non_empty_block.block_number` | `/api/v2/stats` | `stats.go` | explorer DB latest block where `transaction_count > 0` or equivalent join | derived from indexed block/tx data | per stats request | yes | quiet-chain vs stale-visibility interpretation | +| `freshness.latest_non_empty_block.timestamp` | `/api/v2/stats` | `stats.go` | explorer DB latest non-empty block row | derived from indexed block/tx data | per stats request | yes | recent activity framing | +| `freshness.latest_non_empty_block.distance_from_head` | `/api/v2/stats` | `stats.go` | computed from chain head minus last non-empty block | derived | per stats request | yes | homepage block-gap explanation | +| `completeness.transactions_feed` | `/api/v2/stats` | `stats.go` | comparison of tx freshness vs head freshness | derived | per stats request | no | trust badges | +| `completeness.blocks_feed` | `/api/v2/stats` | `stats.go` | indexed block freshness vs chain head freshness | derived | per stats request | no | trust badges | +| `completeness.gas_metrics` | `/api/v2/stats` | `stats.go` | gas fields presence and quality | derived | per stats request | no | gas card honesty | +| `completeness.utilization_metrics` | `/api/v2/stats` | `stats.go` | utilization field presence and quality | derived | per stats request | no | utilization card honesty | +| `sampling.stats_generated_at` | `/api/v2/stats` | `stats.go` | server clock at response generation | directly measured | per response | no | “updated” copy | +| `sampling.rpc_probe_at` | `/api/v2/stats` | `stats.go` | latest successful RPC sample timestamp | directly measured or nullable | per stats request | yes | source confidence | +| `mode.kind` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | mission-control feed mode | directly measured if known, otherwise derived conservatively | per response / SSE tick | no | snapshot/live messaging | +| `mode.updated_at` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | mission-control snapshot timestamp | directly measured | per response | no | snapshot age | +| `mode.reason` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | bridge/homepage mode controller | directly measured if available, else nullable | per response | yes | scope explanation | +| `mode.scope` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | bridge/homepage mode controller | directly measured if available, else nullable | per response | yes | “what is affected?” | +| `subsystems.rpc_head.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | RPC probe result | directly measured | per response | no | mission-control trust cues | +| `subsystems.tx_index.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` using stats freshness or shared helper | explorer DB tx freshness | derived from authoritative indexed data | per response / shared cache | yes | homepage stale-tx explanation | +| `subsystems.bridge_relay_monitoring.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` | existing relay probe payload | directly measured | per response | no | lane posture | +| `subsystems.stats_summary.*` | `/explorer-api/v1/track1/bridge/status` | `mission_control.go` or shared summary helper | stats freshness sample | derived | per response | yes | homepage summary confidence | + +## Bucket 2: Response-Shape Rollout + +### Ship immediately as nullable additions + +These are low-risk additive fields that can be introduced without breaking existing clients. + +- `freshness.latest_indexed_transaction.*` +- `freshness.latest_non_empty_block.*` +- `freshness.latest_indexed_block.timestamp` +- `sampling.stats_generated_at` +- `sampling.rpc_probe_at` +- `mode.kind` +- `mode.updated_at` +- `mode.reason` +- `mode.scope` +- `subsystems.*` + +### Ship after backend wiring + +These need real data acquisition or shared helpers. + +- `freshness.chain_head.*` +- `completeness.transactions_feed` +- `completeness.blocks_feed` +- `completeness.gas_metrics` +- `completeness.utilization_metrics` + +### Derived computations + +These may be computed in backend code once the authoritative inputs exist. + +- `freshness.latest_non_empty_block.distance_from_head` +- subsystem `status` +- completeness enums +- optional `activity_interpretation` + +### Frontend adoption order + +1. Prefer new fields when present. +2. Fall back to current inference when absent. +3. Remove inference once fields are stable in production. + +## Bucket 3: Freshness Semantics + +Each field must answer a precise question. + +### `freshness.chain_head` + +- Meaning: latest chain head observed from the authoritative public RPC +- Must not mean: latest indexed explorer block +- If unknown: return `null` object members where needed plus completeness/confidence state + +### `freshness.latest_indexed_block` + +- Meaning: latest block successfully indexed into the explorer DB or visible explorer block source +- Must not mean: latest RPC head + +### `freshness.latest_indexed_transaction` + +- Meaning: latest transaction currently visible in the public indexed transaction feed +- Must not mean: latest mempool event or latest raw RPC tx if not visible in the explorer feed + +### `freshness.latest_non_empty_block` + +- Meaning: latest indexed block containing at least one visible indexed transaction +- This is the critical disambiguator for quiet-chain vs stale-visibility interpretation + +### `mode.kind` + +- Meaning: the current homepage/mission-control delivery mode +- Allowed values: `live`, `snapshot`, `mixed`, `unknown` + +### `mode.scope` + +- Meaning: which user-visible surface is affected by mode choice +- Examples: + - `relay_monitoring_homepage_card_only` + - `homepage_summary_only` + - `bridge_monitoring_and_homepage` + +### `mode.reason` + +- Meaning: why snapshot or mixed mode is active +- Must be calm and operational, not blame-oriented +- Examples: + - `live_homepage_stream_not_attached` + - `relay_snapshot_only_source` + - `partial_observability_inputs` + +### `subsystems.*` + +- Meaning: freshness of each component, not overall product health +- Recommended subsystem keys: + - `rpc_head` + - `tx_index` + - `bridge_relay_monitoring` + - `stats_summary` + +## Bucket 4: Confidence And Completeness + +Every nullable or derived field should have explicit semantics. + +### Confidence + +- `high`: authoritative source and recent sample +- `medium`: authoritative source but partially stale, or a stable derived value from strong inputs +- `low`: weakly derived or missing one of the underlying inputs +- `unknown`: no basis to express confidence + +### Completeness + +- `complete`: field is current and supported by recent source data +- `partial`: field exists but some required inputs are missing or weak +- `stale`: field is known, but the latest available value is older than acceptable freshness +- `unavailable`: no trustworthy value exists + +### Null and zero handling + +- Unknown must be `null`, not synthetic `0` +- Zero may be returned only when zero is a real measured value +- If a value is null, a sibling completeness/confidence field must explain why + +## Acceptance Tests + +These should be implemented in backend tests and used as rollout gates. + +### 1. Current head, stale tx visibility + +If chain head is current but tx visibility is stale: + +- `freshness.chain_head` must be current +- `freshness.latest_indexed_transaction` must be older +- `freshness.latest_non_empty_block` must be exposed +- completeness must not report all feeds as `complete` + +### 2. Quiet chain, current visibility + +If recent head blocks are genuinely empty: + +- `freshness.chain_head` must still be current +- `freshness.latest_non_empty_block` must be present +- `freshness.latest_indexed_transaction` must be present +- API must not force a stale diagnosis if visibility itself is current + +### 3. Snapshot mode active + +If snapshot mode is active: + +- `mode.kind` must be `snapshot` or `mixed` +- `mode.scope` must state what is affected +- `mode.reason` must be present if known + +### 4. Unknown fields + +If a field is unknown: + +- return `null` +- expose confidence/completeness state +- do not return fake zero values + +## Backend Implementation Checklist + +### `stats.go` + +- [ ] Extend `explorerStats` with nullable freshness/completeness/sampling fields. +- [ ] Add query/helper for latest indexed transaction. +- [ ] Add query/helper for latest non-empty block. +- [ ] Add query/helper for latest indexed block timestamp. +- [ ] Add RPC helper for current chain head number and timestamp. +- [ ] Compute `distance_from_head` when both chain head and latest non-empty block are present. +- [ ] Compute completeness enums for blocks, transactions, gas metrics, and utilization. +- [ ] Return `null` for unknowns rather than synthetic zero values. +- [ ] Add internal tests covering: + - healthy current state + - quiet-chain state + - stale tx visibility state + - null/unknown field handling + +### `mission_control.go` + +- [ ] Extend bridge status response with `mode`. +- [ ] Extend bridge status response with `subsystems`. +- [ ] Reuse or call shared freshness helper for tx index freshness rather than duplicating logic. +- [ ] Emit `mode.scope` and `mode.reason` only when backend can support them. +- [ ] Use `unknown` or nullable values when reason/scope cannot be stated authoritatively. +- [ ] Add tests covering: + - live mode + - snapshot mode + - mixed mode + - tx index stale while RPC head remains current + +### Shared rollout + +- [ ] Frontend reads new fields opportunistically. +- [ ] Existing frontend inference remains as fallback until backend fields are stable. +- [ ] Swagger/OpenAPI docs updated after implementation. +- [ ] Public docs updated only after payload shape is live. + +## Test Coverage Guidance + +For every field, capture: + +- who computes it +- from what source +- at what cadence +- whether nullable or required +- fallback behavior +- confidence/completeness semantics +- frontend dependency +- backend test case name + +That metadata is more important than perfect initial coverage breadth. + +## Shortest Path To Value + +If the team wants the fastest possible trust win, implement these first: + +1. `freshness.chain_head` +2. `freshness.latest_indexed_transaction` +3. `freshness.latest_non_empty_block` +4. `sampling.stats_generated_at` +5. `mode.kind` +6. `mode.scope` +7. `mode.reason` + +That is the minimum set that lets the frontend stop guessing about the most visible freshness ambiguity. diff --git a/docs/api/track-api-contracts.md b/docs/api/track-api-contracts.md index d8817fb..b74bed5 100644 --- a/docs/api/track-api-contracts.md +++ b/docs/api/track-api-contracts.md @@ -1,5 +1,7 @@ # Track API Contracts +See also: [EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md](/home/intlc/projects/proxmox/explorer-monorepo/docs/api/EXPLORER_FRESHNESS_DIAGNOSTICS_CONTRACT.md) for the public freshness and observability fields required by the current SolaceScan frontend. + Complete API contract definitions for all 4 tracks of SolaceScan Explorer. ## Track 1: Public Meta Explorer (No Auth Required) diff --git a/frontend/src/components/common/ActivityContextPanel.tsx b/frontend/src/components/common/ActivityContextPanel.tsx new file mode 100644 index 0000000..641fa43 --- /dev/null +++ b/frontend/src/components/common/ActivityContextPanel.tsx @@ -0,0 +1,137 @@ +import Link from 'next/link' +import { Card } from '@/libs/frontend-ui-primitives' +import EntityBadge from '@/components/common/EntityBadge' +import type { ChainActivityContext } from '@/utils/activityContext' +import { formatRelativeAge, formatTimestamp } from '@/utils/format' +import { Explain, useUiMode } from './UiModeContext' + +function resolveTone(state: ChainActivityContext['state']): 'success' | 'warning' | 'neutral' { + switch (state) { + case 'active': + return 'success' + case 'low': + case 'inactive': + return 'warning' + default: + return 'neutral' + } +} + +function resolveLabel(state: ChainActivityContext['state']): string { + switch (state) { + case 'active': + return 'active' + case 'low': + return 'low activity' + case 'inactive': + return 'inactive' + default: + return 'unknown' + } +} + +function renderHeadline(context: ChainActivityContext): string { + if (context.transaction_visibility_unavailable) { + return 'Transaction index freshness is currently unavailable, while chain-head visibility remains live.' + } + if (context.state === 'unknown') { + return 'Recent activity context is temporarily unavailable.' + } + if (context.state === 'active') { + return 'Recent transactions are close to the visible chain tip.' + } + if (context.head_is_idle) { + return 'The chain head is advancing, but the latest visible transaction is older than the current tip.' + } + return 'Recent transaction activity is sparse right now.' +} + +export default function ActivityContextPanel({ + context, + title = 'Chain Activity Context', +}: { + context: ChainActivityContext + title?: string +}) { + const { mode } = useUiMode() + const tone = resolveTone(context.state) + const dualTimelineLabel = + context.latest_block_timestamp && context.latest_transaction_timestamp + ? `${formatRelativeAge(context.latest_block_timestamp)} head · ${formatRelativeAge(context.latest_transaction_timestamp)} latest tx` + : 'Dual timeline unavailable' + + return ( + +
+
+
+
{renderHeadline(context)}
+ +

+ Use the transaction tip and last non-empty block below to distinguish a quiet chain from a broken explorer. +

+
+
+ +
+ +
+
+
Latest Block
+
+ {context.latest_block_number != null ? `#${context.latest_block_number}` : 'Unknown'} +
+
+ {formatRelativeAge(context.latest_block_timestamp)} +
+
+
+
Latest Transaction
+
+ {context.latest_transaction_block_number != null ? `#${context.latest_transaction_block_number}` : 'Unknown'} +
+
+ {formatRelativeAge(context.latest_transaction_timestamp)} +
+
+
+
Last Non-Empty Block
+
+ {context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number}` : 'Unknown'} +
+
+ {formatRelativeAge(context.last_non_empty_block_timestamp)} +
+
+
+
Block Gap
+
+ {context.block_gap_to_latest_transaction != null ? context.block_gap_to_latest_transaction.toLocaleString() : 'Unknown'} +
+
+ {mode === 'guided' + ? 'Difference between the current tip and the latest visible transaction block.' + : dualTimelineLabel} +
+
+
+ +
+ {context.latest_transaction_block_number != null ? ( + + Open latest transaction block → + + ) : null} + {context.last_non_empty_block_number != null ? ( + + Open last non-empty block → + + ) : null} + {context.latest_transaction_timestamp ? ( + Latest visible transaction time: {formatTimestamp(context.latest_transaction_timestamp)} + ) : null} +
+
+
+ ) +} diff --git a/frontend/src/components/common/BrandLockup.tsx b/frontend/src/components/common/BrandLockup.tsx new file mode 100644 index 0000000..1643142 --- /dev/null +++ b/frontend/src/components/common/BrandLockup.tsx @@ -0,0 +1,27 @@ +import BrandMark from './BrandMark' + +export default function BrandLockup({ compact = false }: { compact?: boolean }) { + return ( + <> + + + + SolaceScan + + + Chain 138 Explorer by DBIS + + + + ) +} diff --git a/frontend/src/components/common/BrandMark.tsx b/frontend/src/components/common/BrandMark.tsx new file mode 100644 index 0000000..59049b6 --- /dev/null +++ b/frontend/src/components/common/BrandMark.tsx @@ -0,0 +1,45 @@ +export default function BrandMark({ size = 'default' }: { size?: 'default' | 'compact' }) { + const containerClassName = + size === 'compact' + ? 'h-10 w-10 rounded-xl' + : 'h-11 w-11 rounded-2xl' + const iconClassName = size === 'compact' ? 'h-6 w-6' : 'h-7 w-7' + + return ( + + + + + + + + + + ) +} diff --git a/frontend/src/components/common/ExplorerChrome.tsx b/frontend/src/components/common/ExplorerChrome.tsx index 7a0b9d6..ca92c6b 100644 --- a/frontend/src/components/common/ExplorerChrome.tsx +++ b/frontend/src/components/common/ExplorerChrome.tsx @@ -2,22 +2,25 @@ import type { ReactNode } from 'react' import Navbar from './Navbar' import Footer from './Footer' import ExplorerAgentTool from './ExplorerAgentTool' +import { UiModeProvider } from './UiModeContext' export default function ExplorerChrome({ children }: { children: ReactNode }) { return ( -
- - Skip to content - - -
- {children} + +
+ + Skip to content + + +
+ {children} +
+ +
- -
-
+ ) } diff --git a/frontend/src/components/common/FreshnessTrustNote.tsx b/frontend/src/components/common/FreshnessTrustNote.tsx new file mode 100644 index 0000000..ec13848 --- /dev/null +++ b/frontend/src/components/common/FreshnessTrustNote.tsx @@ -0,0 +1,85 @@ +import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import type { ExplorerStats } from '@/services/api/stats' +import type { ChainActivityContext } from '@/utils/activityContext' +import { + resolveFreshnessSourceLabel, + summarizeFreshnessConfidence, +} from '@/utils/explorerFreshness' +import { formatRelativeAge } from '@/utils/format' + +function buildSummary(context: ChainActivityContext) { + if (context.transaction_visibility_unavailable) { + return 'Chain-head visibility is current, while transaction freshness is currently unavailable.' + } + + if (context.state === 'active') { + return 'Chain head and latest indexed transactions are closely aligned.' + } + + if (context.head_is_idle) { + return 'Chain head is current, while latest visible transactions trail the tip.' + } + + if (context.state === 'low' || context.state === 'inactive') { + return 'Chain head is current, and recent visible transaction activity is sparse.' + } + + return 'Freshness context is based on the latest visible public explorer evidence.' +} + +function buildDetail(context: ChainActivityContext) { + if (context.transaction_visibility_unavailable) { + return 'Use chain-head visibility and the last non-empty block as the current trust anchors.' + } + + const latestTxAge = formatRelativeAge(context.latest_transaction_timestamp) + const latestNonEmptyBlock = + context.last_non_empty_block_number != null ? `#${context.last_non_empty_block_number.toLocaleString()}` : 'unknown' + + if (context.head_is_idle) { + return `Latest visible transaction: ${latestTxAge}. Last non-empty block: ${latestNonEmptyBlock}.` + } + + if (context.state === 'active') { + return `Latest visible transaction: ${latestTxAge}. Recent indexed activity remains close to the tip.` + } + + return `Latest visible transaction: ${latestTxAge}. Recent head blocks may be quiet even while the chain remains current.` +} + +export default function FreshnessTrustNote({ + context, + stats, + bridgeStatus, + scopeLabel, + className = '', +}: { + context: ChainActivityContext + stats?: ExplorerStats | null + bridgeStatus?: MissionControlBridgeStatusResponse | null + scopeLabel?: string + className?: string +}) { + const sourceLabel = resolveFreshnessSourceLabel(stats, bridgeStatus) + const confidenceBadges = summarizeFreshnessConfidence(stats, bridgeStatus) + const normalizedClassName = className ? ` ${className}` : '' + + return ( +
+
{buildSummary(context)}
+
+ {buildDetail(context)} {scopeLabel ? `${scopeLabel}. ` : ''}{sourceLabel} +
+
+ {confidenceBadges.map((badge) => ( + + {badge} + + ))} +
+
+ ) +} diff --git a/frontend/src/components/common/HeaderCommandPalette.tsx b/frontend/src/components/common/HeaderCommandPalette.tsx new file mode 100644 index 0000000..c57120b --- /dev/null +++ b/frontend/src/components/common/HeaderCommandPalette.tsx @@ -0,0 +1,202 @@ +'use client' + +import { useRouter } from 'next/navigation' +import { useEffect, useMemo, useRef, useState } from 'react' +import { Explain, useUiMode } from './UiModeContext' + +export type HeaderCommandItem = { + href?: string + label: string + description?: string + section: string + keywords?: string[] + onSelect?: () => void | Promise +} + +function SearchIcon({ className = 'h-4 w-4' }: { className?: string }) { + return ( + + + + + ) +} + +function matchItem(item: HeaderCommandItem, query: string) { + const haystack = `${item.label} ${item.description || ''} ${item.section} ${(item.keywords || []).join(' ')}`.toLowerCase() + return haystack.includes(query.toLowerCase()) +} + +export default function HeaderCommandPalette({ + open, + onClose, + items, +}: { + open: boolean + onClose: () => void + items: HeaderCommandItem[] +}) { + const router = useRouter() + const { mode } = useUiMode() + const inputRef = useRef(null) + const itemRefs = useRef>([]) + const [query, setQuery] = useState('') + const [activeIndex, setActiveIndex] = useState(0) + + const filteredItems = useMemo(() => { + const matches = query.trim() + ? items.filter((item) => matchItem(item, query)) + : items + + return [ + { + href: `/search${query.trim() ? `?q=${encodeURIComponent(query.trim())}` : ''}`, + label: query.trim() ? `Search for “${query.trim()}”` : 'Open full explorer search', + description: query.trim() + ? 'Jump to the full search surface with the current query.' + : 'Open the full search page and browse the explorer index.', + section: 'Search', + keywords: ['query', 'find', 'lookup'], + }, + ...matches, + ] + }, [items, query]) + + useEffect(() => { + if (!open) { + setQuery('') + setActiveIndex(0) + return + } + + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault() + onClose() + } + } + + document.addEventListener('keydown', handleKeyDown) + requestAnimationFrame(() => inputRef.current?.focus()) + return () => document.removeEventListener('keydown', handleKeyDown) + }, [onClose, open]) + + useEffect(() => { + setActiveIndex(0) + }, [query]) + + useEffect(() => { + if (!open) return + itemRefs.current[activeIndex]?.scrollIntoView({ block: 'nearest' }) + }, [activeIndex, open]) + + if (!open) return null + + const handleSelect = async (item: HeaderCommandItem) => { + onClose() + if (item.onSelect) { + await item.onSelect() + return + } + if (item.href) { + router.push(item.href) + } + } + + return ( +
+
+
+ +
+ + setQuery(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'ArrowDown') { + event.preventDefault() + setActiveIndex((index) => Math.min(index + 1, filteredItems.length - 1)) + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setActiveIndex((index) => Math.max(index - 1, 0)) + } + if (event.key === 'Enter') { + event.preventDefault() + const activeItem = filteredItems[activeIndex] + if (activeItem) void handleSelect(activeItem) + } + }} + placeholder={mode === 'expert' ? 'Search tx / addr / block / tool' : 'Search pages, tools, tokens, and routes'} + className="w-full border-0 bg-transparent text-sm text-gray-900 placeholder:text-gray-500 focus:outline-none dark:text-white dark:placeholder:text-gray-400" + /> + + Esc + +
+ +

+ Search destinations and run high-frequency header actions from one keyboard-first surface. +

+
+
+ +
+
+ {filteredItems.map((item, index) => ( + + ))} +
+
+
+ {mode === 'expert' ? 'Keyboard-first ' : 'Use '} + / or{' '} + Ctrl/Cmd + K{' '} + {mode === 'expert' ? 'to reopen.' : 'to reopen this palette.'} +
+
+
+ ) +} diff --git a/frontend/src/components/common/Navbar.tsx b/frontend/src/components/common/Navbar.tsx index 12da719..67f84df 100644 --- a/frontend/src/components/common/Navbar.tsx +++ b/frontend/src/components/common/Navbar.tsx @@ -2,29 +2,77 @@ import Link from 'next/link' import { usePathname, useRouter } from 'next/navigation' -import { useEffect, useId, useRef, useState } from 'react' +import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react' import { accessApi, type WalletAccessSession } from '@/services/api/access' +import BrandLockup from './BrandLockup' +import HeaderCommandPalette, { type HeaderCommandItem } from './HeaderCommandPalette' +import { useUiMode } from './UiModeContext' -const navItemBase = - 'rounded-xl px-3 py-2 text-[15px] font-medium transition-all duration-150' -const navLink = - `${navItemBase} text-gray-700 dark:text-gray-300 hover:bg-primary-50 hover:text-primary-700 dark:hover:bg-gray-700/70 dark:hover:text-primary-300` -const navLinkActive = - `${navItemBase} bg-primary-50 text-primary-700 shadow-sm ring-1 ring-primary-100 dark:bg-primary-500/10 dark:text-primary-300 dark:ring-primary-500/20` - -function NavDropdown({ - label, - icon, - active, - children, -}: { +type MenuItem = { + href?: string label: string - icon: React.ReactNode + description?: string + external?: boolean + onSelect?: () => void | Promise +} + +type MenuTone = 'default' | 'emphasis' + +const desktopLinkBase = + 'inline-flex items-center rounded-xl px-3 py-2 text-[15px] font-medium text-gray-700 transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:text-gray-200 dark:focus-visible:ring-offset-gray-900' +const desktopLinkIdle = + 'hover:bg-gray-100 hover:text-gray-950 dark:hover:bg-gray-800 dark:hover:text-white' +const desktopLinkActive = + 'bg-gray-100 text-gray-950 ring-1 ring-gray-200 dark:bg-gray-800 dark:text-white dark:ring-gray-700' + +function shortAddress(address: string) { + if (!address) return 'Wallet' + return `${address.slice(0, 6)}...${address.slice(-4)}` +} + +function SearchIcon({ className = 'h-4 w-4' }: { className?: string }) { + return ( + + + + + ) +} + +function ChevronIcon({ open, className = 'h-4 w-4' }: { open?: boolean; className?: string }) { + return ( + + + + ) +} + +function MenuDropdown({ + label, + items, + active = false, + tone = 'default', + align = 'left', + menuHeader, +}: { + label: ReactNode + items: MenuItem[] active?: boolean - children: React.ReactNode + tone?: MenuTone + align?: 'left' | 'right' + menuHeader?: ReactNode }) { + const { mode } = useUiMode() const [open, setOpen] = useState(false) const wrapperRef = useRef(null) + const triggerRef = useRef(null) + const itemRefs = useRef>([]) const menuId = useId() useEffect(() => { @@ -37,111 +85,387 @@ function NavDropdown({ } } - const handleKeyDown = (event: KeyboardEvent) => { - if (event.key === 'Escape') { - setOpen(false) - } - } - document.addEventListener('mousedown', handlePointerDown) document.addEventListener('touchstart', handlePointerDown) - document.addEventListener('keydown', handleKeyDown) return () => { document.removeEventListener('mousedown', handlePointerDown) document.removeEventListener('touchstart', handlePointerDown) - document.removeEventListener('keydown', handleKeyDown) } }, [open]) + const focusMenuItem = (index: number) => { + const item = itemRefs.current[index] + item?.focus() + } + + const handleTriggerKeyDown = (event: React.KeyboardEvent) => { + if (event.key === 'ArrowDown' || event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + setOpen(true) + requestAnimationFrame(() => focusMenuItem(0)) + } + if (event.key === 'ArrowUp') { + event.preventDefault() + setOpen(true) + requestAnimationFrame(() => focusMenuItem(items.length - 1)) + } + if (event.key === 'Escape') { + setOpen(false) + } + } + + const handleMenuKeyDown = (event: React.KeyboardEvent) => { + const currentIndex = itemRefs.current.findIndex((node) => node === document.activeElement) + if (event.key === 'ArrowDown') { + event.preventDefault() + focusMenuItem((currentIndex + 1 + items.length) % items.length) + } + if (event.key === 'ArrowUp') { + event.preventDefault() + focusMenuItem((currentIndex - 1 + items.length) % items.length) + } + if (event.key === 'Home') { + event.preventDefault() + focusMenuItem(0) + } + if (event.key === 'End') { + event.preventDefault() + focusMenuItem(items.length - 1) + } + if (event.key === 'Escape') { + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + if (event.key === 'Tab') { + setOpen(false) + } + } + return (
setOpen(true)} - onMouseLeave={() => setOpen(false)} + className="relative hidden lg:block" onBlurCapture={(event) => { const nextTarget = event.relatedTarget as Node | null - if (nextTarget && wrapperRef.current?.contains(nextTarget)) { - return + if (!nextTarget || !wrapperRef.current?.contains(nextTarget)) { + setOpen(false) } - setOpen(false) }} > - {open && ( + + {open ? ( - )} + ) : null}
) } -function DropdownItem({ - href, - icon, - children, - external, +function SearchControl({ + active, + mobile = false, + onSelect, }: { - href: string - icon?: React.ReactNode - children: React.ReactNode - external?: boolean + active: boolean + mobile?: boolean + onSelect?: () => void }) { - const className = - 'flex items-center gap-2 px-4 py-2.5 text-gray-700 transition-colors hover:bg-gray-100 hover:text-primary-600 dark:text-gray-300 dark:hover:bg-gray-700 dark:hover:text-primary-400' - if (external) { + const { mode } = useUiMode() + if (mobile) { return ( -
  • - - {icon} - {children} - -
  • + ) } + return ( -
  • - - {icon} - {children} - -
  • + + ) +} + +function getAccessTier(walletSession: WalletAccessSession) { + const permissions = walletSession.permissions || [] + if (permissions.some((permission) => permission.startsWith('operator.'))) { + return 'Operator Tier' + } + if (permissions.some((permission) => permission.startsWith('analytics.'))) { + return 'Analytics Tier' + } + if (permissions.some((permission) => permission.includes('enhanced') || permission.includes('full'))) { + return 'Enhanced Explorer Tier' + } + return 'Explorer Tier' +} + +function getSessionSummary(walletSession: WalletAccessSession) { + const permissionCount = walletSession.permissions?.length || 0 + const tierLabel = getAccessTier(walletSession) + if (permissionCount > 0) { + return `${tierLabel} · ${permissionCount} permission${permissionCount === 1 ? '' : 's'}` + } + return `${tierLabel} · Explorer access active` +} + +function UiModeToggle({ mobile = false }: { mobile?: boolean }) { + const { mode, toggleMode } = useUiMode() + const className = mobile + ? 'inline-flex items-center gap-2 rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300' + : 'hidden lg:inline-flex items-center gap-2 rounded-2xl border border-gray-200 bg-white px-3.5 py-2.5 text-sm font-medium text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 focus-visible:ring-offset-2 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300 dark:focus-visible:ring-offset-gray-900' + + return ( + + ) +} + +function AccountButton({ + walletSession, + connectingWallet, + onConnect, + onCopyAddress, + onSwitchWallet, + onDisconnect, +}: { + walletSession: WalletAccessSession | null + connectingWallet: boolean + onConnect: () => void + onCopyAddress: () => void + onSwitchWallet: () => void + onDisconnect: () => void +}) { + const { mode } = useUiMode() + const accountItems: MenuItem[] = [ + { + href: '/access', + label: 'Account', + description: 'Open API access, subscriptions, and account-linked explorer features.', + }, + { + href: '/wallet', + label: 'Settings', + description: 'Review network, token-list, and wallet configuration guidance.', + }, + { + label: 'Copy address', + description: walletSession?.address || 'Copy the connected wallet address.', + onSelect: onCopyAddress, + }, + { + label: 'Switch wallet', + description: 'Connect a different wallet without leaving the explorer.', + onSelect: onSwitchWallet, + }, + { + label: 'Disconnect wallet', + description: 'End the current wallet connection on this device.', + onSelect: onDisconnect, + }, + ] + + if (!walletSession) { + return ( + + ) + } + + const sessionSummary = getSessionSummary(walletSession) + const tierLabel = getAccessTier(walletSession) + const expiresLabel = walletSession.expiresAt + ? new Date(walletSession.expiresAt).toLocaleString() + : 'Session expiry unavailable' + + return ( + + + {shortAddress(walletSession.address)} + + } + items={accountItems} + align="right" + tone="emphasis" + active={false} + menuHeader={ +
    +
    +
    +
    Wallet connected
    +
    + {mode === 'guided' ? `${sessionSummary}. Access tier is derived from current explorer permissions.` : sessionSummary} +
    +
    + + + Active + +
    +
    +
    {walletSession.address}
    +
    Access tier: {tierLabel}
    +
    Session expires {expiresLabel}
    +
    +
    + } + /> ) } export default function Navbar() { const router = useRouter() + const { mode, setMode } = useUiMode() const pathname = usePathname() ?? '' const [mobileMenuOpen, setMobileMenuOpen] = useState(false) - const [exploreOpen, setExploreOpen] = useState(false) - const [dataOpen, setDataOpen] = useState(false) - const [operationsOpen, setOperationsOpen] = useState(false) + const [commandPaletteOpen, setCommandPaletteOpen] = useState(false) const [walletSession, setWalletSession] = useState(null) const [connectingWallet, setConnectingWallet] = useState(false) + const mobilePanelId = useId() const isExploreActive = pathname === '/' || @@ -150,19 +474,19 @@ export default function Navbar() { pathname.startsWith('/addresses') const isDataActive = pathname.startsWith('/tokens') || - pathname.startsWith('/pools') || pathname.startsWith('/analytics') || + pathname.startsWith('/pools') || pathname.startsWith('/watchlist') const isOperationsActive = pathname.startsWith('/bridge') || pathname.startsWith('/routes') || pathname.startsWith('/liquidity') || pathname.startsWith('/operations') || - pathname.startsWith('/operator') || pathname.startsWith('/system') || + pathname.startsWith('/operator') || pathname.startsWith('/weth') const isDocsActive = pathname.startsWith('/docs') - const isAccessActive = pathname.startsWith('/access') + const isSearchActive = pathname.startsWith('/search') useEffect(() => { const syncWalletSession = () => { @@ -172,19 +496,32 @@ export default function Navbar() { syncWalletSession() window.addEventListener('storage', syncWalletSession) window.addEventListener('explorer-access-session-changed', syncWalletSession) + + const handleShortcut = (event: KeyboardEvent) => { + const target = event.target as HTMLElement | null + const tag = target?.tagName?.toLowerCase() + const isEditable = + tag === 'input' || + tag === 'textarea' || + target?.isContentEditable + + if (isEditable) return + + if (event.key === '/' || ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === 'k')) { + event.preventDefault() + setCommandPaletteOpen(true) + } + } + + window.addEventListener('keydown', handleShortcut) return () => { window.removeEventListener('storage', syncWalletSession) window.removeEventListener('explorer-access-session-changed', syncWalletSession) + window.removeEventListener('keydown', handleShortcut) } - }, []) - - const handleAccessClick = async () => { - if (walletSession) { - router.push('/access') - setMobileMenuOpen(false) - return - } + }, [router]) + const handleConnectWallet = async () => { try { setConnectingWallet(true) await accessApi.connectWalletSession() @@ -199,185 +536,423 @@ export default function Navbar() { } } - const toggleMobileMenu = () => { - setMobileMenuOpen((open) => { - const nextOpen = !open - if (!nextOpen) { - setExploreOpen(false) - setDataOpen(false) - setOperationsOpen(false) - } - return nextOpen - }) + const handleCopyAddress = async () => { + if (!walletSession?.address || typeof navigator === 'undefined' || !navigator.clipboard) return + try { + await navigator.clipboard.writeText(walletSession.address) + } catch (error) { + console.error('Failed to copy wallet address', error) + } } + const handleDisconnectWallet = () => { + accessApi.clearSession() + accessApi.clearWalletSession() + setMobileMenuOpen(false) + } + + const handleCopyCurrentUrl = async () => { + if (typeof window === 'undefined' || !navigator.clipboard) return + try { + await navigator.clipboard.writeText(window.location.href) + } catch (error) { + console.error('Failed to copy current URL', error) + } + } + + const handleSwitchWallet = async () => { + await handleConnectWallet() + } + + const exploreItems: MenuItem[] = useMemo( + () => [ + { href: '/', label: 'Overview', description: 'Return to the main explorer dashboard and network summary.' }, + { href: '/blocks', label: 'Blocks', description: 'Browse recent block production and block detail pages.' }, + { href: '/transactions', label: 'Transactions', description: 'Inspect indexed transactions and their linked entities.' }, + { href: '/addresses', label: 'Addresses', description: 'Open recent address activity and address detail pages.' }, + ], + [], + ) + const dataItems: MenuItem[] = useMemo( + () => [ + { href: '/tokens', label: 'Tokens', description: 'Review curated assets, standards, and token detail pages.' }, + { href: '/analytics', label: 'Analytics', description: 'Open explorer-visible transaction and block activity summaries.' }, + { href: '/pools', label: 'Pools', description: 'Browse mission-control pool inventory and route-backed liquidity context.' }, + { href: '/watchlist', label: 'Watchlist', description: 'Jump into tracked addresses and saved explorer entities.' }, + ], + [], + ) + const operationsItems: MenuItem[] = useMemo( + () => [ + { href: '/operations', label: 'Operations Hub', description: 'Open the consolidated operator surface for live support workflows.' }, + { href: '/bridge', label: 'Bridge Monitoring', description: 'Inspect relay lanes, queue posture, and bridge trace tooling.' }, + { href: '/routes', label: 'Routes', description: 'Review live route coverage, same-chain lanes, and bridge paths.' }, + { href: '/liquidity', label: 'Liquidity', description: 'Check planner-backed route access and live liquidity posture.' }, + { href: '/system', label: 'System', description: 'Inspect topology, RPC capability, and public integration inventory.' }, + { href: '/operator', label: 'Operator Surface', description: 'Open planner, route, and relay shortcuts in one public page.' }, + { href: '/weth', label: 'WETH References', description: 'Review wrapped-asset references and bridge-oriented WETH context.' }, + { href: '/chain138-command-center.html', label: 'Command Center', description: 'Open the visual command-center reference.', external: true }, + ], + [], + ) + + const commandItems: HeaderCommandItem[] = [ + { + label: 'Copy current page link', + description: 'Copy the current explorer URL for operator handoff or support notes.', + section: 'Actions', + keywords: ['copy', 'link', 'share', 'url'], + onSelect: () => void handleCopyCurrentUrl(), + }, + walletSession + ? { + href: '/access', + label: 'Open connected account', + description: `Review ${getAccessTier(walletSession)} permissions, subscriptions, and access features.`, + section: 'Actions', + keywords: ['account', 'session', 'access', 'permissions'], + } + : { + label: connectingWallet ? 'Connecting wallet…' : 'Connect wallet now', + description: 'Start wallet connection and open the account access surface.', + section: 'Actions', + keywords: ['wallet', 'connect', 'account', 'signin'], + onSelect: () => void handleConnectWallet(), + }, + mode === 'guided' + ? { + label: 'Switch to Expert Mode', + description: 'Reduce helper text and keep the interface denser.', + section: 'Actions', + keywords: ['guided', 'expert', 'mode', 'density'], + onSelect: () => setMode('expert'), + } + : { + label: 'Switch to Guided Mode', + description: 'Restore fuller explanations, helper text, and more interpreted labels.', + section: 'Actions', + keywords: ['guided', 'expert', 'mode', 'density'], + onSelect: () => setMode('guided'), + }, + walletSession + ? { + label: 'Disconnect wallet', + description: 'End the current wallet connection on this device.', + section: 'Actions', + keywords: ['wallet', 'disconnect', 'sign out'], + onSelect: handleDisconnectWallet, + } + : { + href: '/wallet', + label: 'Open wallet tools', + description: 'Review supported networks, token lists, and wallet integration guidance.', + section: 'Actions', + keywords: ['wallet', 'settings', 'metamask', 'network'], + }, + ...exploreItems.map((item) => ({ + href: item.href || '/', + label: item.label, + description: item.description, + section: 'Explore', + keywords: ['overview', 'browse'], + })), + ...dataItems.map((item) => ({ + href: item.href || '/', + label: item.label, + description: item.description, + section: 'Data', + keywords: ['tokens', 'analytics', 'pools', 'watchlist'], + })), + { + href: '/docs', + label: 'Docs', + description: 'Open the canonical explorer documentation surface.', + section: 'Docs', + keywords: ['documentation', 'guide', 'reference'], + }, + ...operationsItems + .filter((item) => !item.external) + .map((item) => ({ + href: item.href || '/', + label: item.label, + description: item.description, + section: 'Operations', + keywords: ['ops', 'bridge', 'routes', 'liquidity', 'system'], + })), + { + href: '/wallet', + label: 'Wallet Tools', + description: 'Open network, token-list, and wallet integration tooling.', + section: 'Wallet', + keywords: ['wallet', 'tokens', 'catalog', 'metamask'], + }, + { + href: '/access', + label: 'Account Access', + description: 'Open account-linked explorer access and subscription tools.', + section: 'Account', + keywords: ['access', 'permissions', 'session', 'account'], + }, + ] + return ( - + + {mobileMenuOpen ? ( +
    +
    + { + setMobileMenuOpen(false) + setCommandPaletteOpen(true) + }} + /> + + +
    +
    +
    + Explore +
    +
    + {exploreItems.map((item) => ( + setMobileMenuOpen(false)} + className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300" + > + {item.label} + {item.description ? ( + {item.description} + ) : null} + + ))} +
    +
    + +
    +
    + Data & Docs +
    +
    + {dataItems.map((item) => ( + setMobileMenuOpen(false)} + className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300" + > + {item.label} + {item.description ? ( + {item.description} + ) : null} + + ))} + setMobileMenuOpen(false)} + className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300" + > + Docs + + Open the canonical explorer documentation surface. + + +
    +
    + +
    +
    + Operations +
    +
    + {operationsItems.map((item) => + item.external ? ( + setMobileMenuOpen(false)} + className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300" + > + {item.label} + {item.description ? ( + {item.description} + ) : null} + + ) : ( + setMobileMenuOpen(false)} + className="rounded-2xl border border-gray-200 bg-white px-4 py-3 text-sm text-gray-800 shadow-sm transition-colors hover:border-primary-300 hover:text-primary-700 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-500 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-100 dark:hover:border-primary-500 dark:hover:text-primary-300" + > + {item.label} + {item.description ? ( + {item.description} + ) : null} + + ), + )} +
    +
    +
    + +
    + {walletSession ? ( +
    +
    +
    {getAccessTier(walletSession)}
    + {mode === 'guided' ? ( +
    + Connected wallet access stays aligned with the same account state shown in the desktop header. +
    + ) : null} +
    + setMobileMenuOpen(false)} + className="rounded-2xl bg-gray-950 px-4 py-3 text-sm font-semibold text-white dark:bg-white dark:text-gray-950" + > + Account · {shortAddress(walletSession.address)} + + + + +
    + ) : ( + + )} +
    +
    +
    + ) : null} +
    + + setCommandPaletteOpen(false)} + items={commandItems} + /> + ) } diff --git a/frontend/src/components/common/UiModeContext.tsx b/frontend/src/components/common/UiModeContext.tsx new file mode 100644 index 0000000..3ce0d64 --- /dev/null +++ b/frontend/src/components/common/UiModeContext.tsx @@ -0,0 +1,57 @@ +'use client' + +import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react' + +export type UiMode = 'guided' | 'expert' + +const UI_MODE_STORAGE_KEY = 'explorer_ui_mode' + +const UiModeContext = createContext<{ + mode: UiMode + setMode: (mode: UiMode) => void + toggleMode: () => void +} | null>(null) + +export function UiModeProvider({ children }: { children: ReactNode }) { + const [mode, setModeState] = useState('guided') + + useEffect(() => { + if (typeof window === 'undefined') return + const stored = window.localStorage.getItem(UI_MODE_STORAGE_KEY) + if (stored === 'guided' || stored === 'expert') { + setModeState(stored) + } + }, []) + + const setMode = (nextMode: UiMode) => { + setModeState(nextMode) + if (typeof window !== 'undefined') { + window.localStorage.setItem(UI_MODE_STORAGE_KEY, nextMode) + } + } + + const value = useMemo( + () => ({ + mode, + setMode, + toggleMode: () => setMode(mode === 'guided' ? 'expert' : 'guided'), + }), + [mode], + ) + + return {children} +} + +export function useUiMode() { + const context = useContext(UiModeContext) + if (!context) { + throw new Error('useUiMode must be used within a UiModeProvider') + } + return context +} + +export function Explain({ children }: { children: ReactNode }) { + const { mode } = useUiMode() + if (mode === 'expert') return null + return <>{children} +} diff --git a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx index b660026..387ff75 100644 --- a/frontend/src/components/explorer/AnalyticsOperationsPage.tsx +++ b/frontend/src/components/explorer/AnalyticsOperationsPage.tsx @@ -16,6 +16,10 @@ import { } from '@/services/api/stats' import { transactionsApi, type Transaction } from '@/services/api/transactions' import { formatWeiAsEth } from '@/utils/format' +import { summarizeChainActivity } from '@/utils/activityContext' +import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' import OperationsPageShell, { MetricCard, StatusBadge, @@ -121,6 +125,17 @@ export default function AnalyticsOperationsPage({ () => trailingWindow.reduce((max, point) => Math.max(max, point.transaction_count), 0), [trailingWindow], ) + const activityContext = useMemo( + () => + summarizeChainActivity({ + blocks, + transactions, + latestBlockNumber: stats?.latest_block ?? blocks[0]?.number ?? null, + latestBlockTimestamp: blocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + }), + [blocks, bridgeStatus, stats, transactions], + ) return ( @@ -130,6 +145,17 @@ export default function AnalyticsOperationsPage({ ) : null} +
    + + +
    +
    + {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? ( +

    + Recent head blocks are currently empty; use the latest transaction block for recent visible activity. +

    + ) : null} {blocks.map((block) => (
    Bridge: {shortAddress(lane.bridgeAddress)}
    + {relayPolicyCue(resolveSnapshot((getMissionControlRelays(bridgeStatus) || {})[lane.key])) ? ( +
    + {relayPolicyCue(resolveSnapshot((getMissionControlRelays(bridgeStatus) || {})[lane.key]))} +
    + ) : null} ))}
    diff --git a/frontend/src/components/explorer/OperationsHubPage.tsx b/frontend/src/components/explorer/OperationsHubPage.tsx index 04707aa..6d83655 100644 --- a/frontend/src/components/explorer/OperationsHubPage.tsx +++ b/frontend/src/components/explorer/OperationsHubPage.tsx @@ -5,6 +5,11 @@ import { explorerFeaturePages } from '@/data/explorerOperations' import { configApi, type CapabilitiesResponse, type NetworksConfigResponse, type TokenListResponse } from '@/services/api/config' import { getMissionControlRelays, missionControlApi, type MissionControlBridgeStatusResponse } from '@/services/api/missionControl' import { routesApi, type RouteMatrixResponse } from '@/services/api/routes' +import { useUiMode } from '@/components/common/UiModeContext' +import { summarizeChainActivity } from '@/utils/activityContext' +import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' function relativeAge(isoString?: string): string { if (!isoString) return 'Unknown' @@ -60,6 +65,7 @@ export default function OperationsHubPage({ initialTokenList = null, initialCapabilities = null, }: OperationsHubPageProps) { + const { mode } = useUiMode() const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [routeMatrix, setRouteMatrix] = useState(initialRouteMatrix) const [networksConfig, setNetworksConfig] = useState(initialNetworksConfig) @@ -138,6 +144,19 @@ export default function OperationsHubPage({ new Set((tokenList?.tokens || []).map((token) => token.symbol).filter(Boolean) as string[]) ).slice(0, 8) }, [tokenList]) + const activityContext = useMemo( + () => + summarizeChainActivity({ + blocks: [], + transactions: [], + latestBlockNumber: bridgeStatus?.data?.chains?.['138']?.block_number + ? Number(bridgeStatus.data.chains['138'].block_number) + : null, + latestBlockTimestamp: null, + freshness: resolveEffectiveFreshness(null, bridgeStatus), + }), + [bridgeStatus], + ) return (
    @@ -167,6 +186,16 @@ export default function OperationsHubPage({ ) : null} +
    + + +
    +
    @@ -226,7 +255,7 @@ export default function OperationsHubPage({ {relativeAge(bridgeStatus?.data?.checked_at)}
    - Public mission-control snapshot freshness. + {mode === 'guided' ? 'Public mission-control snapshot freshness.' : 'Mission-control freshness.'}
    @@ -235,7 +264,7 @@ export default function OperationsHubPage({ {relativeAge(routeMatrix?.updated)}
    - Token-aggregation route inventory timestamp. + {mode === 'guided' ? 'Token-aggregation route inventory timestamp.' : 'Route inventory timestamp.'}
    @@ -244,7 +273,7 @@ export default function OperationsHubPage({ {networksConfig?.defaultChainId ?? 'Unknown'}
    - Wallet onboarding points at Chain 138 by default. + {mode === 'guided' ? 'Wallet onboarding points at Chain 138 by default.' : 'Default wallet chain.'}
    @@ -255,7 +284,7 @@ export default function OperationsHubPage({ : 'Partial'}
    - `wallet_addEthereumChain` and `wallet_watchAsset` compatibility. + {mode === 'guided' ? '`wallet_addEthereumChain` and `wallet_watchAsset` compatibility.' : 'Wallet RPC support.'}
    diff --git a/frontend/src/components/explorer/WethOperationsPage.tsx b/frontend/src/components/explorer/WethOperationsPage.tsx index fa29c48..eb90f85 100644 --- a/frontend/src/components/explorer/WethOperationsPage.tsx +++ b/frontend/src/components/explorer/WethOperationsPage.tsx @@ -33,6 +33,14 @@ function relaySnapshot(relay: MissionControlRelayPayload | undefined) { return relay?.url_probe?.body || relay?.file_snapshot } +function relaySummary(snapshot: ReturnType) { + if (!snapshot) return 'destination unknown' + if (snapshot.status === 'paused' && snapshot.monitoring?.delivery_enabled === false) { + return `Delivery paused · queue ${formatNumber(snapshot.queue?.size ?? 0)}` + } + return `Queue ${formatNumber(snapshot.queue?.size ?? 0)} · ${snapshot.destination?.chain_name || 'destination unknown'}` +} + export default function WethOperationsPage({ initialBridgeStatus = null, initialPlannerCapabilities = null, @@ -112,13 +120,13 @@ export default function WethOperationsPage({ string) { + if (value == null) { + return { value: 'Unknown', note: 'Not reported by the current public stats payload.' } + } + return { value: formatter(value), note: 'Current public stats payload.' } +} + +function formatGasPriceGwei(value: number) { + if (!Number.isFinite(value)) return 'Unknown' + return `${value.toFixed(3)} gwei` +} + export default function Home({ initialStats = null, initialRecentBlocks = [], + initialRecentTransactions = [], initialTransactionTrend = [], initialActivitySnapshot = null, + initialBridgeStatus = null, initialRelaySummary = null, }: HomePageProps) { + const { mode } = useUiMode() const [stats, setStats] = useState(initialStats) const [recentBlocks, setRecentBlocks] = useState(initialRecentBlocks) const [transactionTrend, setTransactionTrend] = useState(initialTransactionTrend) + const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) const [activitySnapshot, setActivitySnapshot] = useState(initialActivitySnapshot) + const [bridgeStatus, setBridgeStatus] = useState(initialBridgeStatus) const [relaySummary, setRelaySummary] = useState(initialRelaySummary) + const [missionExpanded, setMissionExpanded] = useState(false) + const [relayExpanded, setRelayExpanded] = useState(false) + const [relayPage, setRelayPage] = useState(1) const [relayFeedState, setRelayFeedState] = useState<'connecting' | 'live' | 'fallback'>( - initialRelaySummary ? 'fallback' : 'connecting' + initialRelaySummary || initialBridgeStatus ? 'fallback' : 'connecting' ) const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const latestBlock = stats?.latest_block ?? recentBlocks[0]?.number ?? null @@ -92,14 +162,41 @@ export default function Home({ } }, []) + useEffect(() => { + let cancelled = false + + if (recentTransactions.length > 0) { + return () => { + cancelled = true + } + } + + transactionsApi.listSafe(chainId, 1, 5) + .then(({ ok, data }) => { + if (!cancelled && ok && data.length > 0) { + setRecentTransactions(data) + } + }) + .catch((error) => { + if (!cancelled && process.env.NODE_ENV !== 'production') { + console.warn('Failed to load recent transactions for activity context:', error) + } + }) + + return () => { + cancelled = true + } + }, [chainId, recentTransactions.length]) + useEffect(() => { let cancelled = false const loadSnapshot = async () => { try { - const summary = await missionControlApi.getRelaySummary() + const status = await missionControlApi.getBridgeStatus() if (!cancelled) { - setRelaySummary(summary) + setBridgeStatus(status) + setRelaySummary(summarizeMissionControlRelay(status)) } } catch (error) { if (!cancelled && process.env.NODE_ENV !== 'production') { @@ -110,10 +207,11 @@ export default function Home({ loadSnapshot() - const unsubscribe = missionControlApi.subscribeRelaySummary( - (summary) => { + const unsubscribe = missionControlApi.subscribeBridgeStatus( + (status) => { if (!cancelled) { - setRelaySummary(summary) + setBridgeStatus(status) + setRelaySummary(summarizeMissionControlRelay(status)) setRelayFeedState('live') } }, @@ -144,103 +242,375 @@ export default function Home({ (best, point) => (!best || point.transaction_count > best.transaction_count ? point : best), null, ) + const averageBlockTimeSeconds = + stats?.average_block_time_ms != null ? Math.round(stats.average_block_time_ms / 1000) : null + const averageGasPriceGwei = stats?.average_gas_price_gwei ?? null + const transactionsToday = stats?.transactions_today ?? null + const networkUtilization = + stats?.network_utilization_percentage != null ? Math.round(stats.network_utilization_percentage) : null const relayAttentionCount = relaySummary?.items.filter((item) => item.tone !== 'normal').length || 0 const relayOperationalCount = relaySummary?.items.filter((item) => item.tone === 'normal').length || 0 - const relayPrimaryItems = relaySummary?.items.slice(0, 6) || [] + const relayPageSize = 4 + const relayPageCount = relaySummary?.items.length ? Math.max(1, Math.ceil(relaySummary.items.length / relayPageSize)) : 1 + const relayVisibleItems = relaySummary?.items.slice((relayPage - 1) * relayPageSize, relayPage * relayPageSize) || [] + const chainStatus = bridgeStatus?.data?.chains?.['138'] || (bridgeStatus?.data?.chains ? Object.values(bridgeStatus.data.chains)[0] : null) + const checkedAt = bridgeStatus?.data?.checked_at || null + const missionHeadline = relaySummary + ? relaySummary.tone === 'danger' + ? 'Relay lanes need attention' + : relaySummary.tone === 'warning' + ? 'Relay lanes are degraded' + : 'Relay lanes are operational' + : chainStatus?.status === 'operational' + ? 'Chain 138 public health is operational' + : chainStatus?.status + ? `Chain 138 public health is ${chainStatus.status}` + : 'Mission control snapshot available' + const missionDescription = (() => { + const parts: string[] = [] + if (checkedAt) parts.push(`Last checked ${formatTimestamp(checkedAt)}`) + if (chainStatus?.head_age_sec != null) parts.push(`head age ${Math.round(chainStatus.head_age_sec)}s`) + if (chainStatus?.latency_ms != null) parts.push(`RPC latency ${Math.round(chainStatus.latency_ms)}ms`) + if (relaySummary?.items.length) { + parts.push(`${relayOperationalCount} operational lanes`) + if (relayAttentionCount > 0) parts.push(`${relayAttentionCount} flagged lanes`) + } else { + parts.push('relay inventory unavailable in the current snapshot') + } + return parts.join(' · ') + })() + const snapshotAgeLabel = checkedAt ? formatRelativeAge(checkedAt) : 'Unknown' + const chainVisibilityState = + chainStatus?.head_age_sec != null + ? chainStatus.head_age_sec <= 30 + ? 'current' + : chainStatus.head_age_sec <= 120 + ? 'slightly delayed' + : 'stale' + : 'unknown' + const snapshotReason = + relayFeedState === 'fallback' + ? 'Live indexing or relay streaming is not currently attached to this homepage card.' + : relayFeedState === 'live' + ? 'Receiving named live mission-control events.' + : 'Negotiating the mission-control event stream.' + const snapshotScope = + bridgeStatus?.data?.mode?.scope + ? bridgeStatus.data.mode.scope.replaceAll('_', ' ') + : relayFeedState === 'fallback' + ? 'This primarily affects relay-lane freshness on the homepage card. Core explorer pages and public RPC health can still be current.' + : relayFeedState === 'live' + ? 'Relay and chain status are arriving through live mission-control events.' + : 'Homepage status is waiting for the mission-control stream to settle.' + const missionImpact = relayAttentionCount > 0 + ? 'Some cross-chain relay lanes are degraded. Core Chain 138 operation remains visible through the public RPC and explorer surfaces.' + : 'Core Chain 138 operation and the visible relay lanes are currently healthy.' + const activityContext = summarizeChainActivity({ + blocks: recentBlocks, + transactions: recentTransactions, + latestBlockNumber: latestBlock, + latestBlockTimestamp: recentBlocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(stats, bridgeStatus), + }) + const txCompleteness = stats?.completeness?.transactions_feed || bridgeStatus?.data?.subsystems?.tx_index?.completeness || null + const blockCompleteness = stats?.completeness?.blocks_feed || null + const statsGeneratedAt = stats?.sampling?.stats_generated_at || null + const missionMode = bridgeStatus?.data?.mode || null + const freshnessIssues = Object.entries({ + ...(bridgeStatus?.data?.sampling?.issues || {}), + ...(stats?.sampling?.issues || {}), + }) + const latestTransactionAgeLabel = activityContext.latest_transaction_timestamp + ? formatRelativeAge(activityContext.latest_transaction_timestamp) + : 'Unknown' + const latestTransactionFreshness = + activityContext.latest_transaction_age_seconds == null + ? 'Latest transaction freshness is unavailable.' + : activityContext.latest_transaction_age_seconds <= 15 * 60 + ? 'Recent visible transactions are close to the chain head.' + : activityContext.latest_transaction_age_seconds <= 3 * 60 * 60 + ? 'The chain head is current, but visible transactions are older than the current tip.' + : 'The chain head is current, but visible transactions are substantially older than the current tip.' + const severityBreakdown = { + down: relaySummary?.items.filter((item) => item.status === 'down').length || 0, + degraded: relaySummary?.items.filter((item) => item.status === 'degraded').length || 0, + warning: + relaySummary?.items.filter((item) => ['paused', 'starting', 'unknown', 'snapshot-error'].includes(item.status)).length || 0, + } + const avgBlockTimeSummary = formatObservabilityValue( + averageBlockTimeSeconds, + (value) => `${value}s`, + ) + const avgGasPriceSummary = formatObservabilityValue( + averageGasPriceGwei, + formatGasPriceGwei, + ) + const transactionsTodaySummary = formatObservabilityValue( + transactionsToday, + (value) => value.toLocaleString(), + ) + const networkUtilizationSummary = + networkUtilization == null + ? { value: 'Unknown', note: 'Utilization is not reported by the current public stats payload.' } + : networkUtilization === 0 + ? { value: '0%', note: 'No utilization was reported in the latest visible stats sample.' } + : { value: `${networkUtilization}%`, note: 'Current public stats payload.' } + const missionCollapsedSummary = relaySummary + ? `${missionHeadline} · ${relayOperationalCount} operational` + : `${missionHeadline}${chainStatus?.status ? ` · chain 138 ${chainStatus.status}` : ''}` + + useEffect(() => { + setRelayPage(1) + }, [relaySummary?.items.length]) + + useEffect(() => { + if (relayPage > relayPageCount) { + setRelayPage(relayPageCount) + } + }, [relayPage, relayPageCount]) return (
    -
    -

    SolaceScan

    -

    Chain 138 Explorer by DBIS

    -
    - - {relaySummary && ( - -
    -
    -
    -
    Mission Control
    -
    - {relaySummary.tone === 'danger' - ? 'Relay lanes need attention' - : relaySummary.tone === 'warning' - ? 'Relay lanes are degraded' - : 'Relay lanes are operational'} -
    -

    - {relaySummary.text}. This surface summarizes the public relay posture in a compact operator-friendly format. -

    -
    - - - - 0 ? 'warning' : 'info'} /> -
    + {(relaySummary || bridgeStatus) && ( + +
    + -
    -
    -
    Live Feed
    + {missionExpanded ? ( + <> +
    +
    +
    {missionHeadline}
    +

    + {missionDescription}. + {mode === 'guided' + ? ' This surface summarizes the public chain and relay posture in a compact operator-friendly format.' + : ' Public chain and relay posture.'} +

    +

    + {missionImpact} +

    + +

    + {latestTransactionFreshness} +

    +
    +
    + + {relaySummary ? ( + + ) : null} + {chainStatus?.status ? ( + + ) : null} + + {severityBreakdown.down > 0 ? : null} + {severityBreakdown.degraded > 0 ? : null} + {severityBreakdown.warning > 0 ? : null} +
    +
    + +
    +
    +
    Live Feed
    +
    + {missionMode?.kind === 'live' + ? 'Streaming' + : missionMode?.kind === 'snapshot' || relayFeedState === 'fallback' + ? 'Snapshot mode' + : 'Connecting'} +
    +
    + {`${statsGeneratedAt ? `Snapshot updated ${formatRelativeAge(statsGeneratedAt)}.` : `Snapshot updated ${snapshotAgeLabel}.`} ${ + missionMode?.reason ? missionMode.reason.replaceAll('_', ' ') : snapshotReason + }`} +
    +
    + {snapshotScope} +
    + {freshnessIssues.length > 0 ? ( +
    + Freshness diagnostics: {freshnessIssues.map(([key]) => key.replaceAll('_', ' ')).join(', ')}. +
    + ) : null} +
    +
    + + Open bridge monitoring + + + Open operations hub + +
    +
    +
    + + {chainStatus ? ( +
    +
    +
    Chain 138 Status
    +
    {chainStatus.status || 'unknown'}
    +
    {chainStatus.name || 'Defi Oracle Meta Mainnet'}
    +
    +
    +
    Head Age
    - {relayFeedState === 'live' ? 'Streaming' : relayFeedState === 'fallback' ? 'Snapshot mode' : 'Connecting'} + {chainStatus.head_age_sec != null ? `${Math.round(chainStatus.head_age_sec)}s` : 'Unknown'} +
    +
    Latest public RPC head freshness.
    +
    Chain visibility is currently {chainVisibilityState}.
    +
    +
    +
    RPC Latency
    +
    + {chainStatus.latency_ms != null ? `${Math.round(chainStatus.latency_ms)}ms` : 'Unknown'} +
    +
    Public Chain 138 RPC probe latency.
    +
    +
    +
    Latest Transaction
    +
    + {activityContext.latest_transaction_block_number != null ? `#${activityContext.latest_transaction_block_number}` : 'Unknown'} +
    +
    {latestTransactionAgeLabel}
    +
    + Latest visible transaction freshness{txCompleteness ? ` · ${txCompleteness}` : ''}. +
    +
    +
    +
    Last Non-Empty Block
    +
    + {activityContext.last_non_empty_block_number != null ? `#${activityContext.last_non_empty_block_number}` : 'Unknown'}
    - {relayFeedState === 'live' - ? 'Receiving named mission-control events.' - : relayFeedState === 'fallback' - ? 'Using the latest available snapshot.' - : 'Negotiating the event stream.'} + {activityContext.block_gap_to_latest_transaction != null + ? `${activityContext.block_gap_to_latest_transaction.toLocaleString()} blocks behind tip` + : 'Gap unavailable'}
    -
    - - Open operations hub - - - Open live stream - -
    -
    -
    - -
    - {relayPrimaryItems.map((item) => ( -
    -
    -
    -
    {item.label}
    -
    {item.status}
    -
    -
    -

    {item.text}

    -
    - ))} -
    + ) : null} - {relaySummary.items.length > relayPrimaryItems.length ? ( -
    - Showing {relayPrimaryItems.length} of {relaySummary.items.length} relay lanes. The live stream and operations hub carry the fuller view. -
    + {relaySummary?.items.length ? ( +
    + + + {relayExpanded ? ( + <> +
    + {relayVisibleItems.map((item) => ( +
    +
    +
    +
    {item.label}
    +
    {item.status}
    +
    + +
    +

    {item.text}

    +

    + {getLaneImpactNote(item.key, resolveRelaySeverityLabel(item.status, item.tone))} +

    +
    + ))} +
    + + {relayPageCount > 1 ? ( +
    + +
    + Page {relayPage} of {relayPageCount} +
    + +
    + ) : null} + + ) : null} +
    + ) : ( +
    + The current mission-control snapshot does not include per-lane relay inventory. Chain health is still shown above, and the bridge monitoring page remains the canonical operator view. +
    + )} + {relaySummary ? ( +
    + {severityBreakdown.down > 0 ? : null} + {severityBreakdown.degraded > 0 ? : null} + {severityBreakdown.warning > 0 ? : null} +
    + ) : null} + ) : null}
    @@ -253,22 +623,61 @@ export default function Home({
    {latestBlock != null ? latestBlock.toLocaleString() : 'Unavailable'}
    +
    + {activityContext.latest_block_timestamp + ? `Head freshness ${formatRelativeAge(activityContext.latest_block_timestamp)}${blockCompleteness ? ` · ${blockCompleteness}` : ''}` + : 'Head freshness unavailable.'} +
    Total Blocks
    {stats.total_blocks.toLocaleString()}
    +
    Visible public explorer block count.
    Total Transactions
    {stats.total_transactions.toLocaleString()}
    +
    Latest visible tx {latestTransactionAgeLabel}.
    Total Addresses
    {stats.total_addresses.toLocaleString()}
    +
    Current public explorer address count.
    +
    + +
    Avg Block Time
    +
    {avgBlockTimeSummary.value}
    +
    {avgBlockTimeSummary.note}
    +
    + +
    Avg Gas Price
    +
    {avgGasPriceSummary.value}
    +
    {avgGasPriceSummary.note}
    +
    + +
    Transactions Today
    +
    {transactionsTodaySummary.value}
    +
    {transactionsTodaySummary.note}
    +
    + +
    Network Utilization
    +
    {networkUtilizationSummary.value}
    +
    {networkUtilizationSummary.note}
    )} +
    + + +
    + {!stats && (

    @@ -284,6 +693,11 @@ export default function Home({

    ) : (
    + {shouldExplainEmptyHeadBlocks(recentBlocks, activityContext) ? ( +

    + Recent head blocks are currently empty; use the latest transaction block for recent visible activity. +

    + ) : null} {recentBlocks.map((block) => (
    @@ -315,7 +729,9 @@ export default function Home({

    - A concise public view of chain activity, index coverage, and recent execution patterns. + {mode === 'guided' + ? 'A concise public view of chain activity, index coverage, and recent execution patterns.' + : 'Public chain activity and index posture.'}

    diff --git a/frontend/src/components/wallet/WalletPage.tsx b/frontend/src/components/wallet/WalletPage.tsx index 11585b8..8cc6c7f 100644 --- a/frontend/src/components/wallet/WalletPage.tsx +++ b/frontend/src/components/wallet/WalletPage.tsx @@ -6,6 +6,7 @@ import type { } from '@/components/wallet/AddToMetaMask' import { AddToMetaMask } from '@/components/wallet/AddToMetaMask' import Link from 'next/link' +import { Explain, useUiMode } from '@/components/common/UiModeContext' interface WalletPageProps { initialNetworks?: NetworksCatalog | null @@ -17,19 +18,35 @@ interface WalletPageProps { } export default function WalletPage(props: WalletPageProps) { + const { mode } = useUiMode() return (
    -

    Wallet & MetaMask

    +

    Wallet Tools

    - Connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets. Use the token list URL so tokens and oracles are discoverable. + {mode === 'guided' + ? 'Use the explorer-served network catalog, token list, and capability metadata to connect Chain 138 (DeFi Oracle Meta Mainnet) and Ethereum Mainnet to MetaMask and other Web3 wallets.' + : 'Use explorer-served network and token metadata to connect Chain 138 and Ethereum Mainnet wallets.'}

    - Need swap and liquidity discovery too? Visit the{' '} - - Liquidity Access - {' '} - page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints. + + <> + Need swap and liquidity discovery too? Visit the{' '} + + Liquidity Access + {' '} + page for live Chain 138 pools, route matrix links, partner payload templates, and the internal fallback execution plan endpoints. + + + {mode === 'expert' ? ( + <> + Liquidity and planner posture lives on the{' '} + + Liquidity Access + {' '} + surface. + + ) : null}
    ) diff --git a/frontend/src/data/explorerOperations.ts b/frontend/src/data/explorerOperations.ts index 4f1795a..9706aa9 100644 --- a/frontend/src/data/explorerOperations.ts +++ b/frontend/src/data/explorerOperations.ts @@ -14,7 +14,7 @@ export interface ExplorerFeaturePage { actions: ExplorerFeatureAction[] } -const legacyNote = +const sharedOperationsNote = 'These pages collect the public monitoring, route, wallet, and topology surfaces that support Chain 138 operations and investigation.' export const explorerFeaturePages = { @@ -23,7 +23,7 @@ export const explorerFeaturePages = { title: 'Bridge & Relay Monitoring', description: 'Inspect the CCIP relay status, follow the live mission-control stream, trace bridge transactions, and review the managed Mainnet, BSC, Avalanche, Avalanche cW, and Avalanche to Chain 138 lanes.', - note: legacyNote, + note: sharedOperationsNote, actions: [ { title: 'Mission-control live stream', @@ -73,7 +73,7 @@ export const explorerFeaturePages = { title: 'Routes, Pools, and Execution Access', description: 'Surface the route matrix, live pool inventory, public liquidity endpoints, and bridge-adjacent execution paths from one public explorer surface.', - note: legacyNote, + note: sharedOperationsNote, actions: [ { title: 'Liquidity access', @@ -81,12 +81,6 @@ export const explorerFeaturePages = { href: '/liquidity', label: 'Open liquidity access', }, - { - title: 'Pools inventory', - description: 'Jump to the pool overview page for quick PMM route and asset discovery.', - href: '/pools', - label: 'Open pools page', - }, { title: 'Pools inventory', description: 'Open the live pools page instead of dropping into a raw backend response.', @@ -112,7 +106,7 @@ export const explorerFeaturePages = { title: 'WETH Utilities & Bridge References', description: 'Reach the WETH-focused tooling that operators use during support and bridge investigation without depending on the hidden legacy explorer navigation.', - note: legacyNote, + note: sharedOperationsNote, actions: [ { title: 'Bridge monitoring', @@ -146,7 +140,7 @@ export const explorerFeaturePages = { title: 'Analytics & Network Activity', description: 'Use the public explorer pages and live monitoring endpoints as the visible analytics surface for chain activity, recent blocks, and transaction flow.', - note: legacyNote, + note: sharedOperationsNote, actions: [ { title: 'Blocks', @@ -176,11 +170,11 @@ export const explorerFeaturePages = { ], }, operator: { - eyebrow: 'Operator Shortcuts', - title: 'Operator Panel Shortcuts', + eyebrow: 'Operator Surface', + title: 'Operator Surface', description: - 'Expose the public operational shortcuts for bridge checks, route validation, liquidity entry points, and documentation.', - note: legacyNote, + 'Expose the public operator surface for bridge checks, route validation, planner providers, liquidity entry points, and documentation.', + note: sharedOperationsNote, actions: [ { title: 'Bridge monitoring', @@ -220,7 +214,7 @@ export const explorerFeaturePages = { title: 'System & Topology', description: 'Jump straight into the public topology and reference surfaces that describe how Chain 138, bridge monitoring, and adjacent systems fit together.', - note: legacyNote, + note: sharedOperationsNote, actions: [ { title: 'Visual command center', @@ -254,7 +248,7 @@ export const explorerFeaturePages = { title: 'Operations Hub', description: 'This hub exposes the public operational surfaces for bridge monitoring, routes, wrapped-asset references, analytics shortcuts, operator links, and topology views.', - note: legacyNote, + note: sharedOperationsNote, actions: [ { title: 'Bridge & relay monitoring', diff --git a/frontend/src/pages/addresses/index.tsx b/frontend/src/pages/addresses/index.tsx index 853a60f..a8cf480 100644 --- a/frontend/src/pages/addresses/index.tsx +++ b/frontend/src/pages/addresses/index.tsx @@ -8,6 +8,12 @@ import { readWatchlistFromStorage } from '@/utils/watchlist' import PageIntro from '@/components/common/PageIntro' import { fetchPublicJson } from '@/utils/publicExplorer' import { normalizeTransaction } from '@/services/api/blockscout' +import { summarizeChainActivity } from '@/utils/activityContext' +import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import { resolveEffectiveFreshness } from '@/utils/explorerFreshness' function normalizeAddress(value: string) { const trimmed = value.trim() @@ -16,6 +22,9 @@ function normalizeAddress(value: string) { interface AddressesPageProps { initialRecentTransactions: Transaction[] + initialLatestBlocks: Array<{ number: number; timestamp: string }> + initialStats: ExplorerStats | null + initialBridgeStatus: MissionControlBridgeStatusResponse | null } function serializeRecentTransactions(transactions: Transaction[]): Transaction[] { @@ -26,17 +35,43 @@ function serializeRecentTransactions(transactions: Transaction[]): Transaction[] block_number: transaction.block_number, from_address: transaction.from_address, to_address: transaction.to_address ?? null, + created_at: transaction.created_at, })), ), ) as Transaction[] } -export default function AddressesPage({ initialRecentTransactions }: AddressesPageProps) { +export default function AddressesPage({ + initialRecentTransactions, + initialLatestBlocks, + initialStats, + initialBridgeStatus, +}: AddressesPageProps) { const router = useRouter() const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const [query, setQuery] = useState('') const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) const [watchlist, setWatchlist] = useState([]) + const activityContext = useMemo( + () => + summarizeChainActivity({ + blocks: initialLatestBlocks.map((block) => ({ + chain_id: chainId, + number: block.number, + hash: '', + timestamp: block.timestamp, + miner: '', + transaction_count: 0, + gas_used: 0, + gas_limit: 0, + })), + transactions: recentTransactions, + latestBlockNumber: initialLatestBlocks[0]?.number ?? null, + latestBlockTimestamp: initialLatestBlocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), + }), + [chainId, initialBridgeStatus, initialLatestBlocks, initialStats, recentTransactions], + ) useEffect(() => { if (initialRecentTransactions.length > 0) { @@ -111,6 +146,17 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa ]} /> +
    + + +
    +
    {activeAddresses.length === 0 ? (

    - Recent address activity is unavailable right now. You can still open an address directly above. + Recent address activity is unavailable in the latest visible transaction sample. You can still open an address directly above.

    ) : (
    @@ -177,14 +223,30 @@ export default function AddressesPage({ initialRecentTransactions }: AddressesPa export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const transactionsResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null) + const [transactionsResult, blocksResult, statsResult, bridgeResult] = await Promise.all([ + fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=20').catch(() => null), + fetchPublicJson<{ items?: Array<{ height?: number | string | null; timestamp?: string | null }> }>('/api/v2/blocks?page=1&page_size=3').catch(() => null), + fetchPublicJson>('/api/v2/stats').catch(() => null), + fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), + ]) const initialRecentTransactions = Array.isArray(transactionsResult?.items) ? transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId)) : [] + const initialLatestBlocks = Array.isArray(blocksResult?.items) + ? blocksResult.items + .map((item) => ({ + number: Number(item.height || 0), + timestamp: item.timestamp || '', + })) + .filter((item) => Number.isFinite(item.number) && item.number > 0 && item.timestamp) + : [] return { props: { initialRecentTransactions: serializeRecentTransactions(initialRecentTransactions), + initialLatestBlocks, + initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, + initialBridgeStatus: bridgeResult, }, } } diff --git a/frontend/src/pages/blocks/index.tsx b/frontend/src/pages/blocks/index.tsx index ffe966a..9937295 100644 --- a/frontend/src/pages/blocks/index.tsx +++ b/frontend/src/pages/blocks/index.tsx @@ -1,20 +1,55 @@ import type { GetServerSideProps } from 'next' -import { useCallback, useEffect, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { blocksApi, Block } from '@/services/api/blocks' import { Card, Address } from '@/libs/frontend-ui-primitives' import Link from 'next/link' import PageIntro from '@/components/common/PageIntro' import { formatTimestamp } from '@/utils/format' import { fetchPublicJson } from '@/utils/publicExplorer' -import { normalizeBlock } from '@/services/api/blockscout' +import { normalizeBlock, normalizeTransaction } from '@/services/api/blockscout' +import type { Transaction } from '@/services/api/transactions' +import { transactionsApi } from '@/services/api/transactions' +import { summarizeChainActivity } from '@/utils/activityContext' +import ActivityContextPanel from '@/components/common/ActivityContextPanel' +import FreshnessTrustNote from '@/components/common/FreshnessTrustNote' +import { normalizeExplorerStats, type ExplorerStats } from '@/services/api/stats' +import type { MissionControlBridgeStatusResponse } from '@/services/api/missionControl' +import { resolveEffectiveFreshness, shouldExplainEmptyHeadBlocks } from '@/utils/explorerFreshness' interface BlocksPageProps { initialBlocks: Block[] + initialRecentTransactions: Transaction[] + initialStats: ExplorerStats | null + initialBridgeStatus: MissionControlBridgeStatusResponse | null } -export default function BlocksPage({ initialBlocks }: BlocksPageProps) { +function serializeTransactions(transactions: Transaction[]): Transaction[] { + return JSON.parse( + JSON.stringify( + transactions.map((transaction) => ({ + hash: transaction.hash, + block_number: transaction.block_number, + from_address: transaction.from_address, + to_address: transaction.to_address ?? null, + value: transaction.value, + status: transaction.status ?? null, + contract_address: transaction.contract_address ?? null, + fee: transaction.fee ?? null, + created_at: transaction.created_at, + })), + ), + ) as Transaction[] +} + +export default function BlocksPage({ + initialBlocks, + initialRecentTransactions, + initialStats, + initialBridgeStatus, +}: BlocksPageProps) { const pageSize = 20 const [blocks, setBlocks] = useState(initialBlocks) + const [recentTransactions, setRecentTransactions] = useState(initialRecentTransactions) const [loading, setLoading] = useState(initialBlocks.length === 0) const [page, setPage] = useState(1) const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') @@ -47,8 +82,43 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) { void loadBlocks() }, [initialBlocks, loadBlocks, page]) + useEffect(() => { + if (initialRecentTransactions.length > 0) { + setRecentTransactions(initialRecentTransactions) + return + } + + let active = true + transactionsApi.listSafe(chainId, 1, 5) + .then(({ ok, data }) => { + if (active && ok && data.length > 0) { + setRecentTransactions(data) + } + }) + .catch(() => { + if (active) { + setRecentTransactions([]) + } + }) + + return () => { + active = false + } + }, [chainId, initialRecentTransactions]) + const showPagination = page > 1 || blocks.length > 0 const canGoNext = blocks.length === pageSize + const activityContext = useMemo( + () => + summarizeChainActivity({ + blocks, + transactions: recentTransactions, + latestBlockNumber: blocks[0]?.number ?? null, + latestBlockTimestamp: blocks[0]?.timestamp ?? null, + freshness: resolveEffectiveFreshness(initialStats, initialBridgeStatus), + }), + [blocks, initialBridgeStatus, initialStats, recentTransactions], + ) return (
    @@ -63,12 +133,30 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) { ]} /> +
    + + +
    + {loading ? (

    Loading blocks...

    ) : (
    + {shouldExplainEmptyHeadBlocks(blocks, activityContext) ? ( + +

    + Recent head blocks are currently empty; use the latest transaction block for recent visible activity. +

    +
    + ) : null} {blocks.length === 0 ? (

    Recent blocks are unavailable right now.

    @@ -161,13 +249,23 @@ export default function BlocksPage({ initialBlocks }: BlocksPageProps) { export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const blocksResult = await fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null) + const [blocksResult, transactionsResult, statsResult, bridgeResult] = await Promise.all([ + fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=20').catch(() => null), + fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5').catch(() => null), + fetchPublicJson>('/api/v2/stats').catch(() => null), + fetchPublicJson('/explorer-api/v1/track1/bridge/status').catch(() => null), + ]) return { props: { initialBlocks: Array.isArray(blocksResult?.items) ? blocksResult.items.map((item) => normalizeBlock(item as never, chainId)) : [], + initialRecentTransactions: Array.isArray(transactionsResult?.items) + ? serializeTransactions(transactionsResult.items.map((item) => normalizeTransaction(item as never, chainId))) + : [], + initialStats: statsResult ? normalizeExplorerStats(statsResult as never) : null, + initialBridgeStatus: bridgeResult, }, } } diff --git a/frontend/src/pages/docs/index.tsx b/frontend/src/pages/docs/index.tsx index 4920ee6..2285763 100644 --- a/frontend/src/pages/docs/index.tsx +++ b/frontend/src/pages/docs/index.tsx @@ -6,12 +6,12 @@ import PageIntro from '@/components/common/PageIntro' const docsCards = [ { - title: 'GRU guide', + title: 'GRU Guide', href: '/docs/gru', description: 'Understand GRU standards, x402 readiness, wrapped transport posture, and forward-canonical versioning as surfaced by the explorer.', }, { - title: 'Transaction evidence matrix', + title: 'Transaction Evidence Matrix', href: '/docs/transaction-review', description: 'See how the explorer scores transaction evidence quality, decode richness, asset posture, and counterparty traceability.', }, diff --git a/frontend/src/pages/index.tsx b/frontend/src/pages/index.tsx index 2671c34..8c08dcd 100644 --- a/frontend/src/pages/index.tsx +++ b/frontend/src/pages/index.tsx @@ -11,16 +11,21 @@ import { } from '@/services/api/stats' import { summarizeMissionControlRelay, + type MissionControlBridgeStatusResponse, type MissionControlRelaySummary, } from '@/services/api/missionControl' import type { Block } from '@/services/api/blocks' +import type { Transaction } from '@/services/api/transactions' import { fetchPublicJson } from '@/utils/publicExplorer' +import { normalizeTransaction } from '@/services/api/blockscout' interface IndexPageProps { initialStats: ExplorerStats | null initialRecentBlocks: Block[] + initialRecentTransactions: Transaction[] initialTransactionTrend: ExplorerTransactionTrendPoint[] initialActivitySnapshot: ExplorerRecentActivitySnapshot | null + initialBridgeStatus: MissionControlBridgeStatusResponse | null initialRelaySummary: MissionControlRelaySummary | null } @@ -28,10 +33,28 @@ export default function IndexPage(props: IndexPageProps) { return } +function serializeTransactions(transactions: Transaction[]): Transaction[] { + return JSON.parse( + JSON.stringify( + transactions.map((transaction) => ({ + hash: transaction.hash, + block_number: transaction.block_number, + from_address: transaction.from_address, + to_address: transaction.to_address ?? null, + value: transaction.value, + status: transaction.status ?? null, + contract_address: transaction.contract_address ?? null, + fee: transaction.fee ?? null, + created_at: transaction.created_at, + })), + ), + ) as Transaction[] +} + export const getServerSideProps: GetServerSideProps = async () => { const chainId = Number(process.env.NEXT_PUBLIC_CHAIN_ID || '138') - const [statsResult, blocksResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([ + const [statsResult, blocksResult, transactionsResult, trendResult, activityResult, bridgeResult] = await Promise.allSettled([ fetchPublicJson<{ total_blocks?: number | string | null total_transactions?: number | string | null @@ -39,6 +62,7 @@ export const getServerSideProps: GetServerSideProps = async () = latest_block?: number | string | null }>('/api/v2/stats'), fetchPublicJson<{ items?: unknown[] }>('/api/v2/blocks?page=1&page_size=10'), + fetchPublicJson<{ items?: unknown[] }>('/api/v2/transactions?page=1&page_size=5'), fetchPublicJson<{ chart_data?: Array<{ date?: string | null; transaction_count?: number | string | null }> }>( '/api/v2/stats/charts/transactions' ), @@ -60,10 +84,18 @@ export const getServerSideProps: GetServerSideProps = async () = blocksResult.status === 'fulfilled' && Array.isArray(blocksResult.value?.items) ? blocksResult.value.items.map((item) => normalizeBlock(item as never, chainId)) : [], + initialRecentTransactions: + transactionsResult.status === 'fulfilled' && Array.isArray(transactionsResult.value?.items) + ? serializeTransactions( + transactionsResult.value.items.map((item) => normalizeTransaction(item as never, chainId)), + ) + : [], initialTransactionTrend: trendResult.status === 'fulfilled' ? normalizeTransactionTrend(trendResult.value) : [], initialActivitySnapshot: activityResult.status === 'fulfilled' ? summarizeRecentTransactions(activityResult.value) : null, + initialBridgeStatus: + bridgeResult.status === 'fulfilled' ? (bridgeResult.value as MissionControlBridgeStatusResponse) : null, initialRelaySummary: bridgeResult.status === 'fulfilled' ? summarizeMissionControlRelay(bridgeResult.value as never) : null, }, diff --git a/frontend/src/pages/search/index.tsx b/frontend/src/pages/search/index.tsx index 7b4ede1..c5f1fe1 100644 --- a/frontend/src/pages/search/index.tsx +++ b/frontend/src/pages/search/index.tsx @@ -14,6 +14,7 @@ import { } from '@/utils/search' import PageIntro from '@/components/common/PageIntro' import { fetchPublicJson } from '@/utils/publicExplorer' +import { useUiMode } from '@/components/common/UiModeContext' type SearchFilterMode = 'all' | 'gru' | 'x402' | 'wrapped' @@ -28,6 +29,7 @@ export default function SearchPage({ initialRawResults, initialCuratedTokens, }: SearchPageProps) { + const { mode } = useUiMode() const router = useRouter() const routerQuery = typeof router.query.q === 'string' ? router.query.q : '' const [query, setQuery] = useState(initialQuery) @@ -193,7 +195,11 @@ export default function SearchPage({ setQuery(e.target.value)} - placeholder="Search by address, transaction hash, block number..." + placeholder={mode === 'guided' ? 'Search by address, transaction hash, block number...' : 'Search tx / addr / block / token'} className="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500" />