diff --git a/Dockerfile b/Dockerfile
index 6818f4f2..6b02782a 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -61,19 +61,12 @@ RUN . ~/.bash_profile \
&& poetry config virtualenvs.create false
# install python packages
-USER apl
-COPY pyproject.toml /app/pyproject.toml
-COPY poetry.lock /app/poetry.lock
-RUN . ~/.bash_profile \
- && poetry install --directory /app --only main --no-root \
- && rm -f /app/pyproject.toml \
- && rm -f /app/poetry.lock
-
-# app deploy
USER root
COPY --chown=apl:apl LICENSE /app/ibet-Prime/
RUN mkdir -p /app/ibet-Prime/bin/
COPY --chown=apl:apl bin/ /app/ibet-Prime/bin/
+RUN mkdir -p /app/ibet-Prime/cmd/
+COPY --chown=apl:apl cmd/ /app/ibet-Prime/cmd/
RUN mkdir -p /app/ibet-Prime/contracts/
COPY --chown=apl:apl contracts/ /app/ibet-Prime/contracts/
RUN mkdir -p /app/ibet-Prime/conf/
@@ -88,6 +81,15 @@ COPY --chown=apl:apl app/ /app/ibet-Prime/app/
RUN find /app/ibet-Prime/ -type d -name __pycache__ | xargs rm -fr \
&& chmod -R 755 /app/ibet-Prime/
+USER apl
+COPY pyproject.toml /app/ibet-Prime/pyproject.toml
+COPY poetry.lock /app/ibet-Prime/poetry.lock
+RUN . ~/.bash_profile \
+ && cd /app/ibet-Prime \
+ && poetry install --only main --no-root -E ibet-explorer \
+ && rm -f /app/ibet-Prime/pyproject.toml \
+ && rm -f /app/ibet-Prime/poetry.lock
+
# command deploy
USER apl
COPY run.sh healthcheck.sh /app/
diff --git a/README.md b/README.md
index bf44471b..ea8ae59e 100644
--- a/README.md
+++ b/README.md
@@ -50,7 +50,7 @@ English | [日本語](./README_JA.md)
Install python packages with:
```bash
-$ pip install -r requirements.txt
+$ poetry install --no-root --only main -E explorer
```
### Setting environment variables
@@ -68,13 +68,13 @@ The main environment variables are as follows.
DATABASE_URL |
False |
Database URL |
- postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb |
+ postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb |
TEST_DATABASE_URL |
False |
Test database URL |
- postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb |
+ postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb |
DATABASE_SCHEMA |
diff --git a/README_JA.md b/README_JA.md
index 792b4200..6cbd3e76 100644
--- a/README_JA.md
+++ b/README_JA.md
@@ -51,7 +51,7 @@
以下のコマンドで Python パッケージをインストールします。
```bash
-$ pip install -r requirements.txt
+$ poetry install --no-root --only main -E explorer
```
### 環境変数の設定
@@ -69,13 +69,13 @@ $ pip install -r requirements.txt
DATABASE_URL |
False |
データベース URL |
- postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb |
+ postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb |
TEST_DATABASE_URL |
False |
テスト用データベース URL |
- postgresql://issuerapi:issuerapipass@localhost:5432/issuerapidb |
+ postgresql+psycopg://issuerapi:issuerapipass@localhost:5432/issuerapidb |
DATABASE_SCHEMA |
diff --git a/app/model/schema/__init__.py b/app/model/schema/__init__.py
index c1ccbd5e..c8530f01 100644
--- a/app/model/schema/__init__.py
+++ b/app/model/schema/__init__.py
@@ -41,8 +41,10 @@
# Response
BlockDataResponse,
BlockDataListResponse,
+ BlockDataDetail,
TxDataResponse,
- TxDataListResponse
+ TxDataListResponse,
+ TxDataDetail
)
from .bulk_transfer import (
# Response
diff --git a/app/model/schema/bc_explorer.py b/app/model/schema/bc_explorer.py
index 322fbd4e..3233ebfa 100644
--- a/app/model/schema/bc_explorer.py
+++ b/app/model/schema/bc_explorer.py
@@ -28,7 +28,10 @@
from pydantic.dataclasses import dataclass
from web3 import Web3
-from .types import ResultSet
+from .types import (
+ ResultSet,
+ SortOrder
+)
############################
@@ -44,6 +47,7 @@ class BlockData(BaseModel):
gas_used: int
size: NonNegativeInt
+
class BlockDataDetail(BaseModel):
number: NonNegativeInt = Field(description="Block number")
parent_hash: str
@@ -64,6 +68,7 @@ class BlockDataDetail(BaseModel):
size: NonNegativeInt
transactions: list[str] = Field(description="Transaction list")
+
class TxData(BaseModel):
hash: str = Field(description="Transaction hash")
block_hash: str
@@ -72,6 +77,7 @@ class TxData(BaseModel):
from_address: str
to_address: Optional[str]
+
class TxDataDetail(BaseModel):
hash: str = Field(description="Transaction hash")
block_hash: str
@@ -87,6 +93,7 @@ class TxDataDetail(BaseModel):
value: NonNegativeInt
nonce: NonNegativeInt
+
############################
# REQUEST
############################
@@ -97,6 +104,8 @@ class ListBlockDataQuery:
limit: Optional[NonNegativeInt] = Query(default=None, description="number of set")
from_block_number: Optional[NonNegativeInt] = Query(default=None)
to_block_number: Optional[NonNegativeInt] = Query(default=None)
+ sort_order: Optional[SortOrder] = Query(default=SortOrder.ASC, description="sort order(0: ASC, 1: DESC)")
+
@dataclass
class ListTxDataQuery:
@@ -120,6 +129,7 @@ def to_address_is_valid_address(cls, v):
raise ValueError("to_address is not a valid address")
return v
+
############################
# RESPONSE
############################
@@ -127,13 +137,16 @@ def to_address_is_valid_address(cls, v):
class BlockDataResponse(BaseModel):
__root__: BlockDataDetail
+
class BlockDataListResponse(BaseModel):
result_set: ResultSet
block_data: list[BlockData]
+
class TxDataResponse(BaseModel):
__root__: TxDataDetail
+
class TxDataListResponse(BaseModel):
result_set: ResultSet
tx_data: list[TxData]
diff --git a/app/routers/bc_explorer.py b/app/routers/bc_explorer.py
index 43f0e649..83d21e7e 100644
--- a/app/routers/bc_explorer.py
+++ b/app/routers/bc_explorer.py
@@ -16,18 +16,20 @@
SPDX-License-Identifier: Apache-2.0
"""
-from pathlib import Path
from typing import Tuple, Dict, Any, Type
from eth_utils import to_checksum_address
from fastapi import (
APIRouter,
- Depends, HTTPException
+ Depends,
+ HTTPException,
+ Path
)
from pydantic import NonNegativeInt
from sqlalchemy import desc
from sqlalchemy.orm import Session
from web3.contract import ContractFunction
+import config
from app import log
from app.database import db_session
@@ -36,7 +38,9 @@
)
from app.model.db import (
IDXBlockData,
- IDXTxData, Token
+ IDXTxData,
+ Token,
+ IDXBlockDataBlockNumber
)
from app.model.schema import (
ListBlockDataQuery,
@@ -90,9 +94,27 @@ def list_block_data(
limit = request_query.limit
from_block_number = request_query.from_block_number
to_block_number = request_query.to_block_number
+ sort_order = request_query.sort_order # default: asc
+
+ # NOTE: The more data, the slower the SELECT COUNT(1) query becomes.
+ # To get total number of block data, latest block number where block data synced is used here.
+ idx_block_data_block_number = (
+ session.query(IDXBlockDataBlockNumber).filter(IDXBlockDataBlockNumber.chain_id == str(config.CHAIN_ID)).first()
+ )
+ if idx_block_data_block_number is None:
+ return json_response({
+ "result_set": {
+ "count": 0,
+ "offset": offset,
+ "limit": limit,
+ "total": 0
+ },
+ "block_data": []
+ })
+
+ total = idx_block_data_block_number.latest_block_number + 1
query = session.query(IDXBlockData)
- total = query.count()
# Search Filter
if from_block_number is not None and to_block_number is not None:
@@ -105,7 +127,10 @@ def list_block_data(
count = query.count()
# Sort
- query = query.order_by(IDXBlockData.number)
+ if sort_order == 0:
+ query = query.order_by(IDXBlockData.number)
+ else:
+ query = query.order_by(desc(IDXBlockData.number))
# Pagination
if limit is not None:
diff --git a/cmd/explorer/Makefile b/cmd/explorer/Makefile
new file mode 100644
index 00000000..7fe492ed
--- /dev/null
+++ b/cmd/explorer/Makefile
@@ -0,0 +1,21 @@
+.PHONY: isort black test run
+
+format: isort black
+
+isort:
+ isort src/.
+
+black:
+ poetry run black src
+
+test:
+ pytest .
+
+console:
+ textual console
+
+dev:
+ TEXTUAL=devtools poetry run python src/main.py
+
+run:
+ poetry run python src/main.py
\ No newline at end of file
diff --git a/cmd/explorer/README.md b/cmd/explorer/README.md
new file mode 100644
index 00000000..188d29f2
--- /dev/null
+++ b/cmd/explorer/README.md
@@ -0,0 +1,44 @@
+# ibet-Prime BC-Explorer
+
+## Run
+
+### with container
+
+```bash
+> docker exec -it -e "TERM=xterm-256color" ibet-prime-app bash --login
+> apl@2e5a80e06fcb:/$ ibet-explorer --help
+
+ Usage: ibet-explorer [OPTIONS]
+
+╭─ Options ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮
+│ --url TEXT ibet-Prime server URL to connect [default: http://localhost:5000] │
+│ --lot-size INTEGER Lot size to fetch Block Data list [default: 100] │
+│ --install-completion [bash|zsh|fish|powershell|pwsh] Install completion for the specified shell. [default: None] │
+│ --show-completion [bash|zsh|fish|powershell|pwsh] Show completion for the specified shell, to copy it or customize the installation. [default: None] │
+│ --help Show this message and exit. │
+╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯
+```
+
+- **URL**: ibet-Prime URL.
+- You can run this on pythonic way in local.
+
+### Poetry
+```bash
+> poetry install
+> poetry run python src/main.py --url http://localhost:5000
+```
+
+### Pip
+```bash
+> pip install -e ./
+> python src/main.py --url http://localhost:5000
+```
+
+## Screenshots 👀
+
+![query-setting](https://user-images.githubusercontent.com/15183665/222354993-0c11eedc-fb22-472a-8c9f-f9bc8be4d173.png)
+
+![block](https://user-images.githubusercontent.com/15183665/222355008-0c893524-2a80-4975-9c44-537649b11fc7.png)
+
+![transaction](https://user-images.githubusercontent.com/15183665/222355025-24b72685-8d27-48e5-9ea1-b265c4365629.png)
+
diff --git a/cmd/explorer/poetry.lock b/cmd/explorer/poetry.lock
new file mode 100644
index 00000000..4e734b46
--- /dev/null
+++ b/cmd/explorer/poetry.lock
@@ -0,0 +1,7 @@
+# This file is automatically @generated by Poetry 1.4.0 and should not be changed by hand.
+package = []
+
+[metadata]
+lock-version = "2.0"
+python-versions = "3.10.4"
+content-hash = "27d3391f1c2b128fc1ae428a33310a6bd3ed1b1f8ee7a44bd6f546eb287b334e"
diff --git a/cmd/explorer/pyproject.toml b/cmd/explorer/pyproject.toml
new file mode 100644
index 00000000..cf27734e
--- /dev/null
+++ b/cmd/explorer/pyproject.toml
@@ -0,0 +1,30 @@
+[tool.poetry]
+name = "ibet-prime-explorer"
+version = "0.1.0"
+description = "ibet-Prime Terminal UI for Block Chain Explorer"
+authors = ["BOOSTRY Co., Ltd. "]
+readme = "README.md"
+packages = [
+ { include = "src" },
+]
+
+[tool.poetry.dependencies]
+python = "3.10.4"
+
+[tool.poetry.scripts]
+ibet-explorer = "src.main:app"
+
+[tool.mypy]
+python_version = "3.10"
+no_strict_optional = true
+ignore_missing_imports = true
+check_untyped_defs = true
+
+[tool.black]
+includes = "src"
+target-version = ['py310']
+line-length = 120
+
+[build-system]
+requires = ["poetry-core>=1.0.0"]
+build-backend = "poetry.core.masonry.api"
\ No newline at end of file
diff --git a/cmd/explorer/src/__init__.py b/cmd/explorer/src/__init__.py
new file mode 100644
index 00000000..5ebbd941
--- /dev/null
+++ b/cmd/explorer/src/__init__.py
@@ -0,0 +1,18 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
diff --git a/cmd/explorer/src/connector/__init__.py b/cmd/explorer/src/connector/__init__.py
new file mode 100644
index 00000000..532829c4
--- /dev/null
+++ b/cmd/explorer/src/connector/__init__.py
@@ -0,0 +1,88 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+import os
+import sys
+from dataclasses import asdict
+from typing import Any
+
+from aiohttp import ClientSession
+from cache import AsyncTTL
+
+path = os.path.join(os.path.dirname(__file__), "../../../../")
+sys.path.append(path)
+
+from app.model.schema import (
+ BlockDataDetail,
+ BlockDataListResponse,
+ BlockNumberResponse,
+ ListBlockDataQuery,
+ ListTxDataQuery,
+ TxDataDetail,
+ TxDataListResponse,
+)
+
+
+class ApiNotEnabledException(Exception):
+ pass
+
+
+async def health_check(url: str, session: ClientSession) -> None:
+ async with session.get(url=f"{url}/") as resp:
+ await resp.json()
+
+
+@AsyncTTL(time_to_live=10, skip_args=1)
+async def get_block_number(session: ClientSession, url: str) -> BlockNumberResponse:
+ async with session.get(url=f"{url}/block_number") as resp:
+ data = await resp.json()
+ return BlockNumberResponse.parse_obj(data)
+
+
+def dict_factory(x: list[tuple[str, Any]]):
+ return {k: v for (k, v) in x if v is not None}
+
+
+@AsyncTTL(time_to_live=3600, skip_args=1)
+async def list_block_data(session: ClientSession, url: str, query: ListBlockDataQuery) -> BlockDataListResponse:
+ async with session.get(url=f"{url}/blockchain_explorer/block_data", params=asdict(query, dict_factory=dict_factory)) as resp:
+ data = await resp.json()
+ if resp.status == 404:
+ raise ApiNotEnabledException(data)
+ return BlockDataListResponse.parse_obj(data)
+
+
+@AsyncTTL(time_to_live=3600, skip_args=1)
+async def get_block_data(session: ClientSession, url: str, block_number: int) -> BlockDataDetail:
+ async with session.get(url=f"{url}/blockchain_explorer/block_data/{block_number}") as resp:
+ data = await resp.json()
+ return BlockDataDetail.parse_obj(data)
+
+
+@AsyncTTL(time_to_live=3600, skip_args=1)
+async def list_tx_data(session: ClientSession, url: str, query: ListTxDataQuery) -> TxDataListResponse:
+ async with session.get(url=f"{url}/blockchain_explorer/tx_data", params=asdict(query, dict_factory=dict_factory)) as resp:
+ data = await resp.json()
+ return TxDataListResponse.parse_obj(data)
+
+
+@AsyncTTL(time_to_live=3600, skip_args=1)
+async def get_tx_data(session: ClientSession, url: str, tx_hash: str) -> TxDataDetail:
+ async with session.get(url=f"{url}/blockchain_explorer/tx_data/{tx_hash}") as resp:
+ data = await resp.json()
+ return TxDataDetail.parse_obj(data)
diff --git a/cmd/explorer/src/gui/__init__.py b/cmd/explorer/src/gui/__init__.py
new file mode 100644
index 00000000..5ebbd941
--- /dev/null
+++ b/cmd/explorer/src/gui/__init__.py
@@ -0,0 +1,18 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
diff --git a/cmd/explorer/src/gui/consts.py b/cmd/explorer/src/gui/consts.py
new file mode 100644
index 00000000..f423941f
--- /dev/null
+++ b/cmd/explorer/src/gui/consts.py
@@ -0,0 +1,57 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from enum import Enum
+
+UP = "\u2191"
+DOWN = "\u2193"
+LEFT = "\u2190"
+RIGHT = "\u2192"
+RIGHT_TRIANGLE = "\u25B6"
+BIG_RIGHT_TRIANGLE = "\uE0B0"
+DOWN_TRIANGLE = "\u25BC"
+
+THINKING_FACE = ":thinking_face:"
+FIRE = ":fire:"
+INFO = "[blue]:information:[/]"
+
+
+class ID(str, Enum):
+ BLOCK_CONNECTED = "block_connected"
+ BLOCK_CURRENT_BLOCK_NUMBER = "block_current_block_number"
+ BLOCK_IS_SYNCED = "block_is_synced"
+ BLOCK_NOTION = "block_notion"
+ BLOCK_SCREEN_HEADER = "block_screen_header"
+
+ BLOCK_LIST_FILTER = "block_list_filter"
+ BLOCK_LIST_LOADED_TIME = "block_list_loaded_time"
+ BLOCK_LIST_LOADING = "block_list_loading"
+ BLOCK_LIST_DESCRIPTION = "block_list_description"
+ BLOCK_LIST_TABLE = "block_list_table"
+
+ TX_SELECTED_BLOCK_NUMBER = "tx_selected_block_number"
+
+ MENU = "menu"
+ MENU_CANCEL = "menu_cancel"
+ MENU_SHOW_TX = "menu_show_tx"
+
+ QUERY_PANEL = "query_panel"
+ QUERY_PANEL_FROM_BLOCK_INPUT = "query_panel_from_block_input"
+ QUERY_PANEL_TO_BLOCK_INPUT = "query_panel_to_block_input"
+ QUERY_PANEL_SORT_ORDER_CHOICE = "query_panel_sort_order_choice"
+ QUERY_PANEL_ENTER = "query_panel_enter"
diff --git a/cmd/explorer/src/gui/error.py b/cmd/explorer/src/gui/error.py
new file mode 100644
index 00000000..4c3473d3
--- /dev/null
+++ b/cmd/explorer/src/gui/error.py
@@ -0,0 +1,34 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import Any
+
+from textual.message import Message
+
+
+class Error(Message):
+ """
+ A message sent when there was an error encoding the content.
+ """
+
+ def __init__(self, error: Exception, *args: Any, **kwargs: Any) -> None:
+ """
+ Initialise the error message.
+ """
+ super().__init__(*args, **kwargs)
+ self.error = error
diff --git a/cmd/explorer/src/gui/explorer.css b/cmd/explorer/src/gui/explorer.css
new file mode 100644
index 00000000..87151635
--- /dev/null
+++ b/cmd/explorer/src/gui/explorer.css
@@ -0,0 +1,108 @@
+/*****************************
+ Screens
+ *****************************/
+BlockScreen {
+ layer: 0;
+ height: 100%;
+ layers: one two three;
+ align: center middle;
+}
+BlockScreen.menu {
+ align: left top;
+}
+TransactionScreen {
+ layer: 0;
+}
+TracebackScreen {
+ layer: 0;
+}
+
+/*****************************
+ Widgets
+ *****************************/
+Menu {
+ visibility: hidden;
+ height: auto;
+ width: auto;
+ layer: two;
+ opacity: 0;
+}
+
+DataTable {
+ width: 100%;
+ padding: 0;
+ border: $surface
+}
+
+DataTable:focus {
+ border: heavy $accent-darken-3;
+}
+
+QuerySetting {
+ layer: three;
+ visibility: hidden;
+ margin-top: 3;
+ width: auto;
+ height: auto;
+ align: center middle;
+ content-align: center middle;
+ text-align: center;
+ border: heavy $accent-darken-3;
+}
+
+QuerySetting.visible {
+ align: center middle;
+}
+
+/*****************************
+ Class&Id
+ *****************************/
+
+.column {
+ width: 1fr;
+}
+.visible {
+ visibility: visible;
+}
+.column_auto {
+ width: auto;
+}
+#block_screen_header {
+ height: 1;
+ background: $accent-darken-3;
+}
+#block_list_description {
+ dock: top;
+ height: auto;
+}
+#block_list_table {
+
+}
+#query_panel_from_block_input {
+ width: 50;
+ color: grey;
+}
+#query_panel_to_block_input {
+ width: 50;
+}
+#query_panel_sort_order_choice {
+ align: center middle;
+ width: 50;
+ height: 3;
+}
+#query_panel_enter {
+ width: 50;
+ height: 3;
+}
+#tx_list_header {
+ height: 1;
+ background: $accent-darken-3;
+}
+.menubutton {
+ color: $text;
+ width: 30;
+ height: 3;
+ margin: 0;
+ padding: 0;
+ border: heavy $accent-darken-3;
+}
diff --git a/cmd/explorer/src/gui/explorer.py b/cmd/explorer/src/gui/explorer.py
new file mode 100644
index 00000000..bd67ecc5
--- /dev/null
+++ b/cmd/explorer/src/gui/explorer.py
@@ -0,0 +1,96 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+import os
+
+from pydantic import ValidationError
+from textual.app import App, ReturnType
+from textual.binding import Binding
+
+from src.connector import ApiNotEnabledException
+from src.gui.screen.block import BlockScreen
+from src.gui.screen.traceback import TracebackScreen
+from src.gui.screen.transaction import TransactionScreen
+from src.gui.error import Error
+
+from app.model.schema import ListBlockDataQuery, ListTxDataQuery
+
+
+class AppState:
+ tx_list_query: ListTxDataQuery | None = None
+ block_list_query: ListBlockDataQuery | None = None
+ current_block_number: int | None = None
+ error: Exception | None = None
+
+
+class ExplorerApp(App):
+ """A Textual app to explorer ibet-Network."""
+
+ # Base App Setting
+ BINDINGS = [Binding("ctrl+c", "quit", "Quit")]
+ CSS_PATH = f"{os.path.dirname(os.path.abspath(__file__))}/explorer.css"
+ SCREENS = {"transaction_screen": TransactionScreen, "traceback_screen": TracebackScreen}
+
+ # Injectable App Setting
+ url: str
+ lot_size: int
+
+ # App State
+ state: AppState = AppState()
+
+ # Run App
+ async def run_async(
+ self,
+ *,
+ url: str = "http://localhost:5000",
+ lot_size: int = 30,
+ headless: bool = False,
+ size: tuple[int, int] | None = None,
+ auto_pilot: None = None,
+ ) -> ReturnType | None:
+ self.url = url
+ self.lot_size = lot_size
+ return await super().run_async(headless=headless, size=size, auto_pilot=auto_pilot)
+
+ ##################################################
+ # Event
+ ##################################################
+
+ def on_mount(self):
+ """
+ Occurs when Self is mounted
+ """
+ self.push_screen(BlockScreen(name="block_screen"))
+
+ def on_error(self, event: Error) -> None:
+ if isinstance(event.error, ApiNotEnabledException):
+ raise event.error from None
+ if isinstance(event.error, ValidationError):
+ raise ValueError(event.error.json()) from None
+ self.state.error = event.error
+ self.push_screen("traceback_screen")
+
+ ##################################################
+ # Key binding
+ ##################################################
+
+ async def action_quit(self) -> None:
+ """
+ Occurs when keybind related to `quit` is called.
+ """
+ self.exit()
diff --git a/cmd/explorer/src/gui/rendarable/__init__.py b/cmd/explorer/src/gui/rendarable/__init__.py
new file mode 100644
index 00000000..5ebbd941
--- /dev/null
+++ b/cmd/explorer/src/gui/rendarable/__init__.py
@@ -0,0 +1,18 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
diff --git a/cmd/explorer/src/gui/rendarable/block_detail_info.py b/cmd/explorer/src/gui/rendarable/block_detail_info.py
new file mode 100644
index 00000000..bd8343fb
--- /dev/null
+++ b/cmd/explorer/src/gui/rendarable/block_detail_info.py
@@ -0,0 +1,76 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+import time
+
+from app.model.schema import BlockDataDetail
+from rich.console import Group
+from rich.panel import Panel
+from rich.progress_bar import ProgressBar
+from rich.table import Table
+from rich.text import Text
+
+from src.utils.time import human_time, unix_to_iso
+
+
+class BlockDetailInfo:
+ def __init__(self, block_detail: BlockDataDetail) -> None:
+ self.block_detail = block_detail
+
+ def __rich__(self) -> Group:
+ current = int(round(time.time()))
+
+ basic_table = Table(box=None, expand=False, show_header=False, show_edge=False)
+ basic_table.add_column(style="deep_pink2 bold")
+ basic_table.add_column()
+
+ basic_table.add_row(Text.from_markup("Block Height:"), str(self.block_detail.number))
+ basic_table.add_row(
+ Text.from_markup("Timestamp:"),
+ f"{human_time(current - self.block_detail.timestamp)} ({unix_to_iso(self.block_detail.timestamp)})",
+ )
+ basic_table.add_row(
+ Text.from_markup("Transactions:"), f"{len(self.block_detail.transactions)} transactions in this block"
+ )
+
+ content_table = Table(box=None, expand=False, show_header=False, show_edge=False)
+ content_table.add_column(style="deep_pink2 bold")
+ content_table.add_column()
+ content_table.add_column()
+ content_table.add_row(Text.from_markup("Total Difficulty:"), str(self.block_detail.difficulty), "")
+ content_table.add_row(
+ Text.from_markup("Gas Used:"),
+ f"{self.block_detail.gas_used} ({(self.block_detail.gas_used/self.block_detail.gas_limit)*100:.4f} %)",
+ ProgressBar(completed=(self.block_detail.gas_used / self.block_detail.gas_limit) * 100, width=10),
+ )
+ content_table.add_row(Text.from_markup("Gas Limit:"), f"{self.block_detail.gas_limit}" "")
+ content_table.add_row(Text.from_markup("Size:"), f"{self.block_detail.size} Bytes" "")
+
+ hash_table = Table(box=None, expand=False, show_header=False, show_edge=False)
+ hash_table.add_column(style="deep_pink2 bold")
+ hash_table.add_column()
+ hash_table.add_row(Text.from_markup("Hash:"), self.block_detail.hash)
+ hash_table.add_row(Text.from_markup("Parent Hash:"), self.block_detail.parent_hash)
+ hash_table.add_row("StateRoot: ", self.block_detail.state_root)
+ hash_table.add_row("Nonce: ", self.block_detail.nonce)
+
+ return Group(
+ Panel(basic_table, expand=True, title="Common", title_align="left"),
+ Panel(content_table, expand=True, title="Content", title_align="left"),
+ Panel(hash_table, expand=True, title="Hash", title_align="left"),
+ )
diff --git a/cmd/explorer/src/gui/rendarable/tx_detail_info.py b/cmd/explorer/src/gui/rendarable/tx_detail_info.py
new file mode 100644
index 00000000..f0e6e039
--- /dev/null
+++ b/cmd/explorer/src/gui/rendarable/tx_detail_info.py
@@ -0,0 +1,61 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from rich.console import Group
+from rich.panel import Panel
+from rich.table import Table
+
+from app.model.schema import TxDataDetail
+
+
+class TxDetailInfo:
+ def __init__(self, tx_detail: TxDataDetail) -> None:
+ self.tx_detail = tx_detail
+
+ def __str__(self) -> str:
+ return str(self.tx_detail)
+
+ def __rich__(self) -> Group:
+ common_table = Table(box=None, expand=False, show_header=False, show_edge=False)
+ common_table.add_column(style="deep_pink2 bold")
+ common_table.add_column()
+
+ common_table.add_row("Transaction Hash:", self.tx_detail.hash)
+ common_table.add_row("Block:", str(self.tx_detail.block_number))
+ common_table.add_row("From:", self.tx_detail.from_address)
+ common_table.add_row("Nonce:", str(self.tx_detail.nonce))
+ common_table.add_row("To:", self.tx_detail.to_address)
+
+ common_table.add_row("Value:", str(self.tx_detail.value))
+ common_table.add_row("Gas Price:", str(self.tx_detail.gas_price))
+ common_table.add_row("Gas:", str(self.tx_detail.gas))
+
+ contract_table = Table(box=None, expand=False, show_header=False, show_edge=False)
+ contract_table.add_column(style="deep_pink2 bold")
+ contract_table.add_row("Contract Name:", self.tx_detail.contract_name)
+ contract_table.add_row("Contract Function:", self.tx_detail.contract_function)
+ if self.tx_detail.contract_parameters is not None:
+ function_arguments_table = Table(box=None, expand=False, show_header=False, show_edge=False)
+ for k, v in self.tx_detail.contract_parameters.items():
+ function_arguments_table.add_row(f"{k}: ", str(v))
+ contract_table.add_row("Contract Function Arguments:", Panel(function_arguments_table))
+
+ return Group(
+ Panel(common_table, expand=True, title="Common", title_align="left"),
+ Panel(contract_table, expand=True, title="Contract", title_align="left"),
+ )
diff --git a/cmd/explorer/src/gui/screen/__init__.py b/cmd/explorer/src/gui/screen/__init__.py
new file mode 100644
index 00000000..5ebbd941
--- /dev/null
+++ b/cmd/explorer/src/gui/screen/__init__.py
@@ -0,0 +1,18 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
diff --git a/cmd/explorer/src/gui/screen/base.py b/cmd/explorer/src/gui/screen/base.py
new file mode 100644
index 00000000..9c718c60
--- /dev/null
+++ b/cmd/explorer/src/gui/screen/base.py
@@ -0,0 +1,30 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import TYPE_CHECKING, cast
+
+from textual.screen import Screen
+
+if TYPE_CHECKING:
+ from src.gui.explorer import ExplorerApp
+
+
+class TuiScreen(Screen):
+ @property
+ def tui(self) -> "ExplorerApp":
+ return cast("ExplorerApp", self.app)
diff --git a/cmd/explorer/src/gui/screen/block.py b/cmd/explorer/src/gui/screen/block.py
new file mode 100644
index 00000000..f35b012e
--- /dev/null
+++ b/cmd/explorer/src/gui/screen/block.py
@@ -0,0 +1,266 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+import asyncio
+from asyncio import Event, Lock
+from datetime import datetime
+from typing import Optional
+
+from aiohttp import (
+ ClientSession,
+ ClientTimeout,
+ TCPConnector
+)
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import (
+ Horizontal,
+ Vertical
+)
+from textual.reactive import Reactive
+from textual.widgets import (
+ Button,
+ DataTable,
+ Footer,
+ Label,
+ Static
+)
+
+from src import connector
+from src.gui.consts import ID
+from src.gui.error import Error
+from src.gui.screen.base import TuiScreen
+from src.gui.widget.block_detail_view import BlockDetailView
+from src.gui.widget.block_list_table import BlockListTable
+from src.gui.widget.block_list_view import (
+ BlockListQueryPanel,
+ BlockListSummaryPanel,
+ BlockListView
+)
+from src.gui.widget.menu import (
+ Menu,
+ MenuInstruction
+)
+from src.gui.widget.query_panel import QuerySetting
+
+from app.model.schema import (
+ BlockDataDetail,
+ BlockDataListResponse,
+ BlockNumberResponse,
+ ListBlockDataQuery,
+ ListTxDataQuery,
+)
+from app.model.schema.types import SortOrder
+
+
+class BlockScreen(TuiScreen):
+ BINDINGS = [
+ Binding("e", "edit_query", "Edit list block data query"),
+ ]
+ dark = Reactive(True)
+ mutex_reload_block = Reactive(Lock())
+ background_lock: Optional[Event] = None
+
+ def __init__(self, name: str | None = None, id: str | None = None, classes: str | None = None):
+ super().__init__(name=name, id=id, classes=classes)
+ self.base_url = self.tui.url
+ self.refresh_rate = 5.0
+ self.block_detail_header_widget = BlockDetailView(classes="column")
+
+ def compose(self) -> ComposeResult:
+ yield Horizontal(
+ Vertical(
+ Horizontal(
+ Label(Text.from_markup(" [bold]ibet-Wallet-API BC Explorer[/bold]")),
+ Label(" | "),
+ Label("Fetching current block...", id=ID.BLOCK_CURRENT_BLOCK_NUMBER),
+ Label(" | "),
+ Label("Fetching current status...", id=ID.BLOCK_IS_SYNCED),
+ Label(" | "),
+ Label("Loading...", id=ID.BLOCK_NOTION),
+ id=ID.BLOCK_SCREEN_HEADER,
+ ),
+ Horizontal(BlockListView(classes="column"), self.block_detail_header_widget),
+ classes="column",
+ )
+ )
+ yield Footer()
+ yield Menu(id=ID.MENU)
+ yield QuerySetting(id=ID.QUERY_PANEL)
+
+ ##################################################
+ # Event
+ ##################################################
+
+ async def on_mount(self) -> None:
+ """
+ Occurs when Self is mounted
+ """
+ self.query_one(Menu).hide()
+ self.remove_class("menu")
+ self.query_one(QuerySetting).hide()
+ self.query(BlockListTable)[0].focus()
+ self.set_interval(self.refresh_rate, self.fetch_sync_status)
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ """
+ Occurs when Button is pressed
+ """
+ event.stop()
+ event.prevent_default()
+ match event.button.id:
+ case ID.MENU_CANCEL:
+ self.query_one(Menu).hide()
+ self.remove_class("menu")
+ self.query(BlockListTable)[0].can_focus = True
+ self.query_one(BlockListTable).focus()
+ case ID.MENU_SHOW_TX:
+ ix = self.query_one(Menu).hide()
+ self.remove_class("menu")
+ get_query = ListTxDataQuery()
+ get_query.block_number = ix.block_number
+ self.tui.state.tx_list_query = get_query
+ await self.app.push_screen("transaction_screen")
+ case ID.QUERY_PANEL_ENTER:
+ self.reload_block()
+
+ async def on_query_setting_enter(self, event: QuerySetting.Enter):
+ """
+ Occurs when QuerySetting.Enter is emitted
+ """
+ event.stop()
+ event.prevent_default()
+ self.reload_block()
+
+ async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
+ """
+ Occurs when DataTable row is selected
+ """
+ event.stop()
+ event.prevent_default()
+ selected_row = self.query_one(BlockListTable).data.get(event.cursor_row)
+ if selected_row is None:
+ return
+ await self.fetch_block_detail(selected_row[0])
+
+ if int(selected_row[2]) == 0:
+ # If the number of transaction is 0, menu is not pop up.
+ return
+
+ self.query_one(Menu).show(
+ MenuInstruction(
+ block_number=selected_row[0],
+ block_hash=selected_row[3],
+ selected_row=event.cursor_row
+ )
+ )
+ self.add_class("menu")
+ self.query(BlockListTable)[0].can_focus = False
+
+ def reload_block(self) -> None:
+ if self.tui.state.current_block_number is None or self.tui.state.block_list_query is None:
+ return
+
+ self.query_one(BlockListQueryPanel).block_list_query = self.tui.state.block_list_query
+ asyncio.create_task(self.fetch_block_list())
+
+ ##################################################
+ # Key binding
+ ##################################################
+
+ def action_edit_query(self) -> None:
+ """
+ Occurs when keybind related to `edit_query` is called.
+ """
+ if self.tui.state.current_block_number is None or self.tui.state.block_list_query is None:
+ return
+
+ self.query_one(QuerySetting).show()
+ self.query(BlockListTable)[0].can_focus = False
+
+ ##################################################
+ # Fetch data
+ ##################################################
+
+ async def fetch_sync_status(self):
+ async with TCPConnector(limit=2, keepalive_timeout=0) as tcp_connector:
+ async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session:
+ try:
+ block_number_response: BlockNumberResponse = await connector.get_block_number(session, self.base_url)
+ block_number = block_number_response.block_number
+ except Exception as e:
+ if hasattr(self, "emit_no_wait"):
+ self.emit_no_wait(Error(e, self))
+ return
+ self.update_current_block(block_number)
+ self.update_is_synced(True)
+ if self.tui.state.current_block_number is None and self.tui.state.block_list_query is None:
+ # initialize block list query
+ query = ListBlockDataQuery()
+ query.to_block_number = block_number
+ query.from_block_number = max(block_number - self.tui.lot_size - 1, 0)
+ query.sort_order = SortOrder.DESC
+ self.tui.state.block_list_query = query
+ self.query_one(BlockListQueryPanel).block_list_query = query
+
+ self.tui.state.current_block_number = block_number
+
+ async def fetch_block_list(self) -> None:
+ if self.tui.state.current_block_number == 0:
+ return
+ try:
+ self.query_one(BlockListSummaryPanel).loading = True
+ await asyncio.sleep(5)
+ async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector:
+ async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session:
+ try:
+ block_data_list: BlockDataListResponse = await connector.list_block_data(
+ session, self.base_url, self.tui.state.block_list_query
+ )
+ except Exception as e:
+ if hasattr(self, "emit_no_wait"):
+ self.emit_no_wait(Error(e, self))
+ return
+ self.query_one(BlockListTable).update_rows(block_data_list.block_data)
+ self.query_one(BlockListSummaryPanel).loaded_time = datetime.now()
+
+ finally:
+ self.query_one(BlockListSummaryPanel).loading = False
+
+ async def fetch_block_detail(self, block_number: int):
+ async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector:
+ async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session:
+ try:
+ block_detail: BlockDataDetail = await connector.get_block_data(
+ session,
+ self.base_url,
+ block_number,
+ )
+ except Exception as e:
+ if hasattr(self, "emit_no_wait"):
+ self.emit_no_wait(Error(e, self))
+ return
+ self.query_one(BlockDetailView).block_detail = block_detail
+
+ def update_current_block(self, latest_block_number: int):
+ self.query_one(f"#{ID.BLOCK_CURRENT_BLOCK_NUMBER}", Static).update(f"Current Block: {latest_block_number}")
+
+ def update_is_synced(self, is_synced: bool):
+ self.query_one(f"#{ID.BLOCK_IS_SYNCED}", Static).update(f"Is Synced: {is_synced}")
+ self.query_one(f"#{ID.BLOCK_NOTION}", Static).update(f"Press [E] To Load Block List")
diff --git a/cmd/explorer/src/gui/screen/traceback.py b/cmd/explorer/src/gui/screen/traceback.py
new file mode 100644
index 00000000..c23e6b85
--- /dev/null
+++ b/cmd/explorer/src/gui/screen/traceback.py
@@ -0,0 +1,54 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.widgets import Footer
+
+from src.gui.screen.base import TuiScreen
+from src.gui.widget.block_list_table import BlockListTable
+from src.gui.widget.traceback import TracebackWidget
+
+
+class TracebackScreen(TuiScreen):
+ BINDINGS = [Binding("q,enter,space", "quit", "Close", priority=True)]
+
+ def compose(self) -> ComposeResult:
+ yield TracebackWidget()
+ yield Footer()
+
+ ##################################################
+ # Event
+ ##################################################
+
+ async def on_mount(self) -> None:
+ """
+ Occurs when Self is mounted
+ """
+ self.query(TracebackWidget)[0].focus()
+
+ ##################################################
+ # Key binding
+ ##################################################
+
+ def action_quit(self):
+ """
+ Occurs when keybind related to `quit` is called.
+ """
+ self.tui.pop_screen()
+ self.tui.query_one(BlockListTable).focus()
diff --git a/cmd/explorer/src/gui/screen/transaction.py b/cmd/explorer/src/gui/screen/transaction.py
new file mode 100644
index 00000000..659e79ca
--- /dev/null
+++ b/cmd/explorer/src/gui/screen/transaction.py
@@ -0,0 +1,116 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from aiohttp import ClientSession, ClientTimeout, TCPConnector
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal, Vertical
+from textual.widgets import DataTable, Footer, Label
+
+from src import connector
+from src.gui.consts import ID
+from src.gui.screen.base import TuiScreen
+from src.gui.widget.block_list_table import BlockListTable
+from src.gui.widget.tx_detail_view import TxDetailView
+from src.gui.widget.tx_list_table import TxListTable
+from src.gui.widget.tx_list_view import TxListView
+
+from app.model.schema.bc_explorer import TxDataDetail
+
+
+class TransactionScreen(TuiScreen):
+ BINDINGS = [Binding("q", "quit", "Close", priority=True)]
+
+ def compose(self) -> ComposeResult:
+ yield Horizontal(
+ Vertical(
+ Horizontal(
+ Label(Text.from_markup(" [bold]ibet-Wallet-API BC Explorer[/bold]")),
+ Label(" | "),
+ Label(f"Selected block: -", id=ID.TX_SELECTED_BLOCK_NUMBER),
+ id="tx_list_header",
+ ),
+ Horizontal(TxListView(classes="column"), TxDetailView(classes="column")),
+ classes="column",
+ )
+ )
+ yield Footer()
+
+ ##################################################
+ # Event
+ ##################################################
+
+ async def on_mount(self) -> None:
+ """
+ Occurs when Self is mounted
+ """
+ self.query(TxListTable)[0].focus()
+
+ async def on_data_table_row_selected(self, event: DataTable.RowSelected) -> None:
+ """
+ Occurs when DataTable row is selected
+ """
+ event.stop()
+ event.prevent_default()
+ selected_row = self.query_one(TxListTable).data.get(event.cursor_row)
+ if selected_row is None:
+ return
+
+ tx_hash = selected_row[0]
+ async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector:
+ async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session:
+ tx_detail: TxDataDetail = await connector.get_tx_data(
+ session,
+ self.tui.url,
+ tx_hash,
+ )
+ self.query_one(TxDetailView).tx_detail = tx_detail
+
+ async def on_screen_suspend(self):
+ """
+ Occurs when Self is suspended
+ """
+ self.query_one(TxListTable).update_rows([])
+
+ async def on_screen_resume(self):
+ """
+ Occurs when Self is resumed
+ """
+ if self.tui.state.tx_list_query is not None:
+ async with TCPConnector(limit=1, keepalive_timeout=0) as tcp_connector:
+ async with ClientSession(connector=tcp_connector, timeout=ClientTimeout(30)) as session:
+ tx_list = await connector.list_tx_data(
+ session=session, url=self.tui.url, query=self.tui.state.tx_list_query
+ )
+ self.query_one(TxListTable).update_rows(tx_list.tx_data)
+ self.query_one(f"#{ID.TX_SELECTED_BLOCK_NUMBER}", Label).update(
+ f"Selected block: {self.tui.state.tx_list_query.block_number}"
+ )
+
+ ##################################################
+ # Key binding
+ ##################################################
+
+ def action_quit(self):
+ """
+ Occurs when keybind related to `quit` is called.
+ """
+ self.tui.pop_screen()
+ self.tui.query(BlockListTable)[0].can_focus = True
+ self.tui.query(BlockListTable)[0].focus()
diff --git a/cmd/explorer/src/gui/styles.py b/cmd/explorer/src/gui/styles.py
new file mode 100644
index 00000000..26b0e2c0
--- /dev/null
+++ b/cmd/explorer/src/gui/styles.py
@@ -0,0 +1,27 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from rich import box
+
+BORDER_FOCUSED = "green"
+BORDER_ERROR = "red"
+BORDER_MOUSE_OVER = "grey100"
+BORDER = "grey82"
+BOX = box.SQUARE
+
+TABLE_BOX = box.SIMPLE_HEAD
diff --git a/cmd/explorer/src/gui/widget/__init__.py b/cmd/explorer/src/gui/widget/__init__.py
new file mode 100644
index 00000000..5ebbd941
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/__init__.py
@@ -0,0 +1,18 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
diff --git a/cmd/explorer/src/gui/widget/base.py b/cmd/explorer/src/gui/widget/base.py
new file mode 100644
index 00000000..a7fb0820
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/base.py
@@ -0,0 +1,37 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import TYPE_CHECKING, cast
+
+from textual.widget import Widget
+from textual.widgets import Static
+
+if TYPE_CHECKING:
+ from src.gui.explorer import ExplorerApp
+
+
+class TuiWidget(Widget):
+ @property
+ def tui(self) -> "ExplorerApp":
+ return cast("ExplorerApp", self.app)
+
+
+class TuiStatic(Static):
+ @property
+ def tui(self) -> "ExplorerApp":
+ return cast("ExplorerApp", self.app)
diff --git a/cmd/explorer/src/gui/widget/block_detail_view.py b/cmd/explorer/src/gui/widget/block_detail_view.py
new file mode 100644
index 00000000..ae12cc7d
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/block_detail_view.py
@@ -0,0 +1,58 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import Literal, Union
+
+from rich.align import Align
+from rich.panel import Panel
+from rich.style import Style
+from textual.reactive import Reactive, reactive
+
+from src.gui import styles
+from src.gui.rendarable.block_detail_info import BlockDetailInfo
+from src.gui.widget.base import TuiWidget
+from app.model.schema import BlockDataDetail
+
+
+class BlockDetailView(TuiWidget):
+ block_detail: Reactive[BlockDataDetail | None] = reactive(None)
+
+ def watch_block_detail(self, old: BlockDetailInfo, new: BlockDetailInfo):
+ """
+ Occurs when `block_detail` is changed
+ """
+ self.render()
+
+ def render(self) -> Panel:
+ block_detail: Union[Align, BlockDetailInfo] = Align.center("Press [E] to set query", vertical="middle")
+ style: Style | Literal["none"] = Style(bgcolor="#004578")
+
+ if self.block_detail is not None:
+ block_detail = BlockDetailInfo(self.block_detail)
+ style = "none"
+
+ panel = Panel(
+ block_detail,
+ title="[bold]Block[/]",
+ title_align="left",
+ style=style,
+ border_style=styles.BORDER,
+ box=styles.BOX,
+ )
+
+ return panel
diff --git a/cmd/explorer/src/gui/widget/block_list_table.py b/cmd/explorer/src/gui/widget/block_list_table.py
new file mode 100644
index 00000000..84110636
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/block_list_table.py
@@ -0,0 +1,108 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+import time
+from typing import Iterable
+
+from rich.progress_bar import ProgressBar
+from textual.binding import Binding
+from textual.coordinate import Coordinate
+from textual.reactive import reactive
+from textual.widgets import DataTable
+
+from src.utils.time import human_time
+from app.model.schema.bc_explorer import BlockData
+
+
+class BlockListTable(DataTable):
+ BINDINGS = [
+ Binding("ctrl+n", "cursor_down", "Down", show=False),
+ Binding("ctrl+p", "cursor_up", "Up", show=False),
+ ]
+ only_include_tx = reactive(False)
+ raw_data: Iterable[BlockData] = []
+
+ def __init__(self, name: str, complete_refresh: bool, id: str):
+ super().__init__(name=name, id=id)
+ self.table_name = name
+ self.cursor_type = "row"
+ self.complete_refresh = complete_refresh
+
+ def on_mount(self) -> None:
+ """
+ Occurs when Self is mounted
+ """
+ self.add_column("Block", width=10)
+ self.add_column("Age", width=24)
+ self.add_column("Txn", width=4)
+ self.add_column("Hash", width=70)
+ self.add_column("Gas Used")
+
+ def toggle_filter(self) -> bool:
+ self.only_include_tx = not self.only_include_tx
+ self.update_rows(self.raw_data)
+ return self.only_include_tx
+
+ def update_rows(self, data: Iterable[BlockData]):
+ self.raw_data = data
+ selected_row = self.cursor_row
+ if len(self.data) > 0:
+ selected_block_number = self.data[selected_row][0]
+ else:
+ selected_block_number = None
+
+ if self.complete_refresh:
+ self.clear()
+
+ current = int(round(time.time()))
+ rows = [
+ [
+ str(d.number),
+ human_time(current - d.timestamp) + " ago",
+ str(len(d.transactions)),
+ d.hash,
+ ProgressBar(completed=(d.gas_used / d.gas_limit) * 100, width=10),
+ ]
+ for d in data
+ ]
+ if self.only_include_tx:
+ rows = list(filter(lambda r: r[2] != "0", rows))
+ self.add_rows(rows)
+
+ # Keep current selected position
+ if selected_block_number is not None:
+ row_to_be_selected = next(
+ (i for i, row in enumerate(rows) if row[0] == selected_block_number),
+ len(rows) - 1 if len(rows) > 0 else 0,
+ )
+ self.cursor_cell = Coordinate(row_to_be_selected, 0)
+ self.hover_cell = Coordinate(row_to_be_selected, 0)
+ else:
+ self.cursor_cell = Coordinate(0, 0)
+ self.hover_cell = Coordinate(0, 0)
+
+ self._scroll_cursor_into_view(animate=False)
+ self.refresh()
+
+ def action_select_cursor(self) -> None:
+ """
+ Occurs when keybind related to `select_cursor` is called.
+ """
+ self._set_hover_cursor(False)
+ if self.show_cursor and self.cursor_type != "none" and self.has_focus:
+ self._emit_selected_message()
diff --git a/cmd/explorer/src/gui/widget/block_list_view.py b/cmd/explorer/src/gui/widget/block_list_view.py
new file mode 100644
index 00000000..d7625d61
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/block_list_view.py
@@ -0,0 +1,160 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from datetime import datetime
+
+from rich.panel import Panel
+from rich.spinner import Spinner
+from rich.table import Table
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.containers import Horizontal
+from textual.reactive import Reactive, reactive
+from textual.timer import Timer
+
+from src.gui import styles
+from src.gui.consts import ID
+from src.gui.widget.base import TuiStatic, TuiWidget
+from src.gui.widget.block_list_table import BlockListTable
+from app.model.schema import ListBlockDataQuery
+
+
+class BlockListQueryPanel(TuiStatic):
+ block_list_query: Reactive[ListBlockDataQuery | None] = reactive(None)
+
+ def watch_block_list_query(self, old: ListBlockDataQuery, new: ListBlockDataQuery):
+ """
+ Occurs when `block_list_query` is changed
+ """
+ self.render()
+
+ def render(self) -> Panel:
+ content = Table(show_header=True, header_style="bold", show_edge=False, show_lines=False)
+ content.add_column("From", justify="center", width=10)
+ content.add_column("To", justify="center", width=10)
+ content.add_column("Sort", justify="center", width=7)
+
+ if self.block_list_query is not None:
+ content.add_row(
+ *[
+ str(self.block_list_query.from_block_number),
+ str(self.block_list_query.to_block_number),
+ "Asc" if self.block_list_query.sort_order == 0 else "Desc",
+ ]
+ )
+ else:
+ content.add_row(*["", "", ""])
+
+ style = "none"
+ panel = Panel(
+ content,
+ title="[bold]Query[/]",
+ title_align="left",
+ style=style,
+ border_style=styles.BORDER,
+ box=styles.BOX,
+ )
+
+ return panel
+
+
+class BlockListSummaryPanel(TuiStatic):
+ loading: reactive[bool | None] = reactive(False)
+ loaded_time: reactive[datetime | None] = reactive(None)
+ only_block_filter: reactive[bool | None] = reactive(False)
+
+ update_render: Timer | None = None
+
+ def __init__(self, classes: str | None = None):
+ super().__init__(classes=classes)
+ self._spinner = Spinner("dots")
+
+ def watch_loading(self, new: bool):
+ """
+ Occurs when `loading` is changed
+ """
+ if new:
+ if self.update_render is None:
+ self.update_render = self.set_interval(1 / 60, self.update_spinner)
+ else:
+ self.update_render.resume()
+ else:
+ if self.update_render is not None:
+ self.update_render.pause()
+
+ def watch_loaded_time(self, new: datetime):
+ """
+ Occurs when `loaded_time` is changed
+ """
+ self.render()
+
+ def watch_only_block_filter(self, new: bool):
+ """
+ Occurs when `only_block_filter` is changed
+ """
+ self.render()
+
+ def update_spinner(self) -> None:
+ self.render()
+ self.refresh()
+
+ def render(self) -> Panel:
+ content = Table(show_header=True, header_style="bold", show_edge=False, show_lines=False)
+ content.add_column("Loading", justify="center")
+ content.add_column("Only Blocks Including Tx", style="dim", justify="center")
+ content.add_column("Loaded Time", style="dim", justify="center")
+
+ content.add_row(
+ self._spinner if self.loading else "",
+ f"{self.only_block_filter}",
+ f"{self.loaded_time.strftime('%Y/%m/%d %H:%M:%S')}" if self.loaded_time is not None else "",
+ )
+
+ style = "none"
+ panel = Panel(
+ content,
+ title="[bold]Result[/]",
+ title_align="left",
+ style=style,
+ border_style=styles.BORDER,
+ box=styles.BOX,
+ )
+
+ return panel
+
+
+class BlockListView(TuiWidget):
+ BINDINGS = [
+ Binding("t", "filter", "Toggle Only Blocks Including Tx"),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Horizontal(
+ BlockListQueryPanel(classes="column_auto"),
+ BlockListSummaryPanel(classes="column"),
+ id=ID.BLOCK_LIST_DESCRIPTION,
+ )
+ yield BlockListTable(name="blocks", complete_refresh=True, id=ID.BLOCK_LIST_TABLE)
+
+ def action_filter(self):
+ """
+ Occurs when keybind related to `filter` is called.
+ """
+ if self.query_one(BlockListTable).can_focus:
+ toggle = self.query_one(BlockListTable).toggle_filter()
+ self.query_one(BlockListSummaryPanel).only_block_filter = toggle
diff --git a/cmd/explorer/src/gui/widget/choice.py b/cmd/explorer/src/gui/widget/choice.py
new file mode 100644
index 00000000..8b0351f7
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/choice.py
@@ -0,0 +1,55 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.widgets import Label, ListItem, ListView
+
+
+class Choices(ListView):
+ BINDINGS = [
+ Binding("enter", "select_cursor", "Select", show=False),
+ Binding("up", "cursor_up", "Cursor Up", show=False, priority=True),
+ Binding("ctrl+p", "cursor_up", "Cursor Up", show=False, priority=True),
+ Binding("down", "cursor_down", "Cursor Down", show=False, priority=True),
+ Binding("ctrl+n", "cursor_down", "Cursor Down", show=False, priority=True),
+ ]
+
+ def __init__(self, choices: list[str], *, id: str | None = None) -> None:
+ super().__init__(id=id)
+ self.choices = choices
+
+ def compose(self) -> ComposeResult:
+ for choice in self.choices:
+ yield ListItem(Label(choice))
+
+ @property
+ def value(self) -> ListItem | None:
+ return self.highlighted_child
+
+ @value.setter
+ def value(self, v: str):
+ idx = self.choices.index(v)
+ if idx is None:
+ return
+ else:
+ self.index = idx
+ self.render()
+
+ def current_value(self):
+ return self.choices[self.index]
diff --git a/cmd/explorer/src/gui/widget/menu.py b/cmd/explorer/src/gui/widget/menu.py
new file mode 100644
index 00000000..48a06cf8
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/menu.py
@@ -0,0 +1,62 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from pydantic import BaseModel
+from rich.text import Text
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.widgets import Button
+
+from src.gui.consts import ID
+from src.gui.widget.base import TuiWidget
+
+
+class MenuInstruction(BaseModel):
+ block_number: int
+ block_hash: str
+ selected_row: int
+
+
+class Menu(TuiWidget):
+ BINDINGS = [
+ Binding("tab,down,ctrl+n", "focus_next", "Focus Next", show=False),
+ Binding("shift+tab,up,ctrl+p", "focus_previous", "Focus Previous", show=False),
+ Binding("ctrl+r", "", "", show=False),
+ Binding("t", "click('menu_show_tx')", "Show Transactions"),
+ Binding("c,q", "click('menu_cancel')", "Cancel", key_display="Q, C"),
+ ]
+ ix: MenuInstruction | None = None
+
+ def compose(self) -> ComposeResult:
+ yield Button(Text.from_markup("\[t] Show Transactions :package:"), id=ID.MENU_SHOW_TX, classes="menubutton")
+ yield Button("\[c] Cancel", id=ID.MENU_CANCEL, classes="menubutton")
+
+ def show(self, ix: MenuInstruction):
+ self.ix = ix
+ self.add_class("visible")
+ self.query_one(f"#{ID.MENU_SHOW_TX}", Button).focus()
+
+ def hide(self) -> MenuInstruction | None:
+ self.remove_class("visible")
+ return self.ix
+
+ def action_click(self, _id: str):
+ """
+ Occurs when keybind related to `click` is called.
+ """
+ self.query_one(f"#{_id}", Button).press()
diff --git a/cmd/explorer/src/gui/widget/query_panel.py b/cmd/explorer/src/gui/widget/query_panel.py
new file mode 100644
index 00000000..36f83852
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/query_panel.py
@@ -0,0 +1,269 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import TYPE_CHECKING, cast
+
+from app.model.schema import ListBlockDataQuery
+from app.model.schema.types import SortOrder
+from rich.markdown import Markdown
+from textual import events
+from textual.app import ComposeResult
+from textual.binding import Binding
+from textual.message import Message
+from textual.widgets import Button, Input, Label
+
+from src.gui.consts import ID
+from src.gui.widget.base import TuiWidget
+from src.gui.widget.block_list_table import BlockListTable
+from src.gui.widget.block_list_view import BlockListQueryPanel
+from src.gui.widget.choice import Choices
+
+if TYPE_CHECKING:
+ from src.gui.explorer import ExplorerApp
+
+
+class ToBlockInput(Input):
+ @property
+ def tui(self) -> "ExplorerApp":
+ return cast("ExplorerApp", self.app)
+
+ def increment_value(self, increment: int) -> None:
+ """
+ Increments the value by the increment
+ """
+ if self.value is None:
+ self.value = str(0)
+
+ if (
+ self.tui.state.current_block_number is not None
+ and int(self.value) + increment > self.tui.state.current_block_number
+ ):
+ self.value = f"{self.tui.state.current_block_number}"
+ elif int(self.value) + increment < 0:
+ self.value = str(0)
+ else:
+ self.value = str(int(self.value) + increment)
+
+ def insert_text_at_cursor(self, text: str) -> None:
+ """
+ Insert new text at the cursor, move the cursor to the end of the new text.
+ """
+ if self.value == "0":
+ if text == "0":
+ return
+ self.value = str(int(self.value) + int(text))
+ self.cursor_position = 1
+ return
+
+ if self.cursor_position > len(self.value):
+ if (
+ self.tui.state.current_block_number is not None
+ and int(self.value + text) > self.tui.state.current_block_number
+ ):
+ self.value = f"{self.tui.state.current_block_number}"
+ else:
+ self.value += text
+ self.cursor_position = len(self.value)
+ else:
+ value = self.value
+ before = value[: self.cursor_position]
+ after = value[self.cursor_position :]
+ if (
+ self.tui.state.current_block_number is not None
+ and int(before + text + after) > self.tui.state.current_block_number
+ ):
+ self.value = f"{self.tui.state.current_block_number}"
+ else:
+ self.value = f"{before}{text}{after}"
+ self.cursor_position += len(text)
+
+ async def on_key(self, event: events.Key) -> None:
+ """
+ Occurs when `Key` is emitted.
+ """
+ self._cursor_visible = True
+ if self.cursor_blink:
+ self.blink_timer.reset()
+
+ event.prevent_default()
+ event.stop()
+ if await self.handle_key(event):
+ return
+
+ if event.character is not None and event.character.isdigit():
+ if event.character == "0" and self.cursor_position == 0:
+ self.cursor_position += 1
+ return
+ self.insert_text_at_cursor(event.character)
+ elif event.key in ["left", "ctrl+b"]:
+ if self.cursor_position != 0:
+ self.cursor_position -= 1
+ elif event.key in ["right", "ctrl+f"]:
+ if self.cursor_position != len(self.value):
+ self.cursor_position += 1
+ elif event.key in ["up", "ctrl+p"]:
+ self.increment_value(10)
+ self.cursor_position = len(self.value)
+ elif event.key in ["down", "ctrl+n"]:
+ self.increment_value(-10)
+ self.cursor_position = len(self.value)
+ elif event.key in ["home", "ctrl+a"]:
+ self.cursor_position = 0
+ elif event.key in ["end", "ctrl+e"]:
+ self.cursor_position = len(self.value)
+ elif event.key == "backspace":
+ if self.cursor_position == 0:
+ return
+ elif len(self.value) == 1:
+ self.value = str(0)
+ self.cursor_position = 0
+ elif len(self.value) == 2:
+ if self.cursor_position == 1:
+ self.value = self.value[1]
+ self.cursor_position = 0
+ else:
+ self.value = self.value[0]
+ self.cursor_position = 1
+ else:
+ if self.cursor_position == 1:
+ self.value = self.value[1:]
+ self.cursor_position = 0
+ elif self.cursor_position == len(self.value):
+ self.value = self.value[:-1]
+ self.cursor_position -= 1
+ else:
+ new_value = self.value[: self.cursor_position - 1] + self.value[self.cursor_position :]
+ if new_value != "":
+ self.value = new_value
+ self.cursor_position -= 1
+
+
+class QuerySetting(TuiWidget):
+ BINDINGS = [
+ Binding("c,q,escape", "cancel()", "Cancel", key_display="Q, C", priority=True),
+ Binding("tab", "focus_next", "Focus Next", show=True, priority=True),
+ Binding("shift+tab", "focus_previous", "Focus Previous", show=True, priority=True),
+ Binding("enter", "enter()", "Enter", priority=True),
+ Binding("e", "", "", show=False),
+ Binding("ctrl+c", "", "", show=False),
+ Binding("ctrl+r", "", "", show=False),
+ Binding("ctrl+n", "cursor_down", "Down", show=False),
+ Binding("ctrl+p", "cursor_up", "Up", show=False),
+ ]
+
+ def compose(self) -> ComposeResult:
+ yield Label(Markdown("# Query Setting"))
+ yield Label(Markdown("#### To Block"))
+ yield ToBlockInput(placeholder="100", name="to_block", id=ID.QUERY_PANEL_TO_BLOCK_INPUT)
+ yield Label(Markdown("#### From Block(Auto set)"))
+ yield Input(placeholder="0", name="from_block", id=ID.QUERY_PANEL_FROM_BLOCK_INPUT)
+ yield Label(Markdown("#### Sort Order"))
+ yield Choices(["DESC", "ASC"], id=ID.QUERY_PANEL_SORT_ORDER_CHOICE)
+ yield Button("Enter", id=ID.QUERY_PANEL_ENTER)
+
+ def show(self):
+ self.add_class("visible")
+ self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).focus()
+ self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).can_focus = False
+
+ if self.tui.state.block_list_query is not None:
+ query = self.tui.state.block_list_query
+ self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value = str(query.from_block_number)
+ self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).value = str(query.to_block_number)
+
+ item = "ASC" if query.sort_order.value == 0 else "DESC"
+ self.query_one(f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices).index = self.query_one(
+ f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices
+ ).choices.index(item)
+
+ def hide(self) -> None:
+ self.remove_class("visible")
+ self.tui.query(BlockListTable)[0].can_focus = True
+ self.tui.query(BlockListTable)[0].focus()
+
+ def on_key(self, event: events.Key):
+ """
+ Occurs when `Key` is emitted.
+ """
+ if event.key == "Enter":
+ self.action_enter()
+ from_block = self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value
+ to_block = self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).value
+ sort_order = self.query_one(f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices).current_value()
+
+ query = ListBlockDataQuery(
+ from_block_number=int(from_block),
+ to_block_number=int(to_block),
+ sort_order=SortOrder.DESC if sort_order == "DESC" else SortOrder.ASC,
+ )
+ self.tui.state.block_list_query = query
+ self.tui.query_one(BlockListQueryPanel).block_list_query = query
+
+ self.hide()
+ else:
+ event.stop()
+ event.prevent_default()
+
+ async def on_input_changed(self, event: ToBlockInput.Changed):
+ """
+ Occurs when `Input.Changed` is emitted.
+ """
+ event.prevent_default()
+ event.stop()
+ if event.input.id == ID.QUERY_PANEL_TO_BLOCK_INPUT:
+ self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value = str(
+ max(int(event.input.value) - self.tui.lot_size - 1, 0)
+ )
+
+ async def on_button_pressed(self, event: Button.Pressed) -> None:
+ """
+ Occurs when `Button.Pressed` is emitted.
+ """
+ if event.button.id == ID.QUERY_PANEL_ENTER:
+ self.action_enter()
+ else:
+ event.stop()
+ event.prevent_default()
+
+ class Enter(Message):
+ pass
+
+ def action_enter(self):
+ """
+ Occurs when keybind related to `enter` is called.
+ """
+ from_block = self.query_one(f"#{ID.QUERY_PANEL_FROM_BLOCK_INPUT}", Input).value
+ to_block = self.query_one(f"#{ID.QUERY_PANEL_TO_BLOCK_INPUT}", Input).value
+ sort_order = self.query_one(f"#{ID.QUERY_PANEL_SORT_ORDER_CHOICE}", Choices).current_value()
+
+ query = ListBlockDataQuery(
+ from_block_number=int(from_block),
+ to_block_number=int(to_block),
+ sort_order=SortOrder.DESC if sort_order == "DESC" else SortOrder.ASC,
+ )
+ self.tui.state.block_list_query = query
+ self.tui.query_one(BlockListQueryPanel).block_list_query = query
+
+ self.emit_no_wait(self.Enter(sender=self))
+ self.hide()
+
+ def action_cancel(self):
+ """
+ Occurs when keybind related to `cancel` is called.
+ """
+ self.hide()
diff --git a/cmd/explorer/src/gui/widget/traceback.py b/cmd/explorer/src/gui/widget/traceback.py
new file mode 100644
index 00000000..b9bd2b05
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/traceback.py
@@ -0,0 +1,54 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import Literal
+
+from rich.align import Align
+from rich.panel import Panel
+from rich.style import Style
+from rich.traceback import Traceback
+
+from src.gui import styles
+from src.gui.widget.base import TuiWidget
+
+
+class TracebackWidget(TuiWidget):
+ def render(self) -> Panel:
+ style: Style | Literal["none"] = Style()
+ if self.tui.state.error is not None:
+ trace_back = Traceback.from_exception(
+ exc_type=type(self.tui.state.error),
+ exc_value=self.tui.state.error,
+ traceback=self.tui.state.error.__traceback__,
+ )
+ content: Align = Align.center(trace_back, vertical="middle")
+ else:
+ content = Align.center("", vertical="middle")
+
+ panel = Panel(
+ content,
+ title="[bold]Exception[/]",
+ style=style,
+ border_style=styles.BORDER,
+ box=styles.BOX,
+ title_align="left",
+ padding=0,
+ highlight=True,
+ )
+
+ return panel
diff --git a/cmd/explorer/src/gui/widget/tx_detail_view.py b/cmd/explorer/src/gui/widget/tx_detail_view.py
new file mode 100644
index 00000000..c6ba6ccf
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/tx_detail_view.py
@@ -0,0 +1,65 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import Literal, Union
+
+from app.model.schema import TxDataDetail
+from rich.align import Align
+from rich.panel import Panel
+from rich.style import Style
+from textual.reactive import Reactive, reactive
+
+from src.gui import styles
+from src.gui.rendarable.tx_detail_info import TxDetailInfo
+from src.gui.widget.base import TuiWidget
+
+
+class TxDetailView(TuiWidget):
+ tx_detail: Reactive[TxDataDetail | None] = reactive(None)
+
+ def on_mount(self) -> None:
+ """
+ Occurs when Self is mounted
+ """
+ pass
+
+ def watch_tx_detail(self, old: TxDetailInfo, new: TxDetailInfo):
+ """
+ Occurs when `tx_detail` is changed
+ """
+ self.render()
+
+ def render(self) -> Panel:
+ tx_detail: Union[Align, TxDetailInfo] = Align.center("Not selected", vertical="middle")
+ style: Style | Literal["none"] = Style(bgcolor="#004578")
+
+ if self.tx_detail is not None:
+ tx_detail = TxDetailInfo(self.tx_detail)
+ style = "none"
+
+ panel = Panel(
+ tx_detail,
+ title="[bold]Transaction[/]",
+ style=style,
+ border_style=styles.BORDER,
+ box=styles.BOX,
+ title_align="left",
+ padding=0,
+ )
+
+ return panel
diff --git a/cmd/explorer/src/gui/widget/tx_list_table.py b/cmd/explorer/src/gui/widget/tx_list_table.py
new file mode 100644
index 00000000..e2ebe11a
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/tx_list_table.py
@@ -0,0 +1,61 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from typing import Iterable
+
+from app.model.schema.bc_explorer import TxData
+from textual.binding import Binding
+from textual.reactive import reactive
+from textual.widgets import DataTable
+
+
+class TxListTable(DataTable):
+ BINDINGS = [
+ Binding("ctrl+n", "cursor_down", "Down", show=False),
+ Binding("ctrl+p", "cursor_up", "Up", show=False),
+ ]
+ only_include_tx = reactive(False)
+ raw_data: Iterable[TxData] = []
+
+ def __init__(self, name: str, complete_refresh: bool):
+ super().__init__()
+ self.table_name = name
+ self.column_labels = ["Txn Hash", "Block"]
+ self.cursor_type = "row"
+ self.complete_refresh = complete_refresh
+
+ def on_mount(self) -> None:
+ """
+ Occurs when Self is mounted
+ """
+ self.add_columns(*self.column_labels)
+
+ def update_rows(self, data: Iterable[TxData]):
+ if self.complete_refresh:
+ self.clear()
+ rows = [[d.hash, str(d.block_number)] for d in data]
+ self.add_rows(rows)
+ self.refresh()
+
+ def action_select_cursor(self) -> None:
+ """
+ Occurs when keybind related to `select_cursor` is called.
+ """
+ self._set_hover_cursor(False)
+ if self.show_cursor and self.cursor_type != "none" and self.has_focus:
+ self._emit_selected_message()
diff --git a/cmd/explorer/src/gui/widget/tx_list_view.py b/cmd/explorer/src/gui/widget/tx_list_view.py
new file mode 100644
index 00000000..acf0ba15
--- /dev/null
+++ b/cmd/explorer/src/gui/widget/tx_list_view.py
@@ -0,0 +1,39 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from textual.app import ComposeResult
+from textual.containers import Horizontal
+from textual.widget import Widget
+from textual.widgets import Static
+
+from src.gui.widget.base import TuiWidget
+from src.gui.widget.tx_list_table import TxListTable
+
+
+class TxListView(TuiWidget):
+ BINDINGS = []
+
+ def __init__(self, *children: Widget, name: str | None = None, id: str | None = None, classes: str | None = None):
+ super().__init__(*children, name=name, id=id, classes=classes)
+
+ def compose(self) -> ComposeResult:
+ yield TxListTable(name="transactions", complete_refresh=True)
+ yield Horizontal(
+ Static("", classes="column"),
+ id="tx_list_description",
+ )
diff --git a/cmd/explorer/src/main.py b/cmd/explorer/src/main.py
new file mode 100644
index 00000000..c86d3570
--- /dev/null
+++ b/cmd/explorer/src/main.py
@@ -0,0 +1,41 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+import logging
+import asyncio
+logging.getLogger("asyncio").setLevel(logging.WARNING)
+logging.getLogger("psycopg").setLevel(logging.WARNING)
+
+import typer
+
+from src.gui.explorer import ExplorerApp
+
+app = typer.Typer(pretty_exceptions_show_locals=False)
+
+
+@app.command()
+def run(
+ url: str = typer.Option("http://localhost:5000", help="ibet-Prime server URL to connect"),
+ lot_size: int = typer.Option(100, help="Lot size to fetch Block Data list"),
+):
+ explorer = ExplorerApp()
+ asyncio.run(explorer.run_async(url=url, lot_size=lot_size))
+
+
+if __name__ == "__main__":
+ app()
diff --git a/cmd/explorer/src/utils/time.py b/cmd/explorer/src/utils/time.py
new file mode 100644
index 00000000..c66a5a17
--- /dev/null
+++ b/cmd/explorer/src/utils/time.py
@@ -0,0 +1,77 @@
+"""
+Copyright BOOSTRY Co., Ltd.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+
+You may obtain a copy of the License at
+http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing,
+software distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+
+See the License for the specific language governing permissions and
+limitations under the License.
+
+SPDX-License-Identifier: Apache-2.0
+"""
+from collections import OrderedDict
+from datetime import datetime
+
+INTERVALS = OrderedDict(
+ [
+ ("year", 31536000), # 60 * 60 * 24 * 365
+ ("month", 2627424), # 60 * 60 * 24 * 30.41 (assuming 30.41 days in a month)
+ ("week", 604800), # 60 * 60 * 24 * 7
+ ("day", 86400), # 60 * 60 * 24
+ ("hr", 3600), # 60 * 60
+ ("minute", 60),
+ ("sec", 1),
+ ]
+)
+
+
+def human_time(secs: int):
+ """Human-readable time from secs (ie. 5 days and 2 hrs).
+ Examples:
+ >>> human_time(15)
+ "less than minutes"
+ >>> human_time(3600)
+ "1 hr"
+ >>> human_time(3720)
+ "1 hr"
+ >>> human_time(266400)
+ "3 days"
+ >>> human_time(0)
+ "0 secs"
+ >>> human_time(1)
+ "less than minutes"
+ Args:
+ secs (int): Duration in secs.
+ Returns:
+ str: Human-readable time.
+ """
+ if secs < 0:
+ return "0 secs"
+ elif secs == 0:
+ return "0 secs"
+ elif 1 < secs < INTERVALS["minute"]:
+ return "less than minutes"
+
+ res = []
+ for interval, count in INTERVALS.items():
+ quotient, remainder = divmod(secs, count)
+ if quotient >= 1:
+ secs = remainder
+ if quotient > 1:
+ interval += "s"
+ res.append("%i %s" % (int(quotient), interval))
+ if remainder == 0:
+ break
+
+ return res[0]
+
+
+def unix_to_iso(unix_time: int):
+ return datetime.fromtimestamp(unix_time).isoformat()
diff --git a/poetry.lock b/poetry.lock
index e8fe55c6..de5b5a70 100644
--- a/poetry.lock
+++ b/poetry.lock
@@ -176,6 +176,17 @@ files = [
{file = "asn1crypto-1.5.1.tar.gz", hash = "sha256:13ae38502be632115abf8a24cbe5f4da52e3b5231990aff31123c805306ccb9c"},
]
+[[package]]
+name = "async-cache"
+version = "1.1.1"
+description = "An asyncio Cache"
+category = "main"
+optional = true
+python-versions = ">=3.3"
+files = [
+ {file = "async-cache-1.1.1.tar.gz", hash = "sha256:81aa9ccd19fb06784aaf30bd5f2043dc0a23fc3e998b93d0c2c17d1af9803393"},
+]
+
[[package]]
name = "async-timeout"
version = "4.0.2"
@@ -321,18 +332,18 @@ files = [
[[package]]
name = "boto3"
-version = "1.26.82"
+version = "1.26.83"
description = "The AWS SDK for Python"
category = "main"
optional = false
python-versions = ">= 3.7"
files = [
- {file = "boto3-1.26.82-py3-none-any.whl", hash = "sha256:f618f2e8a4b9d5cb9f05d3a5628ca2afa4bcd3e8b4b8413d11dea0f4c4480b3c"},
- {file = "boto3-1.26.82.tar.gz", hash = "sha256:f50c048b4f3223dd57afb6eee1189ef9a472bf0ed9b26ff9db65ec13275c4414"},
+ {file = "boto3-1.26.83-py3-none-any.whl", hash = "sha256:c07ed643aa72940f92b2c807591e1ba803c1b79d20ca129a6f8cccf846c40ff8"},
+ {file = "boto3-1.26.83.tar.gz", hash = "sha256:df81bfa4f8dfb3a645c43b7fd317f48df3e7fa402115ef21fc2f6430d73a1821"},
]
[package.dependencies]
-botocore = ">=1.29.82,<1.30.0"
+botocore = ">=1.29.83,<1.30.0"
jmespath = ">=0.7.1,<2.0.0"
s3transfer = ">=0.6.0,<0.7.0"
@@ -341,14 +352,14 @@ crt = ["botocore[crt] (>=1.21.0,<2.0a0)"]
[[package]]
name = "botocore"
-version = "1.29.82"
+version = "1.29.83"
description = "Low-level, data-driven core of boto 3."
category = "main"
optional = false
python-versions = ">= 3.7"
files = [
- {file = "botocore-1.29.82-py3-none-any.whl", hash = "sha256:b31e86f9b51eced5506a0f6038370fe8c1355000379610def1bb9e91cae9e77e"},
- {file = "botocore-1.29.82.tar.gz", hash = "sha256:913ca09a184cf4428f05838afc631c4fb93f3b173871b69d66230fd14656a3a1"},
+ {file = "botocore-1.29.83-py3-none-any.whl", hash = "sha256:b7df2a1413f32992f350dd66bc5c8747b7211b776cf6500f64aebba7eddbe4f0"},
+ {file = "botocore-1.29.83.tar.gz", hash = "sha256:2e3d215b94df4e4c0ce3da250db43fb39ef7834f8f8fd19e5b223b35edb4d073"},
]
[package.dependencies]
@@ -1298,6 +1309,20 @@ cli = ["click (>=8.0.0,<9.0.0)", "pygments (>=2.0.0,<3.0.0)", "rich (>=10,<13)"]
http2 = ["h2 (>=3,<5)"]
socks = ["socksio (>=1.0.0,<2.0.0)"]
+[[package]]
+name = "ibet-prime-explorer"
+version = "0.1.0"
+description = "ibet-Prime Terminal UI for Block Chain Explorer"
+category = "main"
+optional = true
+python-versions = "3.10.4"
+files = []
+develop = true
+
+[package.source]
+type = "directory"
+url = "cmd/explorer"
+
[[package]]
name = "idna"
version = "3.4"
@@ -1310,6 +1335,26 @@ files = [
{file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
]
+[[package]]
+name = "importlib-metadata"
+version = "4.13.0"
+description = "Read metadata from Python packages"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+ {file = "importlib_metadata-4.13.0-py3-none-any.whl", hash = "sha256:8a8a81bcf996e74fee46f0d16bd3eaa382a7eb20fd82445c3ad11f4090334116"},
+ {file = "importlib_metadata-4.13.0.tar.gz", hash = "sha256:dd0173e8f150d6815e098fd354f6414b0f079af4644ddfe90c71e2fc6174346d"},
+]
+
+[package.dependencies]
+zipp = ">=0.5"
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"]
+perf = ["ipython"]
+testing = ["flake8 (<5)", "flufl.flake8", "importlib-resources (>=1.3)", "packaging", "pyfakefs", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)", "pytest-perf (>=0.9.2)"]
+
[[package]]
name = "iniconfig"
version = "2.0.0"
@@ -1451,6 +1496,31 @@ babel = ["Babel"]
lingua = ["lingua"]
testing = ["pytest"]
+[[package]]
+name = "markdown-it-py"
+version = "2.2.0"
+description = "Python port of markdown-it. Markdown parsing, done right!"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+ {file = "markdown-it-py-2.2.0.tar.gz", hash = "sha256:7c9a5e412688bc771c67432cbfebcdd686c93ce6484913dccf06cb5a0bea35a1"},
+ {file = "markdown_it_py-2.2.0-py3-none-any.whl", hash = "sha256:5a35f8d1870171d9acc47b99612dc146129b631baf04970128b568f190d0cc30"},
+]
+
+[package.dependencies]
+mdurl = ">=0.1,<1.0"
+
+[package.extras]
+benchmarking = ["psutil", "pytest", "pytest-benchmark"]
+code-style = ["pre-commit (>=3.0,<4.0)"]
+compare = ["commonmark (>=0.9,<1.0)", "markdown (>=3.4,<4.0)", "mistletoe (>=1.0,<2.0)", "mistune (>=2.0,<3.0)", "panflute (>=2.3,<3.0)"]
+linkify = ["linkify-it-py (>=1,<3)"]
+plugins = ["mdit-py-plugins"]
+profiling = ["gprof2dot"]
+rtd = ["attrs", "myst-parser", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "sphinx_book_theme"]
+testing = ["coverage", "pytest", "pytest-cov", "pytest-regressions"]
+
[[package]]
name = "markupsafe"
version = "2.1.2"
@@ -1511,6 +1581,18 @@ files = [
{file = "MarkupSafe-2.1.2.tar.gz", hash = "sha256:abcabc8c2b26036d62d4c746381a6f7cf60aafcc653198ad678306986b09450d"},
]
+[[package]]
+name = "mdurl"
+version = "0.1.2"
+description = "Markdown URL utilities"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+ {file = "mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8"},
+ {file = "mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba"},
+]
+
[[package]]
name = "multiaddr"
version = "0.0.9"
@@ -1613,6 +1695,18 @@ files = [
{file = "multidict-6.0.4.tar.gz", hash = "sha256:3666906492efb76453c0e7b97f2cf459b0682e7402c0489a95484965dbc1da49"},
]
+[[package]]
+name = "nanoid"
+version = "2.0.0"
+description = "A tiny, secure, URL-friendly, unique string ID generator for Python"
+category = "main"
+optional = true
+python-versions = "*"
+files = [
+ {file = "nanoid-2.0.0-py3-none-any.whl", hash = "sha256:90aefa650e328cffb0893bbd4c236cfd44c48bc1f2d0b525ecc53c3187b653bb"},
+ {file = "nanoid-2.0.0.tar.gz", hash = "sha256:5a80cad5e9c6e9ae3a41fa2fb34ae189f7cb420b2a5d8f82bd9d23466e4efa68"},
+]
+
[[package]]
name = "netaddr"
version = "0.8.0"
@@ -1953,6 +2047,21 @@ typing-extensions = ">=4.2.0"
dotenv = ["python-dotenv (>=0.10.4)"]
email = ["email-validator (>=1.0.3)"]
+[[package]]
+name = "pygments"
+version = "2.14.0"
+description = "Pygments is a syntax highlighting package written in Python."
+category = "main"
+optional = true
+python-versions = ">=3.6"
+files = [
+ {file = "Pygments-2.14.0-py3-none-any.whl", hash = "sha256:fa7bd7bd2771287c0de303af8bfdfc731f51bd2c6a47ab69d117138893b82717"},
+ {file = "Pygments-2.14.0.tar.gz", hash = "sha256:b3ed06a9e8ac9a9aae5a6f5dbe78a8a58655d17b43b93c078f094ddc476ae297"},
+]
+
+[package.extras]
+plugins = ["importlib-metadata"]
+
[[package]]
name = "pyrsistent"
version = "0.19.3"
@@ -2190,6 +2299,25 @@ idna = {version = "*", optional = true, markers = "extra == \"idna2008\""}
[package.extras]
idna2008 = ["idna"]
+[[package]]
+name = "rich"
+version = "13.3.1"
+description = "Render rich text, tables, progress bars, syntax highlighting, markdown and more to the terminal"
+category = "main"
+optional = true
+python-versions = ">=3.7.0"
+files = [
+ {file = "rich-13.3.1-py3-none-any.whl", hash = "sha256:8aa57747f3fc3e977684f0176a88e789be314a99f99b43b75d1e9cb5dc6db9e9"},
+ {file = "rich-13.3.1.tar.gz", hash = "sha256:125d96d20c92b946b983d0d392b84ff945461e5a06d3867e9f9e575f8697b67f"},
+]
+
+[package.dependencies]
+markdown-it-py = ">=2.1.0,<3.0.0"
+pygments = ">=2.14.0,<3.0.0"
+
+[package.extras]
+jupyter = ["ipywidgets (>=7.5.1,<9)"]
+
[[package]]
name = "rlp"
version = "2.0.1"
@@ -2384,6 +2512,26 @@ anyio = ">=3.4.0,<5"
[package.extras]
full = ["httpx (>=0.22.0)", "itsdangerous", "jinja2", "python-multipart", "pyyaml"]
+[[package]]
+name = "textual"
+version = "0.10.1"
+description = "Modern Text User Interface framework"
+category = "main"
+optional = true
+python-versions = ">=3.7,<4.0"
+files = [
+ {file = "textual-0.10.1-py3-none-any.whl", hash = "sha256:dd9a5b38a74cf42364a0f247e8f57e3ded7d69d44a63ee664af333f986c48e81"},
+ {file = "textual-0.10.1.tar.gz", hash = "sha256:928cfeec37c60b212963f484e806b25380afdddb5a2aecd888ce8c9b46f93553"},
+]
+
+[package.dependencies]
+importlib-metadata = ">=4.11.3,<5.0.0"
+nanoid = ">=2.0.0"
+rich = ">12.6.0"
+
+[package.extras]
+dev = ["aiohttp (>=3.8.1)", "click (>=8.1.2)", "msgpack (>=1.0.3)"]
+
[[package]]
name = "tomli"
version = "2.0.1"
@@ -2408,6 +2556,27 @@ files = [
{file = "toolz-0.12.0.tar.gz", hash = "sha256:88c570861c440ee3f2f6037c4654613228ff40c93a6c25e0eba70d17282c6194"},
]
+[[package]]
+name = "typer"
+version = "0.7.0"
+description = "Typer, build great CLIs. Easy to code. Based on Python type hints."
+category = "main"
+optional = true
+python-versions = ">=3.6"
+files = [
+ {file = "typer-0.7.0-py3-none-any.whl", hash = "sha256:b5e704f4e48ec263de1c0b3a2387cd405a13767d2f907f44c1a08cbad96f606d"},
+ {file = "typer-0.7.0.tar.gz", hash = "sha256:ff797846578a9f2a201b53442aedeb543319466870fbe1c701eab66dd7681165"},
+]
+
+[package.dependencies]
+click = ">=7.1.1,<9.0.0"
+
+[package.extras]
+all = ["colorama (>=0.4.3,<0.5.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
+dev = ["autoflake (>=1.3.1,<2.0.0)", "flake8 (>=3.8.3,<4.0.0)", "pre-commit (>=2.17.0,<3.0.0)"]
+doc = ["cairosvg (>=2.5.2,<3.0.0)", "mdx-include (>=1.4.1,<2.0.0)", "mkdocs (>=1.1.2,<2.0.0)", "mkdocs-material (>=8.1.4,<9.0.0)", "pillow (>=9.3.0,<10.0.0)"]
+test = ["black (>=22.3.0,<23.0.0)", "coverage (>=6.2,<7.0)", "isort (>=5.0.6,<6.0.0)", "mypy (==0.910)", "pytest (>=4.4.0,<8.0.0)", "pytest-cov (>=2.10.0,<5.0.0)", "pytest-sugar (>=0.9.4,<0.10.0)", "pytest-xdist (>=1.32.0,<4.0.0)", "rich (>=10.11.0,<13.0.0)", "shellingham (>=1.3.0,<2.0.0)"]
+
[[package]]
name = "typing-extensions"
version = "4.5.0"
@@ -2645,7 +2814,26 @@ files = [
idna = ">=2.0"
multidict = ">=4.0"
+[[package]]
+name = "zipp"
+version = "3.15.0"
+description = "Backport of pathlib-compatible object wrapper for zip files"
+category = "main"
+optional = true
+python-versions = ">=3.7"
+files = [
+ {file = "zipp-3.15.0-py3-none-any.whl", hash = "sha256:48904fc76a60e542af151aded95726c1a5c34ed43ab4134b597665c86d7ad556"},
+ {file = "zipp-3.15.0.tar.gz", hash = "sha256:112929ad649da941c23de50f356a2b5570c954b65150642bccdd66bf194d224b"},
+]
+
+[package.extras]
+docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"]
+testing = ["big-O", "flake8 (<5)", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"]
+
+[extras]
+ibet-explorer = ["aiohttp", "async-cache", "ibet-prime-explorer", "textual", "typer"]
+
[metadata]
lock-version = "2.0"
python-versions = "3.10.4"
-content-hash = "f574750ca893eeb073fbd87cf216dac7158e22b9cffe3a0ca684e9c51e30521c"
+content-hash = "d30919603cf774a6bbd285bccf971369efaa9d52177e2740a93ddfedac49005d"
diff --git a/pyproject.toml b/pyproject.toml
index d59fc9ea..b587d73a 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -26,6 +26,12 @@ uvicorn = "~0.20.0"
web3 = "~5.31.3"
sqlalchemy = "~2.0.4"
+ibet-prime-explorer = {path = "cmd/explorer", optional = true, develop = true}
+textual = {version = "~0.10.1", optional = true}
+async-cache = {version = "~1.1.1", optional = true}
+typer = {version = "~0.7.0", optional = true}
+aiohttp = {version = "~3.8.4", optional = true}
+
[tool.poetry.group.dev.dependencies]
httpx = "^0.23.3"
pytest = "^7.2.1"
@@ -33,6 +39,15 @@ pytest-cov = "^4.0.0"
pyyaml = "^6.0"
pytest-freezegun = "^0.4.2"
+[tool.poetry.extras]
+ibet-explorer = [
+ "ibet-prime-explorer",
+ "textual",
+ "async-cache",
+ "typer",
+ "aiohttp"
+]
+
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
diff --git a/tests/Dockerfile b/tests/Dockerfile
index ad4a4475..0487f42f 100644
--- a/tests/Dockerfile
+++ b/tests/Dockerfile
@@ -61,19 +61,12 @@ RUN . ~/.bash_profile \
&& poetry config virtualenvs.create false
# install python packages
-USER apl
-COPY pyproject.toml /app/pyproject.toml
-COPY poetry.lock /app/poetry.lock
-RUN . ~/.bash_profile \
- && poetry install --directory /app --no-root \
- && rm -f /app/pyproject.toml \
- && rm -f /app/poetry.lock
-
-# app deploy
USER root
COPY --chown=apl:apl LICENSE /app/ibet-Prime/
RUN mkdir -p /app/ibet-Prime/bin/
COPY --chown=apl:apl bin/ /app/ibet-Prime/bin/
+RUN mkdir -p /app/ibet-Prime/cmd/
+COPY --chown=apl:apl cmd/ /app/ibet-Prime/cmd/
RUN mkdir -p /app/ibet-Prime/contracts/
COPY --chown=apl:apl contracts/ /app/ibet-Prime/contracts/
RUN mkdir -p /app/ibet-Prime/conf/
@@ -88,6 +81,15 @@ COPY --chown=apl:apl app/ /app/ibet-Prime/app/
RUN find /app/ibet-Prime/ -type d -name __pycache__ | xargs rm -fr \
&& chmod -R 755 /app/ibet-Prime/
+USER apl
+COPY pyproject.toml /app/ibet-Prime/pyproject.toml
+COPY poetry.lock /app/ibet-Prime/poetry.lock
+RUN . ~/.bash_profile \
+ && cd /app/ibet-Prime \
+ && poetry install --no-root \
+ && rm -f /app/ibet-Prime/pyproject.toml \
+ && rm -f /app/ibet-Prime/poetry.lock
+
# test build layer
USER root
RUN mkdir -p /app/ibet-Prime/tests/
diff --git a/tests/test_app_routers_blockchain_explorer_block_data_GET.py b/tests/test_app_routers_blockchain_explorer_block_data_GET.py
index e407fa9d..5be848e3 100644
--- a/tests/test_app_routers_blockchain_explorer_block_data_GET.py
+++ b/tests/test_app_routers_blockchain_explorer_block_data_GET.py
@@ -21,7 +21,11 @@
from fastapi.testclient import TestClient
from sqlalchemy.orm import Session
-from app.model.db import IDXBlockData
+from app.model.db import (
+ IDXBlockData,
+ IDXBlockDataBlockNumber
+)
+from config import CHAIN_ID
class TestListBlockData:
@@ -126,16 +130,45 @@ def insert_block_data(db, block_data):
db.add(block_model)
db.commit()
+ @staticmethod
+ def insert_block_data_block_number(session: Session, latest_block_number: int):
+ idx_block_data_block_number = IDXBlockDataBlockNumber()
+ idx_block_data_block_number.chain_id = CHAIN_ID
+ idx_block_data_block_number.latest_block_number = latest_block_number
+ session.add(idx_block_data_block_number)
+ session.commit()
+
###########################################################################
# Normal
###########################################################################
- # Normal_1
- def test_normal_1(self, client: TestClient, db: Session):
+ # Normal_1_1
+ # IDXBlockDataBlockNumber is None
+ def test_normal_1_1(self, client: TestClient, db: Session):
+ # Request target API
+ with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
+ resp = client.get(self.apiurl)
+
+ # Assertion
+ assert resp.status_code == 200
+
+ response_data = resp.json()
+ assert response_data["result_set"] == {
+ "count": 0,
+ "offset": None,
+ "limit": None,
+ "total": 0
+ }
+ assert response_data["block_data"] == []
+
+ # Normal_1_2
+ def test_normal_1_2(self, client: TestClient, db: Session):
self.insert_block_data(db, self.block_0)
self.insert_block_data(db, self.block_1)
self.insert_block_data(db, self.block_2)
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
# Request target API
with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
resp = client.get(self.apiurl)
@@ -163,6 +196,8 @@ def test_normal_2_1(self, client: TestClient, db: Session):
self.insert_block_data(db, self.block_1)
self.insert_block_data(db, self.block_2)
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
# Request target API
with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
params = {"from_block_number": 1}
@@ -190,6 +225,8 @@ def test_normal_2_2(self, client: TestClient, db: Session):
self.insert_block_data(db, self.block_1)
self.insert_block_data(db, self.block_2)
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
# Request target API
with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
params = {"to_block_number": 1}
@@ -217,6 +254,8 @@ def test_normal_3_1(self, client: TestClient, db: Session):
self.insert_block_data(db, self.block_1)
self.insert_block_data(db, self.block_2)
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
# Request target API
with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
params = {"offset": 1}
@@ -244,6 +283,8 @@ def test_normal_3_2(self, client: TestClient, db: Session):
self.insert_block_data(db, self.block_1)
self.insert_block_data(db, self.block_2)
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
# Request target API
with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
params = {"limit": 1}
@@ -263,6 +304,36 @@ def test_normal_3_2(self, client: TestClient, db: Session):
self.filter_response_item(self.block_0)
]
+ # Normal_4
+ # sort_order
+ def test_normal_4(self, client: TestClient, db: Session):
+ self.insert_block_data(db, self.block_0)
+ self.insert_block_data(db, self.block_1)
+ self.insert_block_data(db, self.block_2)
+
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
+ # Request target API
+ with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True):
+ params = {"sort_order": 1}
+ resp = client.get(self.apiurl, params=params)
+
+ # Assertion
+ assert resp.status_code == 200
+
+ response_data = resp.json()
+ assert response_data["result_set"] == {
+ "count": 3,
+ "offset": None,
+ "limit": None,
+ "total": 3
+ }
+ assert response_data["block_data"] == [
+ self.filter_response_item(self.block_2),
+ self.filter_response_item(self.block_1),
+ self.filter_response_item(self.block_0)
+ ]
+
###########################################################################
# Error
###########################################################################
@@ -339,6 +410,8 @@ def test_error_3(self, client: TestClient, db: Session):
self.insert_block_data(db, self.block_1)
self.insert_block_data(db, self.block_2)
+ self.insert_block_data_block_number(db, latest_block_number=2)
+
# Request target API
with mock.patch("app.routers.bc_explorer.BC_EXPLORER_ENABLED", True),\
mock.patch("app.routers.bc_explorer.BLOCK_RESPONSE_LIMIT", 2):