Frontend: complete task list (C1–L4), security, a11y, L1 block card helper

- React: response.ok checks (address, transaction, search); block number validation; stable Table keys; API modules (addresses, transactions, blocks normalizer)
- SPA: escapeHtml/safe URLs/onclick; getRpcUrl in rpcCall; cancel blocks rAF on view change; named constants; hash route decode
- SPA: createBlockCardHtml + normalizeBlockDisplay (L1); DEBUG console gating; aria-live for errors; token/block/tx detail escaping
- Docs: FRONTEND_REVIEW.md, FRONTEND_TASKS_AND_REVIEW.md; favicons; .gitignore *.tsbuildinfo

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
defiQUG
2026-02-10 18:43:37 -08:00
parent 1c8ca4172a
commit 2b956a5a83
16 changed files with 847 additions and 315 deletions

1
.gitignore vendored
View File

@@ -6,6 +6,7 @@ vendor/
dist/ dist/
build/ build/
.next/ .next/
*.tsbuildinfo
*.exe *.exe
*.exe~ *.exe~
*.dll *.dll

139
frontend/FRONTEND_REVIEW.md Normal file
View File

@@ -0,0 +1,139 @@
# Frontend Code Review Explorer Monorepo
**Scope:** `explorer-monorepo/frontend/`
**Reviewed:** Vanilla JS SPA (`public/index.html`), React/Next.js app (`src/`), services, config.
**Date:** 2025-02-09
---
## 1. Overview
The frontend has two delivery paths:
| Asset | Purpose | Deployed |
|-------|--------|----------|
| **`public/index.html`** | Single-page explorer (Blocks, Transactions, Bridge, WETH, search, wallet connect). Vanilla JS, ~4.2k lines. | Yes (VMID 5000, https://explorer.d-bis.org) |
| **`src/`** (Next.js) | React app (blocks, transactions, addresses, search, wallet). Uses Blockscout-style API. | No (dev/build only) |
---
## 2. Vanilla JS SPA (`public/index.html`)
### 2.1 Security
**Good:**
- **`escapeHtml()`** is used for error messages, revert reasons, method names, ABI/bytecode, token names/symbols, NFT metadata, and other user/API-derived strings before `innerHTML`. Reduces XSS from API or user input.
- **CSP** in `<meta>` restricts script/style/font/connect sources; comment documents `unsafe-eval` for ethers v5 UMD.
- **Credentials:** `fetchAPI` uses `credentials: 'omit'` for API calls.
- **Wallet:** No private keys or secrets in code; MetaMask/ethers used for signing.
**Improve:**
- **Defense in depth for hashes/addresses:** Any API-derived string (e.g. `hash`, `from`, `to`, `address`) that is interpolated into `innerHTML` should be escaped. Currently:
- Block cards: `shortenHash(hash)` and block number are injected without `escapeHtml`. Block numbers are numeric; hashes are usually hex from a trusted API, but escaping would harden against a compromised or malicious API.
- Breadcrumbs: `shortenHash(identifier)` and `identifier` in `href="#/address/' + identifier + '"` if `identifier` can contain `'` or `"`, it could break attributes or enable injection. Recommend `escapeHtml(shortenHash(hash))` for display and sanitize/validate for attributes.
- **`shortenHash`:** Only truncates; does not strip HTML. Use `escapeHtml(shortenHash(hash))` wherever the result is used in HTML.
### 2.2 Correctness & Robustness
**Good:**
- **Navigation:** Re-entrancy guard (`_inNavHandler`) and “showView first, then set hash” prevent the previous infinite recursion from hashchange.
- **`applyHashRoute`:** Uses `currentView` and `currentDetailKey` so the same view/detail is not re-applied unnecessarily.
- **`fetchAPI`:** Uses `AbortController` and 15s timeout; `AbortError` is handled in retry logic.
- **`fetchAPIWithRetry`:** Exponential backoff; retries on timeout/5xx/network; does not retry on non-retryable errors.
- **RPC fallback:** When Blockscout API fails, blocks/transactions can be loaded via `rpcCall` (e.g. `eth_blockNumber`, `eth_getBlockByNumber`).
- **Validation:** `safeBlockNumber`, `safeTxHash`, `safeAddress` used before detail views and in retry buttons.
- **Wrap/Unwrap:** Balance checks and `callStatic` simulation before deposit/withdraw; user rejection vs contract error distinguished in messages.
**Improve:**
- **`rpcCall`:** Uses a single RPC URL; does not use `getRpcUrl()` for failover. Consider using `getRpcUrl()` for critical RPC calls so the app benefits from the same failover as elsewhere.
- **Memory/cleanup:** `requestAnimationFrame(animateScroll)` in the blocks scroll runs indefinitely. On view change, the loop is not cancelled; consider storing the frame id and cancelling in a cleanup when leaving the blocks view.
- **Breadcrumb `identifier`:** In `updateBreadcrumb`, `identifier` is used in `href="#/address/' + identifier + '"`. If `identifier` contains `'`, the attribute can break. Prefer escaping or using `encodeURIComponent` for path segments.
### 2.3 Structure & Maintainability
- **Single file:** ~4.2k lines in one HTML file makes navigation and testing harder. Consider splitting script into logical modules (e.g. API, nav, views, wallet) and bundling, or at least grouping related functions and marking sections.
- **Duplicate logic:** Block card HTML is built in multiple places (home stats area, blocks list, block detail). A single `createBlockCard`-style helper (or shared template) would reduce drift and bugs.
- **Magic numbers:** Timeouts (15s, 5s), retry counts (3), and delays are literal; consider named constants at the top of the script.
- **Console:** Several `console.log`/`console.warn`/`console.error` calls are useful for debugging but could be gated by a `DEBUG` flag or removed for production if desired.
### 2.4 Accessibility & UX
- Nav links use `onclick` and `aria-label` where checked; focus and keyboard flow should be verified (e.g. tab order, Enter to activate).
- Error messages and retry buttons are visible; consider ensuring they are announced (e.g. live region) for screen readers.
- Dark theme is supported and persisted in `localStorage`.
---
## 3. React/Next.js App (`src/`)
### 3.1 Security
- **React escaping:** Components render props as text by default, so no raw `dangerouslySetInnerHTML` was found; Address, Card, Table, etc. are safe from XSS in normal use.
- **API client:** Uses `localStorage.getItem('api_key')` for `X-API-Key`; ensure key is not exposed in logs or error messages.
- **Env:** `NEXT_PUBLIC_*` is appropriate for client-side config; no secrets in frontend code.
### 3.2 Data Fetching & API Shape
**Issues:**
- **`addresses/[address].tsx`:** Uses `fetch()` then `response.json()` without checking `response.ok`. On 4xx/5xx, `data` may be an error body and `setAddressInfo(data.data)` can set invalid state. Recommend: check `response.ok`, handle errors, and only set state on success.
- **`blocksApi.list`:** Returns `ApiResponse<Block[]>` (expects `{ data: Block[] }`). If the backend returns a different shape (e.g. `{ items: [] }`), `response.data` may be `undefined` and `setBlocks(response.data)` can lead to runtime errors in `blocks.map()`. Align client with actual API response or normalize in the client.
- **Home page:** `loadRecentBlocks()` uses `blocksApi.list`; same shape assumption as above. `loadStats()` is a placeholder (no real API call).
### 3.3 Correctness
- **useEffect deps:** In `page.tsx`, `useEffect(..., [])` calls `loadStats` and `loadRecentBlocks` which are recreated each render. This is fine with empty deps (run once). In `blocks/index.tsx` and `transactions/index.tsx`, deps are `[page]`; `loadBlocks`/`loadTransactions` are not in the dependency array, which is intentional to avoid unnecessary runs; no bug found.
- **Block detail:** `blocks/[number].tsx` uses `parseInt((params?.number as string) ?? '0')` invalid or missing number becomes `NaN`/`0`; the API may return 404. Consider validating and showing a clear “Invalid block number” message.
- **Table key:** `Table.tsx` uses `key={rowIndex}`; if the list is reordered or filtered, prefer a stable key (e.g. `block.number`, `tx.hash`).
### 3.4 Consistency & Gaps
- **Routing:** Next.js app uses file-based routes (`/blocks`, `/transactions`, `/addresses/[address]`). The deployed SPA uses hash routing (`#/blocks`, `#/address/0x...`). They are separate; no conflict, but be aware that deep links differ between the two frontends.
- **API base:** React app uses `NEXT_PUBLIC_API_URL` (default `http://localhost:8080`). The vanilla SPA uses same-origin `/api` or Blockscout API. Ensure backend and env are aligned when running the Next app against a real API.
- **blocks API:** Only blocks are implemented in `services/api/`; transactions and addresses use raw `fetch` in pages. Consider moving to shared API modules and the same client for consistency and error handling.
---
## 4. Services & Config
### 4.1 API Client (`src/services/api/client.ts`)
- Axios instance with timeout (30s), JSON headers, and optional `X-API-Key` from `localStorage`.
- Response interceptor rejects with `error.response.data`; type is `ApiError`. Callers should handle both API error shape and network errors.
- **Note:** Used by blocks API only; other pages use `fetch` directly.
### 4.2 Blocks API (`src/services/api/blocks.ts`)
- Builds query params correctly; uses `apiClient.get<Block[]>`.
- Return type `ApiResponse<Block[]>` assumes backend returns `{ data: T }`. If backend is Blockscout-style (`items`, etc.), either add an adapter or document the expected backend contract.
### 4.3 Next.js Config
- `reactStrictMode: true`, `output: 'standalone'`.
- `NEXT_PUBLIC_API_URL` and `NEXT_PUBLIC_CHAIN_ID` defaulted in `next.config.js`; can be overridden by env.
---
## 5. Recommendations Summary
| Priority | Item | Action |
|----------|------|--------|
| **P1** | Address page fetch | In `addresses/[address].tsx`, check `response.ok` and handle non-2xx before parsing JSON and setting state. |
| **P1** | API response shape | Confirm backend response shape for blocks (and any shared APIs). Normalize to `{ data }` in client or document and handle `items`/other shapes. |
| **P2** | XSS hardening (SPA) | Use `escapeHtml(shortenHash(hash))` (and escape other API-derived strings) wherever content is written with `innerHTML`. Escape or encode `identifier` in breadcrumb `href`. |
| **P2** | RPC failover | Use `getRpcUrl()` inside `rpcCall()` (or for critical paths) so RPC failover is consistent. |
| **P2** | Blocks scroll animation | Cancel `requestAnimationFrame` when leaving the blocks view (e.g. in a cleanup or when switching view). |
| **P3** | SPA structure | Split script into modules or clearly grouped sections; extract shared constants and block-card markup. |
| **P3** | Table keys | Use stable keys (e.g. `block.number`, `tx.hash`) in list components instead of index. |
| **P3** | Block number validation | In `blocks/[number].tsx`, validate `params.number` and show a clear message for invalid or missing block number. |
---
## 6. Files Reviewed
- `public/index.html` full read and grep for escapeHtml, innerHTML, fetch, navigation, wallet.
- `src/app/layout.tsx`, `src/app/page.tsx`, `src/app/wallet/page.tsx`
- `src/pages/_app.tsx`, `src/pages/blocks/index.tsx`, `src/pages/blocks/[number].tsx`, `src/pages/transactions/index.tsx`, `src/pages/transactions/[hash].tsx`, `src/pages/addresses/[address].tsx`, `src/pages/search/index.tsx`
- `src/components/common/Card.tsx`, `Button.tsx`, `Table.tsx`
- `src/components/blockchain/Address.tsx`, `src/components/wallet/AddToMetaMask.tsx`
- `src/services/api/client.ts`, `src/services/api/blocks.ts`
- `package.json`, `next.config.js`

View File

@@ -0,0 +1,140 @@
# Frontend: Full Task List (Critical → Optional) + Detail Review
**Full parallel mode:** Tasks in the same tier can be executed in parallel by different owners. Dependencies are called out where a later task requires an earlier one.
---
## Quick reference (all tasks)
| Priority | ID | One-line description |
|----------|----|----------------------|
| Critical | C1 | Address page: check `response.ok` before setting state |
| Critical | C2 | Transaction detail page: check `response.ok` before setting state |
| Critical | C3 | Search page: check `response.ok` before setting results |
| Critical | C4 | Blocks API: confirm/normalize backend response shape `{ data }` |
| High | H1 | SPA: `escapeHtml(shortenHash(...))` and escape API text in all innerHTML |
| High | H2 | SPA: safe breadcrumb/href (encode identifier, contract, addr in URLs) |
| High | H3 | SPA: escape dynamic values in all onclick attributes |
| High | H4 | SPA: use `getRpcUrl()` in `rpcCall()` for failover |
| High | H5 | SPA: cancel blocks scroll `requestAnimationFrame` on view change |
| Medium | M1 | React: validate block number in `blocks/[number].tsx` |
| Medium | M2 | React: stable list keys (Table + search results) |
| Medium | M3 | SPA: named constants for timeouts/retries |
| Medium | M4 | Blocks API: normalizer for backend shape (if needed after C4) |
| Low | L1 | SPA: extract shared block card markup helper |
| Low | L2 | SPA: DEBUG flag for console logs |
| Low | L3 | A11y: live region for error messages |
| Low | L4 | React: shared API modules for transactions/addresses |
---
## Part 1: Task List (Critical → Optional)
### CRITICAL (P1) Fix first; blocks correct behavior or security
| ID | Task | Owner / parallel | Notes |
|----|------|------------------|--------|
| **C1** | **Address page: check `response.ok`** In `src/pages/addresses/[address].tsx`, before `response.json()` and `setAddressInfo(data.data)`, check `response.ok`. On non-2xx: do not set state; set error/empty and optionally `setLoading(false)`. | 1 | Prevents treating error body as address data. |
| **C2** | **Transaction detail page: check `response.ok`** In `src/pages/transactions/[hash].tsx`, same pattern: check `response.ok` before parsing and `setTransaction(data.data)`. Handle 404/5xx without setting transaction state. | 1 | Same bug as C1. |
| **C3** | **Search page: check `response.ok`** In `src/pages/search/index.tsx`, check `response.ok` before `setResults(data.results \|\| [])`. On failure, set empty results and optionally show a message. | 1 | Avoids showing error payload as results. |
| **C4** | **Blocks API response shape** Confirm backend for `/api/v1/blocks` returns `{ data: Block[] }` (or document contract). If it returns `{ items: [] }` or similar, add a normalizer in `src/services/api/blocks.ts` (e.g. `response.data = response.items ?? response.data`) so `response.data` is always an array for list. | 1 | Prevents `blocks.map` / `recentBlocks.map` runtime errors. |
**Parallel:** C1, C2, C3, C4 can all be done in parallel.
---
### HIGH (P2) Security and correctness; no known exploit but reduces risk
| ID | Task | Owner / parallel | Notes |
|----|------|------------------|--------|
| **H1** | **SPA: escape all `shortenHash(...)` in `innerHTML`** Every place that assigns to `innerHTML` and includes `shortenHash(hash|address|from|to|identifier|...)` must use `escapeHtml(shortenHash(...))`. Locations (grep): breadcrumbs (2243, 2247, 2254, 2259, 2280, 2284, 2288, 2292), block cards (2628), transaction rows (27942796, 2909, 29942996), bridge table (3108, 3144), internal tx/logs (3526, 3543), token balances (3746), address internal txs (3774), address tx table (3832), token detail (3887, 3905), NFT detail (3935, 3943), statusEl (1639, 1722). Also escape any other API-derived text in those same innerHTML strings (e.g. `displayBalance`, `val` if from API). | 1 | XSS defense in depth. |
| **H2** | **SPA: safe breadcrumb and `href` attributes** In `updateBreadcrumb`, ensure `identifier` in `href="#/address/' + identifier + '"` cannot break the attribute. Use a safe encoding (e.g. `encodeURIComponent(identifier)` for the path segment, or ensure identifier is validated hex address). Same for token/nft breadcrumb links. | 1 | Prevents attribute breakout. |
| **H3** | **SPA: safe `onclick` attribute values** Every `onclick="showAddressDetail('...')"` / `showBlockDetail` / `showTransactionDetail` that injects a dynamic value (address, hash, block number) must use an escaped value so a quote in the value cannot break the attribute. Use `escapeHtml(address)` (or equivalent) for the value inside the quoted string. Applies to all dynamic onclick handlers in index.html (see grep list: 3108, 3144, 3526, 3543, 3638, 3746, 3774, 3832, 3887, 3905, 3935, 3943, and template literals with `${address}`, `${hash}`, `${from}`, `${to}`, etc.). | 1 | Prevents attribute injection / XSS. |
| **H4** | **RPC failover in `rpcCall`** In `public/index.html`, change `rpcCall` to use `getRpcUrl()` (await) instead of a single RPC_IP/RPC_FQDN, so RPC calls benefit from the same health-check and failover as elsewhere. | 1 | Consistency and resilience. |
| **H5** | **Blocks scroll: cancel `requestAnimationFrame` on view change** When leaving the blocks view (or when `loadLatestBlocks` runs again), cancel the animation loop. Store the `requestAnimationFrame` id (e.g. in a variable or on `scrollContainer.dataset`) and call `cancelAnimationFrame(id)` when switching view or before re-running the block list render. | 1 | Avoids leaking animation loop and unnecessary work. |
**Parallel:** H1, H2, H3 can be done together; H4 and H5 are independent and can run in parallel with each other and with H1H3.
---
### MEDIUM (P3) Quality and maintainability
| ID | Task | Owner / parallel | Notes |
|----|------|------------------|--------|
| **M1** | **Block number validation (React)** In `src/pages/blocks/[number].tsx`, validate `params.number`: reject non-numeric or NaN; show a clear “Invalid block number” (or “Block number required”) message instead of calling API with 0. | 1 | Better UX. |
| **M2** | **Stable list keys (React)** In `src/components/common/Table.tsx`, accept an optional `keyExtractor` prop; in pages that use Table (e.g. addresses, blocks), pass a stable key (e.g. `tx.hash`, `block.number`). In search results, use `key={result.data?.hash ?? result.data?.address ?? index}` instead of `key={index}`. | 1 | Avoids React reconciliation issues. |
| **M3** | **SPA: named constants** In `public/index.html`, replace magic numbers with named constants at the top of the script: e.g. `FETCH_TIMEOUT_MS = 15000`, `RPC_HEALTH_TIMEOUT_MS = 5000`, `FETCH_MAX_RETRIES = 3`, `RETRY_DELAY_MS = 1000`. | 1 | Maintainability. |
| **M4** | **API response normalization (blocks)** If backend is Blockscout-style, add in `blocks.ts` (or client): normalize `{ items: [] }``{ data: [] }` so all consumers get a consistent shape without each page handling both. | 1 | Depends on C4 decision; can follow C4. |
**Parallel:** M1, M2, M3, M4 can be done in parallel (M4 after C4 if adding normalizer).
---
### LOW / OPTIONAL (P4)
| ID | Task | Owner / parallel | Notes |
|----|------|------------------|--------|
| **L1** | **SPA: extract shared block card markup** Introduce a single helper (e.g. `createBlockCardHtml(block, options)`) used by home stats, blocks list, and any other block card HTML to reduce duplication. | 1 | Maintainability. |
| **L2** | **SPA: DEBUG flag for console** Gate `console.log`/`console.warn` (and optionally `console.error`) behind a flag (e.g. `window.DEBUG_EXPLORER`) so production builds can suppress verbose logs. | 1 | Optional. |
| **L3** | **A11y: live region for errors** Add an `aria-live="polite"` (or `assertive`) region for error messages and retry buttons so screen readers announce them. | 1 | Accessibility. |
| **L4** | **Shared API modules (React)** Add `src/services/api/transactions.ts` and `addresses.ts` (or similar) using the same `apiClient`, and refactor `addresses/[address].tsx` and `transactions/[hash].tsx` to use them with consistent error handling. | 1 | Consistency. |
**Parallel:** L1L4 independent; all optional.
---
## Part 2: Detail Review Misses, Gaps, Additional Fixes
### 2.1 Additional React fetch bugs (misses from original review)
- **`transactions/[hash].tsx`** Same pattern as addresses: no `response.ok` check; `setTransaction(data.data)` can run on 4xx/5xx. **Action:** Add to critical list as **C2** (done above).
- **`search/index.tsx`** No `response.ok` check; `setResults(data.results || [])` can set error payload. **Action:** Add as **C3** (done above).
### 2.2 SPA: Gaps in escapeHtml / innerHTML
- **Wallet status (1639, 1722)** `statusEl.innerHTML` uses `shortenHash(userAddress)`. If `userAddress` were ever from an untrusted source, it should be escaped. **Action:** Use `escapeHtml(shortenHash(userAddress))` for consistency (in **H1**).
- **loadGasAndNetworkStats (2509)** `el.innerHTML` uses `gasGwei`, `blockTimeSec`, `tps`. These are from API; escaping is low risk but recommended for defense in depth. **Action:** Escape these values (in **H1** or small follow-up).
- **Token list: `#/token/' + contract`** The `contract` in `href="#/token/' + contract + '"` can break the attribute if it contains a quote. **Action:** Encode or validate; include in **H2** (safe href/attributes).
- **External link (3800)** `'https://explorer.d-bis.org/address/' + addr + '/contract'` `addr` should be validated or encoded so the URL cannot be malformed. **Action:** Use `encodeURIComponent(addr)` for the path segment (in **H2**).
### 2.3 SPA: onclick and attribute injection
- **All dynamic onclick handlers** Values like `address`, `hash`, `from`, `to`, `block`, `txHash`, `contract`, `contractAddress` are interpolated into onclick strings. A single quote in any of them breaks the attribute and can lead to script execution. **Action:** Consistently use `escapeHtml(value)` for every dynamic part inside the quoted argument (in **H3**). Example: `onclick="showAddressDetail('" + escapeHtml(address) + "')"` (and equivalent for template literals).
### 2.4 SPA: requestAnimationFrame leak (clarification)
- The blocks scroll animation starts inside `loadLatestBlocks()` and is never cancelled. When the user navigates to Transactions or Home, the blocks view is hidden but the animation loop keeps running. **Action:** Store the rAF id (e.g. `let scrollAnimationId` in a scope that can be cleared when switching view). When `switchToView` or the blocks view is left, or before the next `loadLatestBlocks` run, call `cancelAnimationFrame(scrollAnimationId)`. Optionally clear the interval/timeout if any. **In task H5.**
### 2.5 React: API shape and Table key (additional)
- **Home page `recentBlocks`** Uses `response.data` from `blocksApi.list`; if `response.data` is undefined (e.g. backend returns `items`), `recentBlocks.map` will throw. **Action:** Covered by **C4** and **M4** (normalize in client).
- **Table key** Using `rowIndex` is acceptable for static lists but can cause unnecessary re-renders or focus issues when list order changes. **Action:** **M2** (stable keys).
- **Search results key** `key={index}` is weak when results can change; prefer `key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index}`. **Action:** Include in **M2**.
### 2.6 Additional checks performed
- **rpcCall** Confirmed it does not use `getRpcUrl()`; single URL used. **Action:** **H4**.
- **Breadcrumb identifier** Multiple places use `identifier` in innerHTML and in `href`; both display and href need to be safe. **Action:** **H1**, **H2**.
- **No other raw `dangerouslySetInnerHTML`** in React components; no additional React XSS findings.
- **transactions/[hash].tsx** No validation of `hash` format (e.g. 0x + 64 hex); invalid hash could trigger odd API/UI behavior. Optional: add validation and show “Invalid transaction hash” (can be P4).
### 2.7 Summary of additional fixes required
| Category | Fix |
|----------|-----|
| React fetch | C2 (transaction detail), C3 (search) check `response.ok` and handle errors. |
| SPA innerHTML | H1 include statusEl (1639, 1722), loadGasAndNetworkStats (2509); escape all shortenHash and API-derived text. |
| SPA href/attributes | H2 encode/validate `identifier` and `contract` in hrefs; encode `addr` in external URL. |
| SPA onclick | H3 escape every dynamic value in onclick (address, hash, from, to, block, txHash, contract, etc.). |
| SPA rAF | H5 store and cancel requestAnimationFrame when leaving blocks view or re-running loadLatestBlocks. |
| Optional | Validate tx hash in transactions/[hash].tsx (P4); add transactions/addresses API modules (L4). |
---
## Part 3: Execution Order (Full Parallel Mode)
- **Wave 1 (all parallel):** C1, C2, C3, C4, H1, H2, H3, H4, H5.
- **Wave 2 (after C4/M4 if normalizer added):** M1, M2, M3, M4.
- **Wave 3 (optional):** L1, L2, L3, L4.
No task in Wave 2 or 3 blocks another within the same wave; only M4 may depend on C4s decision (normalize in client vs. backend contract).

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.9 KiB

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

14
frontend/public/icon.svg Normal file
View File

@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="bg" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#667eea"/>
<stop offset="100%" style="stop-color:#764ba2"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="12" fill="url(#bg)"/>
<!-- Magnifying glass (scout) -->
<circle cx="28" cy="28" r="14" fill="none" stroke="white" stroke-width="4"/>
<line x1="38" y1="38" x2="50" y2="50" stroke="white" stroke-width="4" stroke-linecap="round"/>
<!-- Small block link accent -->
<rect x="12" y="44" width="10" height="10" rx="2" fill="rgba(255,255,255,0.5)"/>
</svg>

After

Width:  |  Height:  |  Size: 692 B

File diff suppressed because it is too large Load Diff

View File

@@ -11,9 +11,11 @@ interface TableProps<T> {
columns: Column<T>[] columns: Column<T>[]
data: T[] data: T[]
className?: string className?: string
/** Stable key for each row (e.g. row => row.id or row => row.hash). Falls back to index if not provided. */
keyExtractor?: (row: T) => string | number
} }
export function Table<T>({ columns, data, className }: TableProps<T>) { export function Table<T>({ columns, data, className, keyExtractor }: TableProps<T>) {
return ( return (
<div className={clsx('overflow-x-auto', className)}> <div className={clsx('overflow-x-auto', className)}>
<table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700"> <table className="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
@@ -34,7 +36,7 @@ export function Table<T>({ columns, data, className }: TableProps<T>) {
</thead> </thead>
<tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700"> <tbody className="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{data.map((row, rowIndex) => ( {data.map((row, rowIndex) => (
<tr key={rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800"> <tr key={keyExtractor ? keyExtractor(row) : rowIndex} className="hover:bg-gray-50 dark:hover:bg-gray-800">
{columns.map((column, colIndex) => ( {columns.map((column, colIndex) => (
<td <td
key={colIndex} key={colIndex}

View File

@@ -5,25 +5,7 @@ import { useParams } from 'next/navigation'
import { Card } from '@/components/common/Card' import { Card } from '@/components/common/Card'
import { Address } from '@/components/blockchain/Address' import { Address } from '@/components/blockchain/Address'
import { Table } from '@/components/common/Table' import { Table } from '@/components/common/Table'
import { addressesApi, AddressInfo, TransactionSummary } from '@/services/api/addresses'
interface AddressInfo {
address: string
chain_id: number
transaction_count: number
token_count: number
is_contract: boolean
label?: string
tags: string[]
}
interface Transaction {
hash: string
block_number: number
from_address: string
to_address?: string
value: string
status?: number
}
export default function AddressDetailPage() { export default function AddressDetailPage() {
const params = useParams() const params = useParams()
@@ -31,7 +13,7 @@ export default function AddressDetailPage() {
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null) const [addressInfo, setAddressInfo] = useState<AddressInfo | null>(null)
const [transactions, setTransactions] = useState<Transaction[]>([]) const [transactions, setTransactions] = useState<TransactionSummary[]>([])
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
@@ -41,25 +23,21 @@ export default function AddressDetailPage() {
const loadAddressInfo = async () => { const loadAddressInfo = async () => {
try { try {
const response = await fetch( const response = await addressesApi.get(chainId, address)
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/addresses/${chainId}/${address}` setAddressInfo(response.data ?? null)
)
const data = await response.json()
setAddressInfo(data.data)
} catch (error) { } catch (error) {
console.error('Failed to load address info:', error) console.error('Failed to load address info:', error)
setAddressInfo(null)
} }
} }
const loadTransactions = async () => { const loadTransactions = async () => {
try { try {
const response = await fetch( const response = await addressesApi.getTransactions(chainId, address, 1, 20)
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions?chain_id=${chainId}&from_address=${address}&page=1&page_size=20` setTransactions(response.data || [])
)
const data = await response.json()
setTransactions(data.data || [])
} catch (error) { } catch (error) {
console.error('Failed to load transactions:', error) console.error('Failed to load transactions:', error)
setTransactions([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -76,7 +54,7 @@ export default function AddressDetailPage() {
const transactionColumns = [ const transactionColumns = [
{ {
header: 'Hash', header: 'Hash',
accessor: (tx: Transaction) => ( accessor: (tx: TransactionSummary) => (
<a href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline"> <a href={`/transactions/${tx.hash}`} className="text-primary-600 hover:underline">
<Address address={tx.hash} truncate /> <Address address={tx.hash} truncate />
</a> </a>
@@ -84,15 +62,15 @@ export default function AddressDetailPage() {
}, },
{ {
header: 'Block', header: 'Block',
accessor: (tx: Transaction) => tx.block_number, accessor: (tx: TransactionSummary) => tx.block_number,
}, },
{ {
header: 'To', header: 'To',
accessor: (tx: Transaction) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation', accessor: (tx: TransactionSummary) => tx.to_address ? <Address address={tx.to_address} truncate /> : 'Contract Creation',
}, },
{ {
header: 'Value', header: 'Value',
accessor: (tx: Transaction) => { accessor: (tx: TransactionSummary) => {
const value = BigInt(tx.value) const value = BigInt(tx.value)
const eth = Number(value) / 1e18 const eth = Number(value) / 1e18
return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH' return eth > 0 ? `${eth.toFixed(4)} ETH` : '0 ETH'
@@ -100,7 +78,7 @@ export default function AddressDetailPage() {
}, },
{ {
header: 'Status', header: 'Status',
accessor: (tx: Transaction) => ( accessor: (tx: TransactionSummary) => (
<span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}> <span className={tx.status === 1 ? 'text-green-600' : 'text-red-600'}>
{tx.status === 1 ? 'Success' : 'Failed'} {tx.status === 1 ? 'Success' : 'Failed'}
</span> </span>
@@ -148,7 +126,7 @@ export default function AddressDetailPage() {
</Card> </Card>
<Card title="Transactions"> <Card title="Transactions">
<Table columns={transactionColumns} data={transactions} /> <Table columns={transactionColumns} data={transactions} keyExtractor={(tx) => tx.hash} />
</Card> </Card>
</div> </div>
) )

View File

@@ -9,15 +9,22 @@ import Link from 'next/link'
export default function BlockDetailPage() { export default function BlockDetailPage() {
const params = useParams() const params = useParams()
const blockNumber = parseInt((params?.number as string) ?? '0') const rawNumber = (params?.number as string) ?? ''
const blockNumber = parseInt(rawNumber, 10)
const isValidBlock = rawNumber !== '' && !Number.isNaN(blockNumber) && blockNumber >= 0
const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138') const chainId = parseInt(process.env.NEXT_PUBLIC_CHAIN_ID || '138')
const [block, setBlock] = useState<Block | null>(null) const [block, setBlock] = useState<Block | null>(null)
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
useEffect(() => { useEffect(() => {
if (!isValidBlock) {
setLoading(false)
setBlock(null)
return
}
loadBlock() loadBlock()
}, [blockNumber]) }, [blockNumber, isValidBlock])
const loadBlock = async () => { const loadBlock = async () => {
setLoading(true) setLoading(true)
@@ -31,6 +38,10 @@ export default function BlockDetailPage() {
} }
} }
if (!isValidBlock) {
return <div className="p-8">Invalid block number. Please use a valid block number from the URL.</div>
}
if (loading) { if (loading) {
return <div className="p-8">Loading block...</div> return <div className="p-8">Loading block...</div>
} }

View File

@@ -32,9 +32,14 @@ export default function SearchPage() {
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(query)}` `${process.env.NEXT_PUBLIC_API_URL}/api/v1/search?q=${encodeURIComponent(query)}`
) )
const data = await response.json() const data = await response.json()
if (!response.ok) {
setResults([])
return
}
setResults(data.results || []) setResults(data.results || [])
} catch (error) { } catch (error) {
console.error('Search failed:', error) console.error('Search failed:', error)
setResults([])
} finally { } finally {
setLoading(false) setLoading(false)
} }
@@ -67,7 +72,7 @@ export default function SearchPage() {
<Card title="Search Results"> <Card title="Search Results">
<div className="space-y-4"> <div className="space-y-4">
{results.map((result, index) => ( {results.map((result, index) => (
<div key={index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0"> <div key={result.data?.hash ?? result.data?.address ?? result.data?.number ?? index} className="border-b border-gray-200 dark:border-gray-700 pb-4 last:border-0">
{result.type === 'block' && result.data.number && ( {result.type === 'block' && result.data.number && (
<Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline"> <Link href={`/blocks/${result.data.number}`} className="text-primary-600 hover:underline">
Block #{result.data.number} Block #{result.data.number}

View File

@@ -5,26 +5,7 @@ import { useParams } from 'next/navigation'
import { Card } from '@/components/common/Card' import { Card } from '@/components/common/Card'
import { Address } from '@/components/blockchain/Address' import { Address } from '@/components/blockchain/Address'
import Link from 'next/link' import Link from 'next/link'
import { transactionsApi, Transaction } from '@/services/api/transactions'
interface Transaction {
chain_id: number
hash: string
block_number: number
block_hash: string
transaction_index: number
from_address: string
to_address?: string
value: string
gas_price?: number
max_fee_per_gas?: number
max_priority_fee_per_gas?: number
gas_limit: number
gas_used?: number
status?: number
input_data?: string
contract_address?: string
created_at: string
}
export default function TransactionDetailPage() { export default function TransactionDetailPage() {
const params = useParams() const params = useParams()
@@ -41,13 +22,11 @@ export default function TransactionDetailPage() {
const loadTransaction = async () => { const loadTransaction = async () => {
setLoading(true) setLoading(true)
try { try {
const response = await fetch( const response = await transactionsApi.get(chainId, hash)
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/transactions/${chainId}/${hash}` setTransaction(response.data ?? null)
)
const data = await response.json()
setTransaction(data.data)
} catch (error) { } catch (error) {
console.error('Failed to load transaction:', error) console.error('Failed to load transaction:', error)
setTransaction(null)
} finally { } finally {
setLoading(false) setLoading(false)
} }

View File

@@ -0,0 +1,53 @@
import { apiClient, ApiResponse } from './client'
export interface AddressInfo {
address: string
chain_id: number
transaction_count: number
token_count: number
is_contract: boolean
label?: string
tags: string[]
}
export interface AddressTransactionsParams {
chain_id: number
from_address: string
page?: number
page_size?: number
}
export interface TransactionSummary {
hash: string
block_number: number
from_address: string
to_address?: string
value: string
status?: number
}
export const addressesApi = {
get: async (chainId: number, address: string): Promise<ApiResponse<AddressInfo>> => {
return apiClient.get<AddressInfo>(`/api/v1/addresses/${chainId}/${address}`)
},
getTransactions: async (
chainId: number,
address: string,
page = 1,
pageSize = 20
): Promise<ApiResponse<TransactionSummary[]>> => {
const params = new URLSearchParams({
chain_id: chainId.toString(),
from_address: address,
page: page.toString(),
page_size: pageSize.toString(),
})
const raw = (await apiClient.get(`/api/v1/transactions?${params.toString()}`)) as unknown as {
data?: TransactionSummary[]
items?: TransactionSummary[]
}
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
return { data }
},
}

View File

@@ -22,6 +22,12 @@ export interface BlockListParams {
order?: 'asc' | 'desc' order?: 'asc' | 'desc'
} }
/** Normalize list response: backend may return { data: T[] } or { items: T[] }. */
function normalizeListResponse<T>(raw: { data?: T[]; items?: T[] }): ApiResponse<T[]> {
const data = Array.isArray(raw?.data) ? raw.data : Array.isArray(raw?.items) ? raw.items : []
return { data }
}
export const blocksApi = { export const blocksApi = {
list: async (params: BlockListParams): Promise<ApiResponse<Block[]>> => { list: async (params: BlockListParams): Promise<ApiResponse<Block[]>> => {
const queryParams = new URLSearchParams() const queryParams = new URLSearchParams()
@@ -34,7 +40,8 @@ export const blocksApi = {
if (params.sort) queryParams.append('sort', params.sort) if (params.sort) queryParams.append('sort', params.sort)
if (params.order) queryParams.append('order', params.order) if (params.order) queryParams.append('order', params.order)
return apiClient.get<Block[]>(`/api/v1/blocks?${queryParams.toString()}`) const raw = (await apiClient.get(`/api/v1/blocks?${queryParams.toString()}`)) as unknown as { data?: Block[]; items?: Block[] }
return normalizeListResponse(raw)
}, },
getByNumber: async (chainId: number, number: number): Promise<ApiResponse<Block>> => { getByNumber: async (chainId: number, number: number): Promise<ApiResponse<Block>> => {

View File

@@ -0,0 +1,27 @@
import { apiClient, ApiResponse } from './client'
export interface Transaction {
chain_id: number
hash: string
block_number: number
block_hash: string
transaction_index: number
from_address: string
to_address?: string
value: string
gas_price?: number
max_fee_per_gas?: number
max_priority_fee_per_gas?: number
gas_limit: number
gas_used?: number
status?: number
input_data?: string
contract_address?: string
created_at: string
}
export const transactionsApi = {
get: async (chainId: number, hash: string): Promise<ApiResponse<Transaction>> => {
return apiClient.get<Transaction>(`/api/v1/transactions/${chainId}/${hash}`)
},
}

View File

@@ -11,6 +11,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
FRONTEND_SOURCE="${REPO_ROOT}/explorer-monorepo/frontend/public/index.html" FRONTEND_SOURCE="${REPO_ROOT}/explorer-monorepo/frontend/public/index.html"
[ -f "$FRONTEND_SOURCE" ] || FRONTEND_SOURCE="${SCRIPT_DIR}/../frontend/public/index.html" [ -f "$FRONTEND_SOURCE" ] || FRONTEND_SOURCE="${SCRIPT_DIR}/../frontend/public/index.html"
FRONTEND_PUBLIC="$(dirname "$FRONTEND_SOURCE")"
PROXMOX_R630_02="${PROXMOX_HOST_R630_02:-192.168.11.12}" PROXMOX_R630_02="${PROXMOX_HOST_R630_02:-192.168.11.12}"
echo "==========================================" echo "=========================================="
@@ -77,7 +78,45 @@ else
fi fi
echo "" echo ""
# Step 5: Update nginx (skip when remote; full config via ensure-explorer-nginx-api-proxy.sh) # Step 4b: Deploy favicon and apple-touch-icon
echo "=== Step 4b: Deploying icons ==="
for ASSET in apple-touch-icon.png favicon.ico; do
SRC="${FRONTEND_PUBLIC}/${ASSET}"
if [ ! -f "$SRC" ]; then
echo "⚠️ Skip $ASSET (not found)"
continue
fi
if [ "$DEPLOY_METHOD" = "direct" ]; then
cp "$SRC" "/var/www/html/$ASSET"
chown www-data:www-data "/var/www/html/$ASSET" 2>/dev/null || true
echo "$ASSET deployed"
elif [ "$DEPLOY_METHOD" = "remote" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$SRC" root@${PROXMOX_R630_02}:/tmp/"$ASSET"
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct push $VMID /tmp/$ASSET /var/www/html/$ASSET --perms 0644 && pct exec $VMID -- chown www-data:www-data /var/www/html/$ASSET"
echo "$ASSET deployed via $PROXMOX_R630_02"
else
pct push $VMID "$SRC" "/var/www/html/$ASSET"
$EXEC_PREFIX chown www-data:www-data "/var/www/html/$ASSET" 2>/dev/null || true
echo "$ASSET deployed"
fi
done
echo ""
# Step 5 (remote): Apply nginx config so /favicon.ico and /apple-touch-icon.png are served
if [ "$DEPLOY_METHOD" = "remote" ]; then
echo "=== Step 5 (remote): Applying nginx config for icons ==="
FIX_NGINX_SCRIPT="${REPO_ROOT}/explorer-monorepo/scripts/fix-nginx-serve-custom-frontend.sh"
if [ -f "$FIX_NGINX_SCRIPT" ]; then
scp -o ConnectTimeout=10 -o StrictHostKeyChecking=no "$FIX_NGINX_SCRIPT" root@${PROXMOX_R630_02}:/tmp/fix-nginx-explorer.sh
ssh -o ConnectTimeout=10 -o StrictHostKeyChecking=no root@${PROXMOX_R630_02} "pct push $VMID /tmp/fix-nginx-explorer.sh /tmp/fix-nginx-explorer.sh --perms 0755 && pct exec $VMID -- /tmp/fix-nginx-explorer.sh"
echo "✅ Nginx config applied (favicon and apple-touch-icon locations)"
else
echo "⚠️ Nginx fix script not found ($FIX_NGINX_SCRIPT); icons may still 404 until nginx is updated on VM"
fi
echo ""
fi
# Step 5 (local/pct): Update nginx configuration
if [ "$DEPLOY_METHOD" != "remote" ]; then if [ "$DEPLOY_METHOD" != "remote" ]; then
echo "=== Step 5: Updating nginx configuration ===" echo "=== Step 5: Updating nginx configuration ==="
$EXEC_PREFIX bash << 'NGINX_UPDATE' $EXEC_PREFIX bash << 'NGINX_UPDATE'
@@ -158,8 +197,8 @@ else
exit 1 exit 1
fi fi
# Test HTTP endpoint # Test HTTP endpoint (non-fatal: do not exit on failure)
HTTP_RESPONSE=$(run_in_vm "curl -s http://localhost/ 2>/dev/null | head -5") HTTP_RESPONSE=$(run_in_vm "curl -s --max-time 5 http://localhost/ 2>/dev/null | head -5" 2>/dev/null) || true
if echo "$HTTP_RESPONSE" | grep -q "SolaceScanScout\|<!DOCTYPE html"; then if echo "$HTTP_RESPONSE" | grep -q "SolaceScanScout\|<!DOCTYPE html"; then
echo "✅ Frontend is accessible via nginx" echo "✅ Frontend is accessible via nginx"
else else