From 3f94b81c36adf3f5dfe43609d2478b89f6e7d8d6 Mon Sep 17 00:00:00 2001 From: erhant Date: Thu, 18 Jun 2026 17:07:29 +0300 Subject: [PATCH 1/4] feat!: grand migration to QML, update readme as well --- CMakeLists.txt | 55 +--- README.md | 58 ++-- app/CMakeLists.txt | 44 --- app/main.cpp | 13 - app/mainwindow.cpp | 60 ---- app/mainwindow.h | 14 - flake.lock | 186 ++++++++++- flake.nix | 7 +- interfaces/IComponent.h | 17 - metadata.json | 16 +- src/ExplorerPlugin.cpp | 17 - src/ExplorerPlugin.h | 18 -- src/ExplorerWidget.cpp | 213 ------------- src/ExplorerWidget.h | 50 --- src/Style.h | 142 --------- src/explorer_resources.qrc | 12 - src/lez_explorer_ui.rep | 43 +++ src/lez_explorer_ui_backend.cpp | 438 ++++++++++++++++++++++++++ src/lez_explorer_ui_backend.h | 62 ++++ src/models/Account.h | 11 - src/models/Block.h | 33 -- src/models/Transaction.h | 47 --- src/pages/AccountPage.cpp | 160 ---------- src/pages/AccountPage.h | 16 - src/pages/BlockPage.cpp | 218 ------------- src/pages/BlockPage.h | 16 - src/pages/MainPage.cpp | 453 --------------------------- src/pages/MainPage.h | 52 --- src/pages/TransactionPage.cpp | 185 ----------- src/pages/TransactionPage.h | 15 - src/qml/Main.qml | 145 +++++++++ src/qml/components/AccountRow.qml | 57 ++++ src/qml/components/Badge.qml | 27 ++ src/qml/components/BlockRow.qml | 59 ++++ src/qml/components/Card.qml | 30 ++ src/qml/components/FieldLabel.qml | 13 + src/qml/components/IconButton.qml | 33 ++ src/qml/components/InfoRow.qml | 39 +++ src/qml/components/MonoText.qml | 12 + src/qml/components/NavigationBar.qml | 43 +++ src/qml/components/SearchBar.qml | 70 +++++ src/qml/components/SectionHeader.qml | 11 + src/qml/components/StatusBadge.qml | 11 + src/qml/components/TxRow.qml | 43 +++ src/qml/components/TxTypeBadge.qml | 13 + src/{ => qml}/icons/activity.svg | 0 src/{ => qml}/icons/arrow-left.svg | 0 src/{ => qml}/icons/arrow-right.svg | 0 src/{ => qml}/icons/box.svg | 0 src/{ => qml}/icons/file-text.svg | 0 src/{ => qml}/icons/home.svg | 0 src/{ => qml}/icons/search.svg | 0 src/{ => qml}/icons/user.svg | 0 src/qml/pages/AccountPage.qml | 113 +++++++ src/qml/pages/BlockPage.qml | 118 +++++++ src/qml/pages/HomePage.qml | 172 ++++++++++ src/qml/pages/SearchResultsPage.qml | 88 ++++++ src/qml/pages/TransactionPage.qml | 143 +++++++++ src/services/IndexerService.h | 46 --- src/services/LpIndexerService.cpp | 438 -------------------------- src/services/LpIndexerService.h | 69 ---- src/services/MockIndexerService.cpp | 378 ---------------------- src/services/MockIndexerService.h | 44 --- src/widgets/ClickableFrame.h | 47 --- src/widgets/NavigationBar.cpp | 63 ---- src/widgets/NavigationBar.h | 24 -- src/widgets/SearchBar.cpp | 38 --- src/widgets/SearchBar.h | 20 -- 68 files changed, 2021 insertions(+), 3057 deletions(-) delete mode 100644 app/CMakeLists.txt delete mode 100644 app/main.cpp delete mode 100644 app/mainwindow.cpp delete mode 100644 app/mainwindow.h delete mode 100644 interfaces/IComponent.h delete mode 100644 src/ExplorerPlugin.cpp delete mode 100644 src/ExplorerPlugin.h delete mode 100644 src/ExplorerWidget.cpp delete mode 100644 src/ExplorerWidget.h delete mode 100644 src/Style.h delete mode 100644 src/explorer_resources.qrc create mode 100644 src/lez_explorer_ui.rep create mode 100644 src/lez_explorer_ui_backend.cpp create mode 100644 src/lez_explorer_ui_backend.h delete mode 100644 src/models/Account.h delete mode 100644 src/models/Block.h delete mode 100644 src/models/Transaction.h delete mode 100644 src/pages/AccountPage.cpp delete mode 100644 src/pages/AccountPage.h delete mode 100644 src/pages/BlockPage.cpp delete mode 100644 src/pages/BlockPage.h delete mode 100644 src/pages/MainPage.cpp delete mode 100644 src/pages/MainPage.h delete mode 100644 src/pages/TransactionPage.cpp delete mode 100644 src/pages/TransactionPage.h create mode 100644 src/qml/Main.qml create mode 100644 src/qml/components/AccountRow.qml create mode 100644 src/qml/components/Badge.qml create mode 100644 src/qml/components/BlockRow.qml create mode 100644 src/qml/components/Card.qml create mode 100644 src/qml/components/FieldLabel.qml create mode 100644 src/qml/components/IconButton.qml create mode 100644 src/qml/components/InfoRow.qml create mode 100644 src/qml/components/MonoText.qml create mode 100644 src/qml/components/NavigationBar.qml create mode 100644 src/qml/components/SearchBar.qml create mode 100644 src/qml/components/SectionHeader.qml create mode 100644 src/qml/components/StatusBadge.qml create mode 100644 src/qml/components/TxRow.qml create mode 100644 src/qml/components/TxTypeBadge.qml rename src/{ => qml}/icons/activity.svg (100%) rename src/{ => qml}/icons/arrow-left.svg (100%) rename src/{ => qml}/icons/arrow-right.svg (100%) rename src/{ => qml}/icons/box.svg (100%) rename src/{ => qml}/icons/file-text.svg (100%) rename src/{ => qml}/icons/home.svg (100%) rename src/{ => qml}/icons/search.svg (100%) rename src/{ => qml}/icons/user.svg (100%) create mode 100644 src/qml/pages/AccountPage.qml create mode 100644 src/qml/pages/BlockPage.qml create mode 100644 src/qml/pages/HomePage.qml create mode 100644 src/qml/pages/SearchResultsPage.qml create mode 100644 src/qml/pages/TransactionPage.qml delete mode 100644 src/services/IndexerService.h delete mode 100644 src/services/LpIndexerService.cpp delete mode 100644 src/services/LpIndexerService.h delete mode 100644 src/services/MockIndexerService.cpp delete mode 100644 src/services/MockIndexerService.h delete mode 100644 src/widgets/ClickableFrame.h delete mode 100644 src/widgets/NavigationBar.cpp delete mode 100644 src/widgets/NavigationBar.h delete mode 100644 src/widgets/SearchBar.cpp delete mode 100644 src/widgets/SearchBar.h diff --git a/CMakeLists.txt b/CMakeLists.txt index e56a8fe..ff36c49 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -3,10 +3,6 @@ project(LezExplorerUiPlugin LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) set(CMAKE_CXX_STANDARD_REQUIRED ON) -# Hand-written Q_OBJECT classes (widgets/pages/services) need MOC; -# explorer_resources.qrc -> compiled icons via AUTORCC. -set(CMAKE_AUTOMOC ON) -set(CMAKE_AUTORCC ON) # Logos Module CMake helper. For nix builds this is provided via # LOGOS_MODULE_BUILDER_ROOT. @@ -18,51 +14,18 @@ else() message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.") endif() -# Legacy `type: ui` QWidget plugin (hand-written ExplorerPlugin / IComponent — -# no `interface: universal`, so no codegen). The builder links the logos -# SDK/protocol (logos_api.h / getClient) automatically; we add the Qt widget -# stack the explorer needs on top of the default Core/RemoteObjects. +# Universal ui_qml module: we author only the .rep view contract + the *Backend +# class. REP_FILE runs repc; the generated *Plugin / *Interface glue in +# generated_code/ (Q_PLUGIN_METADATA, initLogos wiring, QtRO registration) is +# compiled automatically. The QML view (src/qml/) is delivered by the host +# alongside the plugin — no Qt Widgets, no .qrc. The logos SDK/protocol (incl. +# the typed lez_indexer_module wrappers) is linked by the builder. logos_module( NAME lez_explorer_ui + REP_FILE src/lez_explorer_ui.rep SOURCES - src/ExplorerPlugin.cpp - src/ExplorerPlugin.h - src/ExplorerWidget.cpp - src/ExplorerWidget.h - src/Style.h - src/services/IndexerService.h - src/services/LpIndexerService.cpp - src/services/LpIndexerService.h - src/services/MockIndexerService.cpp - src/services/MockIndexerService.h - src/models/Block.h - src/models/Transaction.h - src/models/Account.h - src/widgets/SearchBar.cpp - src/widgets/SearchBar.h - src/widgets/NavigationBar.cpp - src/widgets/NavigationBar.h - src/widgets/ClickableFrame.h - src/pages/MainPage.cpp - src/pages/MainPage.h - src/pages/BlockPage.cpp - src/pages/BlockPage.h - src/pages/TransactionPage.cpp - src/pages/TransactionPage.h - src/pages/AccountPage.cpp - src/pages/AccountPage.h - src/explorer_resources.qrc - FIND_PACKAGES - Qt6Widgets - Qt6Svg - Qt6SvgWidgets - Qt6Network - LINK_LIBRARIES - Qt6::Widgets - Qt6::Svg - Qt6::SvgWidgets - Qt6::Network + src/lez_explorer_ui_backend.h + src/lez_explorer_ui_backend.cpp INCLUDE_DIRS src - interfaces ) diff --git a/README.md b/README.md index 1dff78f..66e7612 100644 --- a/README.md +++ b/README.md @@ -1,39 +1,49 @@ # LEZ Explorer UI -A Qt6/C++ block explorer for the **Logos Execution Zone** (LEZ). Builds as both a shared library (Qt plugin) and a standalone desktop application. +A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a +`logos-module-builder` **`ui_qml`** module (universal authoring model). The QML +view runs process-isolated in a `ui-host`, backed by a small C++ backend that +reads from the [`lez_indexer_module`](https://github.com/logos-blockchain/logos-execution-zone-indexer-module) +**in-process over the Logos protocol** (Qt Remote Objects) — no WebSocket, no +external RPC socket. ## Features -- Browse recent blocks with live streaming of new blocks -- View block details: hash, previous hash, timestamp, status, signature, transactions -- Navigate to previous blocks via clickable hash links -- View transaction details (Public, Privacy-Preserving, Program Deployment) -- View account details with paginated transaction history -- Full-text search by block ID, block hash, transaction hash, or account ID +- Browse recent blocks with a live tip poll (new heads appear automatically) +- Block details: hash, previous hash, timestamp, Bedrock status, signature, transactions +- Navigate to the previous block via a clickable hash link +- Transaction details (Public, Privacy-Preserving, Program Deployment) +- Account details with recent transaction history +- Search by block id, block hash, transaction hash, or account id - Browser-style back/forward navigation -- Pagination with "Load more" for older blocks -- Dark theme (Catppuccin Mocha) +- "Load more" pagination for older blocks +- Styled with the shared `Logos.Theme` / `Logos.Controls` design system + +## Architecture + +- `src/lez_explorer_ui.rep` — the QtRO view contract (PROPs / SLOTs / SIGNALs). +- `src/lez_explorer_ui_backend.{h,cpp}` — the only hand-written C++: derives the + generated `LezExplorerUiSimpleSource` + `LogosUiPluginContext`, calls the + indexer with typed `modules().lez_indexer_module.*`, converts its compact JSON + into QVariant for the view, and polls the chain tip for live updates. +- `src/qml/` — the view (`Main.qml`, `pages/`, `components/`, `icons/`). + +The `*Plugin` / `*Interface` glue is generated by the builder from the `.rep` + +`metadata.json` (`interface: "universal"`). ## Building with Nix -### Standalone application - ```sh -nix build '.#app' -nix run '.#app' +git add -A # nix only sees git-tracked files +nix build # runs repc + compiles → result/lib/lez_explorer_ui_plugin.{so,dylib} +nix run . # preview in logos-standalone-app (spawns the ui-host) +nix build .#lgx # bundle as a .lgx for logos-basecamp ``` -### Shared library (Qt plugin) - -```sh -nix build '.#lib' -``` - -### Development shell - -```sh -nix develop -``` +Running against live data needs the indexer reachable; enter its +`indexer_config.json` path in the explorer's config field (leave blank if the +indexer is already running). Load the explorer and indexer `.lgx` bundles +together in logos-basecamp for the full experience. ## License diff --git a/app/CMakeLists.txt b/app/CMakeLists.txt deleted file mode 100644 index 88bb6fc..0000000 --- a/app/CMakeLists.txt +++ /dev/null @@ -1,44 +0,0 @@ -cmake_minimum_required(VERSION 3.16) -project(LEZExplorerUIApp LANGUAGES CXX) - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_AUTOMOC ON) -set(CMAKE_INCLUDE_CURRENT_DIR ON) - -# Find Qt packages -find_package(Qt6 REQUIRED COMPONENTS Core Widgets) - -# Set output directories -set(CMAKE_RUNTIME_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/bin) - -# Create the executable -add_executable(lez-explorer-ui-app - main.cpp - mainwindow.cpp - mainwindow.h -) - -# Link libraries -target_link_libraries(lez-explorer-ui-app PRIVATE - Qt6::Core - Qt6::Widgets -) - -# Set RPATH settings for the executable -if(APPLE) - set_target_properties(lez-explorer-ui-app PROPERTIES - INSTALL_RPATH "@executable_path/../lib" - BUILD_WITH_INSTALL_RPATH TRUE - ) -elseif(UNIX) - set_target_properties(lez-explorer-ui-app PROPERTIES - INSTALL_RPATH "$ORIGIN/../lib" - BUILD_WITH_INSTALL_RPATH TRUE - ) -endif() - -# Install rules -install(TARGETS lez-explorer-ui-app - RUNTIME DESTINATION bin -) diff --git a/app/main.cpp b/app/main.cpp deleted file mode 100644 index 8d904da..0000000 --- a/app/main.cpp +++ /dev/null @@ -1,13 +0,0 @@ -#include "mainwindow.h" - -#include - -int main(int argc, char* argv[]) -{ - QApplication app(argc, argv); - - MainWindow window; - window.show(); - - return app.exec(); -} diff --git a/app/mainwindow.cpp b/app/mainwindow.cpp deleted file mode 100644 index 67ea477..0000000 --- a/app/mainwindow.cpp +++ /dev/null @@ -1,60 +0,0 @@ -#include "mainwindow.h" - -#include - -MainWindow::MainWindow(QWidget* parent) - : QMainWindow(parent) -{ - setupUi(); -} - -MainWindow::~MainWindow() = default; - -void MainWindow::setupUi() -{ - // Determine the appropriate plugin extension based on the platform - QString pluginExtension; -#if defined(Q_OS_WIN) - pluginExtension = ".dll"; -#elif defined(Q_OS_MAC) - pluginExtension = ".dylib"; -#else - pluginExtension = ".so"; -#endif - - QString pluginPath = QCoreApplication::applicationDirPath() + "/../lez_explorer_ui" + pluginExtension; - QPluginLoader loader(pluginPath); - - QWidget* explorerWidget = nullptr; - - if (loader.load()) { - QObject* plugin = loader.instance(); - if (plugin) { - QMetaObject::invokeMethod(plugin, "createWidget", - Qt::DirectConnection, - Q_RETURN_ARG(QWidget*, explorerWidget)); - } - } - - if (explorerWidget) { - setCentralWidget(explorerWidget); - } else { - qWarning() << "Failed to load LEZ Explorer UI plugin from:" << pluginPath; - qWarning() << "Error:" << loader.errorString(); - - auto* fallbackWidget = new QWidget(this); - auto* layout = new QVBoxLayout(fallbackWidget); - - auto* messageLabel = new QLabel("LEZ Explorer UI module not loaded", fallbackWidget); - QFont font = messageLabel->font(); - font.setPointSize(14); - messageLabel->setFont(font); - messageLabel->setAlignment(Qt::AlignCenter); - - layout->addWidget(messageLabel); - setCentralWidget(fallbackWidget); - } - - setWindowTitle("LEZ Explorer"); - resize(1024, 768); -} diff --git a/app/mainwindow.h b/app/mainwindow.h deleted file mode 100644 index 90a9e50..0000000 --- a/app/mainwindow.h +++ /dev/null @@ -1,14 +0,0 @@ -#pragma once - -#include - -class MainWindow : public QMainWindow { - Q_OBJECT - -public: - explicit MainWindow(QWidget* parent = nullptr); - ~MainWindow() override; - -private: - void setupUi(); -}; diff --git a/flake.lock b/flake.lock index 1c917de..c4375e9 100644 --- a/flake.lock +++ b/flake.lock @@ -21,11 +21,11 @@ "logos-module-builder": "logos-module-builder_2" }, "locked": { - "lastModified": 1781780348, - "narHash": "sha256-XBipaTgJQk4jl17RJLMhVbdEJKfSC2BdWHgTVZN9UG8=", + "lastModified": 1781791313, + "narHash": "sha256-dRwDkySQQZfup1A9fb6e5Z59R59NgWGnM+/UFIHStkc=", "ref": "erhant/migr-to-logos-module-builder", - "rev": "31e176a09be44bcaf7a0b8846df69fb38564bb88", - "revCount": 35, + "rev": "7ba107e7f5b46ba871210f28a562863198b9b8a8", + "revCount": 36, "type": "git", "url": "https://github.com/logos-blockchain/logos-execution-zone-indexer-module" }, @@ -7256,6 +7256,42 @@ "type": "github" } }, + "logos-nix_298": { + "inputs": { + "nixpkgs": "nixpkgs_299" + }, + "locked": { + "lastModified": 1774455309, + "narHash": "sha256-3AN7aFnArdysrbQQ2UskWzjNSFADb4hDCsnx69Fa0ng=", + "owner": "logos-co", + "repo": "logos-nix", + "rev": "e637a1f5e871244d1c2df1e3c52a067f2eb406f2", + "type": "github" + }, + "original": { + "owner": "logos-co", + "repo": "logos-nix", + "type": "github" + } + }, + "logos-nix_299": { + "inputs": { + "nixpkgs": "nixpkgs_300" + }, + "locked": { + "lastModified": 1773955630, + "narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=", + "owner": "logos-co", + "repo": "logos-nix", + "rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88", + "type": "github" + }, + "original": { + "owner": "logos-co", + "repo": "logos-nix", + "type": "github" + } + }, "logos-nix_3": { "inputs": { "nixpkgs": "nixpkgs_4" @@ -7292,6 +7328,24 @@ "type": "github" } }, + "logos-nix_300": { + "inputs": { + "nixpkgs": "nixpkgs_301" + }, + "locked": { + "lastModified": 1773955630, + "narHash": "sha256-KqzMoWYIVp2xMgphs7v02T/BE54RKMFxpdC2duhJKG0=", + "owner": "logos-co", + "repo": "logos-nix", + "rev": "0e9e6d66ab8eb34f59e45ed448f7dc29130feb88", + "type": "github" + }, + "original": { + "owner": "logos-co", + "repo": "logos-nix", + "type": "github" + } + }, "logos-nix_31": { "inputs": { "nixpkgs": "nixpkgs_32" @@ -9977,6 +10031,30 @@ "type": "github" } }, + "logos-package_37": { + "inputs": { + "logos-nix": "logos-nix_299", + "nixpkgs": [ + "nix-bundle-lgx", + "logos-package", + "logos-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777575342, + "narHash": "sha256-l/tL39ZVNz3nWpE9zWpC/kg11QHSQJ2HHp3W3yv1pNI=", + "owner": "logos-co", + "repo": "logos-package", + "rev": "118ccc4efd04eb0c4d541be8db7f1075e014ac39", + "type": "github" + }, + "original": { + "owner": "logos-co", + "repo": "logos-package", + "type": "github" + } + }, "logos-package_4": { "inputs": { "logos-nix": "logos-nix_36", @@ -13601,6 +13679,30 @@ "type": "github" } }, + "nix-bundle-dir_52": { + "inputs": { + "logos-nix": "logos-nix_300", + "nixpkgs": [ + "nix-bundle-lgx", + "nix-bundle-dir", + "logos-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1774455641, + "narHash": "sha256-HrVJguPxhIoZMCH+x8Wooa0tE6slUhgNOU6P89t2uQc=", + "owner": "logos-co", + "repo": "nix-bundle-dir", + "rev": "3d155cab09051703a0b02ff2de166a53c30cbca8", + "type": "github" + }, + "original": { + "owner": "logos-co", + "repo": "nix-bundle-dir", + "type": "github" + } + }, "nix-bundle-dir_6": { "inputs": { "logos-nix": "logos-nix_39", @@ -14116,6 +14218,31 @@ "type": "github" } }, + "nix-bundle-lgx_22": { + "inputs": { + "logos-nix": "logos-nix_298", + "logos-package": "logos-package_37", + "nix-bundle-dir": "nix-bundle-dir_52", + "nixpkgs": [ + "nix-bundle-lgx", + "logos-nix", + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1777575520, + "narHash": "sha256-HOwg1N4NfGYq579IppVPpVjPDZfYQGndXGlcl1VRXXo=", + "owner": "logos-co", + "repo": "nix-bundle-lgx", + "rev": "3c44d99b9d8dbd8a135b44b5b328e6175650305e", + "type": "github" + }, + "original": { + "owner": "logos-co", + "repo": "nix-bundle-lgx", + "type": "github" + } + }, "nix-bundle-lgx_3": { "inputs": { "logos-nix": "logos-nix_40", @@ -18071,6 +18198,22 @@ "type": "github" } }, + "nixpkgs_299": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_3": { "locked": { "lastModified": 1759036355, @@ -18103,6 +18246,38 @@ "type": "github" } }, + "nixpkgs_300": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_301": { + "locked": { + "lastModified": 1759036355, + "narHash": "sha256-0m27AKv6ka+q270dw48KflE0LwQYrO7Fm4/2//KCVWg=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "e9f00bd893984bc8ce46c895c3bf7cac95331127", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, "nixpkgs_31": { "locked": { "lastModified": 1759036355, @@ -19535,7 +19710,8 @@ "root": { "inputs": { "lez_indexer_module": "lez_indexer_module", - "logos-module-builder": "logos-module-builder_5" + "logos-module-builder": "logos-module-builder_5", + "nix-bundle-lgx": "nix-bundle-lgx_22" } }, "rust-overlay": { diff --git a/flake.nix b/flake.nix index 796e0fb..b6cf57b 100644 --- a/flake.nix +++ b/flake.nix @@ -1,8 +1,9 @@ { - description = "LEZ Explorer UI - a Qt block explorer for the Logos Execution Zone"; + description = "LEZ Explorer UI - a QML block explorer for the Logos Execution Zone"; inputs = { logos-module-builder.url = "github:logos-co/logos-module-builder"; + nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx"; # The provider it reads from, over the Logos protocol. Input name MUST match # the metadata.json "dependencies" entry. For local development against an @@ -10,13 +11,13 @@ # lez_indexer_module.url = "path:../logos-execution-zone-indexer-module"; # NOTE: git+https (not github:) because the branch name contains a slash — # the github: fetcher mis-routes slashed refs through the commits API (404). - # TODO: repoint to the merge target once this branch lands. + # TODO: repoint to the merge target once this branch lands / goes public. lez_indexer_module.url = "git+https://github.com/logos-blockchain/logos-execution-zone-indexer-module?ref=erhant/migr-to-logos-module-builder"; }; outputs = inputs@{ logos-module-builder, ... }: - logos-module-builder.lib.mkLogosModule { + logos-module-builder.lib.mkLogosQmlModule { src = ./.; configFile = ./metadata.json; flakeInputs = inputs; diff --git a/interfaces/IComponent.h b/interfaces/IComponent.h deleted file mode 100644 index 06ad57a..0000000 --- a/interfaces/IComponent.h +++ /dev/null @@ -1,17 +0,0 @@ -#pragma once - -#include -#include -#include - -class LogosAPI; - -class IComponent { -public: - virtual ~IComponent() = default; - virtual QWidget* createWidget(LogosAPI* logosAPI = nullptr) = 0; - virtual void destroyWidget(QWidget* widget) = 0; -}; - -#define IComponent_iid "com.logos.component.IComponent" -Q_DECLARE_INTERFACE(IComponent, IComponent_iid) diff --git a/metadata.json b/metadata.json index e976608..f8b2de6 100644 --- a/metadata.json +++ b/metadata.json @@ -1,22 +1,26 @@ { "name": "lez_explorer_ui", "version": "1.0.0", - "type": "ui", + "type": "ui_qml", + "interface": "universal", "category": "explorer", "description": "LEZ Explorer UI - A block explorer for the Logos Execution Zone", "main": "lez_explorer_ui_plugin", + "icon": "src/qml/icons/box.svg", + "view": "qml/Main.qml", "dependencies": ["lez_indexer_module"], + "codegen": { "rep": "src/lez_explorer_ui.rep" }, "nix": { "packages": { - "build": ["qt6.qtsvg"], - "runtime": ["qt6.qtsvg"] + "build": [], + "runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"] }, "external_libraries": [], "cmake": { - "find_packages": ["Qt6Widgets", "Qt6Svg", "Qt6SvgWidgets", "Qt6Network"], + "find_packages": [], "extra_sources": [], - "extra_include_dirs": ["interfaces"], - "extra_link_libraries": ["Qt6::Widgets", "Qt6::Svg", "Qt6::SvgWidgets", "Qt6::Network"] + "extra_include_dirs": [], + "extra_link_libraries": [] } } } diff --git a/src/ExplorerPlugin.cpp b/src/ExplorerPlugin.cpp deleted file mode 100644 index c547f52..0000000 --- a/src/ExplorerPlugin.cpp +++ /dev/null @@ -1,17 +0,0 @@ -#include "ExplorerPlugin.h" -#include "ExplorerWidget.h" - -ExplorerPlugin::ExplorerPlugin(QObject* parent) - : QObject(parent) -{ -} - -QWidget* ExplorerPlugin::createWidget(LogosAPI* logosAPI) -{ - return new ExplorerWidget(logosAPI); -} - -void ExplorerPlugin::destroyWidget(QWidget* widget) -{ - delete widget; -} diff --git a/src/ExplorerPlugin.h b/src/ExplorerPlugin.h deleted file mode 100644 index 458b9ec..0000000 --- a/src/ExplorerPlugin.h +++ /dev/null @@ -1,18 +0,0 @@ -#pragma once - -#include "IComponent.h" - -#include -#include - -class ExplorerPlugin : public QObject, public IComponent { - Q_OBJECT - Q_INTERFACES(IComponent) - Q_PLUGIN_METADATA(IID IComponent_iid FILE "metadata.json") - -public: - explicit ExplorerPlugin(QObject* parent = nullptr); - - Q_INVOKABLE QWidget* createWidget(LogosAPI* logosAPI = nullptr) override; - void destroyWidget(QWidget* widget) override; -}; diff --git a/src/ExplorerWidget.cpp b/src/ExplorerWidget.cpp deleted file mode 100644 index 4576f5e..0000000 --- a/src/ExplorerWidget.cpp +++ /dev/null @@ -1,213 +0,0 @@ -#include "ExplorerWidget.h" -#include "Style.h" -#include "services/LpIndexerService.h" -#include "widgets/NavigationBar.h" -#include "widgets/SearchBar.h" -#include "pages/MainPage.h" -#include "pages/BlockPage.h" -#include "pages/TransactionPage.h" -#include "pages/AccountPage.h" - -#include -#include -#include - -ExplorerWidget::ExplorerWidget(LogosAPI* logosAPI, QWidget* parent) - : QWidget(parent) - , m_indexer(std::make_unique(logosAPI)) -{ - setStyleSheet(Style::appBackground()); - - auto* layout = new QVBoxLayout(this); - layout->setContentsMargins(16, 12, 16, 12); - layout->setSpacing(8); - - // Navigation bar - m_navBar = new NavigationBar(this); - layout->addWidget(m_navBar); - - // Search bar - m_searchBar = new SearchBar(this); - layout->addWidget(m_searchBar); - - // Page stack - m_stack = new QStackedWidget(this); - layout->addWidget(m_stack, 1); - - // Create main page - m_mainPage = new MainPage(m_indexer.get(), this); - m_stack->addWidget(m_mainPage); - connectPageSignals(m_mainPage); - - m_currentTarget = NavHome{}; - - // Connect navigation - connect(m_navBar, &NavigationBar::backClicked, this, &ExplorerWidget::navigateBack); - connect(m_navBar, &NavigationBar::forwardClicked, this, &ExplorerWidget::navigateForward); - connect(m_navBar, &NavigationBar::homeClicked, this, &ExplorerWidget::navigateHome); - connect(m_searchBar, &SearchBar::searchRequested, this, &ExplorerWidget::onSearch); - - connect(m_indexer.get(), &IndexerService::newBlockAdded, this, [this](const Block& block) { - m_mainPage->onNewBlock(block); - }); - - updateNavButtons(); -} - -ExplorerWidget::~ExplorerWidget() = default; - -void ExplorerWidget::connectPageSignals(QWidget* page) -{ - if (auto* mainPage = qobject_cast(page)) { - connect(mainPage, &MainPage::blockClicked, this, [this](quint64 id) { - navigateTo(NavBlock{id}); - }); - connect(mainPage, &MainPage::transactionClicked, this, [this](const QString& hash) { - navigateTo(NavTransaction{hash}); - }); - connect(mainPage, &MainPage::accountClicked, this, [this](const QString& id) { - navigateTo(NavAccount{id}); - }); - } else if (auto* blockPage = qobject_cast(page)) { - connect(blockPage, &BlockPage::blockClicked, this, [this](quint64 id) { - navigateTo(NavBlock{id}); - }); - connect(blockPage, &BlockPage::transactionClicked, this, [this](const QString& hash) { - navigateTo(NavTransaction{hash}); - }); - } else if (auto* txPage = qobject_cast(page)) { - connect(txPage, &TransactionPage::accountClicked, this, [this](const QString& id) { - navigateTo(NavAccount{id}); - }); - } else if (auto* accPage = qobject_cast(page)) { - connect(accPage, &AccountPage::transactionClicked, this, [this](const QString& hash) { - navigateTo(NavTransaction{hash}); - }); - } -} - -void ExplorerWidget::showPage(QWidget* page) -{ - // Remove all pages except main page - while (m_stack->count() > 1) { - auto* w = m_stack->widget(1); - m_stack->removeWidget(w); - w->deleteLater(); - } - - if (page == m_mainPage) { - m_stack->setCurrentWidget(m_mainPage); - } else { - m_stack->addWidget(page); - m_stack->setCurrentWidget(page); - } -} - -void ExplorerWidget::navigateTo(const NavTarget& target, bool addToHistory) -{ - if (addToHistory) { - m_backHistory.push(m_currentTarget); - m_forwardHistory.clear(); - } - - m_currentTarget = target; - - std::visit([this](auto&& t) { - using T = std::decay_t; - - if constexpr (std::is_same_v) { - m_mainPage->clearSearchResults(); - m_mainPage->refresh(); - showPage(m_mainPage); - } else if constexpr (std::is_same_v) { - auto block = m_indexer->getBlockById(t.blockId); - if (block) { - auto* page = new BlockPage(*block, this); - connectPageSignals(page); - showPage(page); - } else { - qWarning("Block #%llu not found", static_cast(t.blockId)); - showPage(m_mainPage); - } - } else if constexpr (std::is_same_v) { - auto tx = m_indexer->getTransaction(t.hash); - if (tx) { - auto* page = new TransactionPage(*tx, this); - connectPageSignals(page); - showPage(page); - } else { - qWarning("Transaction %s not found", qPrintable(t.hash)); - showPage(m_mainPage); - } - } else if constexpr (std::is_same_v) { - auto account = m_indexer->getAccount(t.accountId); - if (account) { - auto* page = new AccountPage(*account, m_indexer.get(), this); - connectPageSignals(page); - showPage(page); - } else { - qWarning("Account %s not found", qPrintable(t.accountId)); - showPage(m_mainPage); - } - } - }, target); - - updateNavButtons(); -} - -void ExplorerWidget::navigateBack() -{ - if (m_backHistory.isEmpty()) return; - - m_forwardHistory.push(m_currentTarget); - NavTarget target = m_backHistory.pop(); - navigateTo(target, false); -} - -void ExplorerWidget::navigateForward() -{ - if (m_forwardHistory.isEmpty()) return; - - m_backHistory.push(m_currentTarget); - NavTarget target = m_forwardHistory.pop(); - navigateTo(target, false); -} - -void ExplorerWidget::navigateHome() -{ - navigateTo(NavHome{}); -} - -void ExplorerWidget::onSearch(const QString& query) -{ - if (query.trimmed().isEmpty()) return; - - auto results = m_indexer->search(query); - - // If exactly one result, navigate directly to it - int totalResults = results.blocks.size() + results.transactions.size() + results.accounts.size(); - if (totalResults == 1) { - if (!results.blocks.isEmpty()) { - navigateTo(NavBlock{results.blocks.first().blockId}); - return; - } - if (!results.transactions.isEmpty()) { - navigateTo(NavTransaction{results.transactions.first().hash}); - return; - } - if (!results.accounts.isEmpty()) { - navigateTo(NavAccount{results.accounts.first().accountId}); - return; - } - } - - // Show results on main page - navigateTo(NavHome{}); - m_mainPage->showSearchResults(results); -} - -void ExplorerWidget::updateNavButtons() -{ - m_navBar->setBackEnabled(!m_backHistory.isEmpty()); - m_navBar->setForwardEnabled(!m_forwardHistory.isEmpty()); -} diff --git a/src/ExplorerWidget.h b/src/ExplorerWidget.h deleted file mode 100644 index bbe91e3..0000000 --- a/src/ExplorerWidget.h +++ /dev/null @@ -1,50 +0,0 @@ -#pragma once - -#include "services/IndexerService.h" - -#include -#include -#include -#include - -class QStackedWidget; -class NavigationBar; -class SearchBar; -class MainPage; -class LogosAPI; - -struct NavHome {}; -struct NavBlock { quint64 blockId; }; -struct NavTransaction { QString hash; }; -struct NavAccount { QString accountId; }; - -using NavTarget = std::variant; - -class ExplorerWidget : public QWidget { - Q_OBJECT - -public: - explicit ExplorerWidget(LogosAPI* logosAPI = nullptr, QWidget* parent = nullptr); - ~ExplorerWidget() override; - -private: - void navigateTo(const NavTarget& target, bool addToHistory = true); - void navigateBack(); - void navigateForward(); - void navigateHome(); - void onSearch(const QString& query); - void updateNavButtons(); - - void showPage(QWidget* page); - void connectPageSignals(QWidget* page); - - std::unique_ptr m_indexer; - NavigationBar* m_navBar = nullptr; - SearchBar* m_searchBar = nullptr; - QStackedWidget* m_stack = nullptr; - MainPage* m_mainPage = nullptr; - - QStack m_backHistory; - QStack m_forwardHistory; - NavTarget m_currentTarget; -}; diff --git a/src/Style.h b/src/Style.h deleted file mode 100644 index e38d75c..0000000 --- a/src/Style.h +++ /dev/null @@ -1,142 +0,0 @@ -#pragma once - -#include - -namespace Style { - -// --- Color palette --- - -namespace Color { - inline const char* bg() { return "#1e1e2e"; } - inline const char* surface() { return "#2a2a3c"; } - inline const char* surfaceHover() { return "#33334a"; } - inline const char* border() { return "#3b3b52"; } - inline const char* text() { return "#cdd6f4"; } - inline const char* subtext() { return "#7f849c"; } - inline const char* accent() { return "#89b4fa"; } - inline const char* green() { return "#a6e3a1"; } - inline const char* yellow() { return "#f9e2af"; } - inline const char* red() { return "#f38ba8"; } - inline const char* blue() { return "#89b4fa"; } - inline const char* mauve() { return "#cba6f7"; } - inline const char* peach() { return "#fab387"; } - inline const char* teal() { return "#94e2d5"; } -} - -// --- Base font size applied to the entire app --- - -inline int baseFontSize() { return 14; } - -// --- Widget styles --- - -inline QString appBackground() -{ - return QString("background-color: %1; color: %2; font-size: %3px;") - .arg(Color::bg(), Color::text()) - .arg(baseFontSize()); -} - -inline QString cardFrame() -{ - return QString( - "background: %1; border: 1px solid %2; border-radius: 8px; padding: 18px;" - ).arg(Color::surface(), Color::border()); -} - -inline QString cardFrameWithLabels() -{ - return QString( - "QFrame { background: %1; border: 1px solid %2; border-radius: 8px; padding: 18px; }" - " QFrame QLabel { color: %3; font-size: %4px; }" - ).arg(Color::surface(), Color::border(), Color::text()) - .arg(baseFontSize()); -} - -inline QString clickableRowWithLabels(const QString& selector) -{ - return QString( - "%1 { background: %2; border: 1px solid %3; border-radius: 8px; padding: 12px 16px; margin: 3px 0; }" - " %1 QLabel { color: %4; font-size: %5px; }" - ).arg(selector, Color::surface(), Color::border(), Color::text()) - .arg(baseFontSize()); -} - -inline QString mutedText() -{ - return QString("color: %1;").arg(Color::subtext()); -} - -inline QString monoText() -{ - return QString("font-family: 'Menlo', 'Courier New'; font-size: %1px; color: %2;") - .arg(baseFontSize() - 1) - .arg(Color::accent()); -} - -inline QString badge(const QString& bgColor) -{ - return QString("color: %1; background: %2; border-radius: 4px; padding: 3px 12px; font-weight: bold; font-size: %3px;") - .arg(Color::bg(), bgColor) - .arg(baseFontSize() - 1); -} - -inline QString sectionHeader() -{ - return QString("color: %1;").arg(Color::text()); -} - -inline QString navButton() -{ - return QString( - "QPushButton { background: %1; color: %2; border: 1px solid %3; border-radius: 6px; padding: 6px 14px; font-size: %7px; }" - " QPushButton:hover { background: %4; border-color: %5; }" - " QPushButton:disabled { color: %6; background: %1; border-color: %3; opacity: 0.4; }" - ).arg(Color::surface(), Color::text(), Color::border(), Color::surfaceHover(), Color::accent(), Color::subtext()) - .arg(baseFontSize() + 2); -} - -inline QString searchInput() -{ - return QString( - "QLineEdit { background: %1; color: %2; border: 1px solid %3; border-radius: 6px; padding: 10px 14px; font-size: %6px; }" - " QLineEdit:focus { border-color: %4; }" - " QLineEdit::placeholder { color: %5; }" - ).arg(Color::surface(), Color::text(), Color::border(), Color::accent(), Color::subtext()) - .arg(baseFontSize()); -} - -inline QString searchButton() -{ - return QString( - "QPushButton { background: %1; color: %2; border: none; border-radius: 6px; padding: 10px 20px; font-size: %4px; font-weight: bold; }" - " QPushButton:hover { background: %3; }" - ).arg(Color::accent(), Color::bg(), Color::mauve()) - .arg(baseFontSize()); -} - -inline QString healthLabel() -{ - return QString("color: %1; font-weight: bold; font-size: %2px;") - .arg(Color::green()) - .arg(baseFontSize()); -} - -// --- Status colors --- - -inline QString statusColor(const QString& status) -{ - if (status == "Finalized") return Color::green(); - if (status == "Safe") return Color::yellow(); - return Color::subtext(); // Pending -} - -// --- Transaction type colors --- - -inline QString txTypeColor(const QString& type) -{ - if (type == "Public") return Color::blue(); - if (type == "Privacy-Preserving") return Color::mauve(); - return Color::peach(); // Program Deployment -} - -} // namespace Style diff --git a/src/explorer_resources.qrc b/src/explorer_resources.qrc deleted file mode 100644 index 9060833..0000000 --- a/src/explorer_resources.qrc +++ /dev/null @@ -1,12 +0,0 @@ - - - icons/arrow-left.svg - icons/arrow-right.svg - icons/home.svg - icons/search.svg - icons/box.svg - icons/file-text.svg - icons/user.svg - icons/activity.svg - - diff --git a/src/lez_explorer_ui.rep b/src/lez_explorer_ui.rep new file mode 100644 index 0000000..79bc24b --- /dev/null +++ b/src/lez_explorer_ui.rep @@ -0,0 +1,43 @@ +#include +#include + +// QtRO view contract for the LEZ block explorer. The QML view talks to this +// surface; the *Plugin / *Interface glue is generated (interface: universal). +// +// State the view binds to is exposed as auto-synced PROPs; one-shot lookups are +// SLOTs whose return value is delivered to QML via logos.watch(...). Everything +// is Qt-typed (the QtRO wire contract); the backend converts the indexer's +// compact JSON into QVariantMap/QVariantList before it crosses this boundary. +class LezExplorerUi +{ + // Recent-blocks feed shown on the home page (newest first). Backend-owned: + // the 2 s poll prepends new heads and "Load more" appends older pages. + PROP(QVariantList recentBlocks READONLY) + // Tip block id (from getLastFinalizedBlockId). + PROP(qlonglong chainHeight=0 READONLY) + // Absolute path of the indexer config currently in use ("" if none set). + PROP(QString indexerConfig READONLY) + // "Connecting" / "Connected" / "Error" — drives the health indicator. + PROP(QString connectionStatus READONLY) + + // Point the indexer at a config and (re)start ingestion, then refresh the + // feed. port is the indexer RPC port. Returns true on success. A blank + // configPath skips start_indexer (the indexer is assumed already running). + SLOT(bool applyIndexerConfig(QString configPath, QString port)) + // Reload the latest page into recentBlocks and re-baseline the poller. + SLOT(void refreshBlocks()) + // Append the next older page of blocks to recentBlocks. + SLOT(void loadMoreBlocks()) + + // Detail lookups — each returns an empty map/list when not found. + SLOT(QVariantMap getBlockById(QString id)) + SLOT(QVariantMap getBlockByHash(QString hash)) + SLOT(QVariantMap getTransaction(QString hash)) + SLOT(QVariantMap getAccount(QString accountId)) + SLOT(QVariantList getTransactionsByAccount(QString accountId, int offset, int limit)) + // Composed client-side: { blocks: [...], transactions: [...], accounts: [...] }. + SLOT(QVariantMap search(QString query)) + + // Surfaced to the view for transient failures (e.g. a failed start_indexer). + SIGNAL(error(QString message)) +} diff --git a/src/lez_explorer_ui_backend.cpp b/src/lez_explorer_ui_backend.cpp new file mode 100644 index 0000000..929e2ac --- /dev/null +++ b/src/lez_explorer_ui_backend.cpp @@ -0,0 +1,438 @@ +#include "lez_explorer_ui_backend.h" + +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include + +// Generated umbrella: LogosModules (behind modules()) built from +// metadata.json#dependencies — the Qt-typed wrappers for lez_indexer_module. +#include "logos_sdk.h" + +namespace { + +constexpr int kPollIntervalMs = 2000; +// Above this gap we rebuild the feed from the backend rather than fetch every +// missing block one by one (matches the former LpIndexerService threshold). +constexpr quint64 kGapRebuildThreshold = 8; + +// 64/128-bit values arrive as decimal strings (to dodge JSON double precision +// loss), but be lenient and also accept a JSON number. +quint64 jsonU64(const QJsonValue& value) +{ + if (value.isString()) { + bool ok = false; + const qulonglong parsed = value.toString().toULongLong(&ok); + return ok ? parsed : 0; + } + if (value.isDouble()) { + const double number = value.toDouble(); + return number > 0.0 ? static_cast(number) : 0; + } + return 0; +} + +QString jsonStr(const QJsonValue& value) +{ + if (value.isString()) { + return value.toString(); + } + if (value.isDouble()) { + return QString::number(static_cast(value.toDouble())); + } + return {}; +} + +QString timestampText(quint64 timestamp) +{ + QDateTime dt; + // Tolerate either seconds or milliseconds since epoch. + if (timestamp > 1000000000000ULL) { + dt = QDateTime::fromMSecsSinceEpoch(static_cast(timestamp), QTimeZone::UTC); + } else { + dt = QDateTime::fromSecsSinceEpoch(static_cast(timestamp), QTimeZone::UTC); + } + if (!dt.isValid()) { + dt = QDateTime::currentDateTimeUtc(); + } + return dt.toString(QStringLiteral("yyyy-MM-dd hh:mm:ss")) + QStringLiteral(" UTC"); +} + +QVariantMap txObjectToMap(const QJsonObject& obj) +{ + QVariantMap tx; + if (obj.isEmpty()) { + return tx; + } + + tx.insert(QStringLiteral("hash"), jsonStr(obj.value(QStringLiteral("hash")))); + const QString type = obj.value(QStringLiteral("type")).toString(); + tx.insert(QStringLiteral("type"), type); + + const auto accountsOf = [](const QJsonArray& arr) { + QVariantList accounts; + for (const auto& entry : arr) { + const QJsonObject account = entry.toObject(); + QVariantMap ref; + ref.insert(QStringLiteral("accountId"), jsonStr(account.value(QStringLiteral("account_id")))); + QString nonce = jsonStr(account.value(QStringLiteral("nonce"))); + ref.insert(QStringLiteral("nonce"), nonce.isEmpty() ? QStringLiteral("0") : nonce); + accounts.append(ref); + } + return accounts; + }; + + if (type == QLatin1String("Public")) { + tx.insert(QStringLiteral("programId"), jsonStr(obj.value(QStringLiteral("program_id")))); + tx.insert(QStringLiteral("accounts"), accountsOf(obj.value(QStringLiteral("accounts")).toArray())); + tx.insert(QStringLiteral("instructionDataCount"), + obj.value(QStringLiteral("instruction_data")).toArray().size()); + tx.insert(QStringLiteral("signatureCount"), obj.value(QStringLiteral("signature_count")).toInt()); + } else if (type == QLatin1String("PrivacyPreserving")) { + tx.insert(QStringLiteral("accounts"), accountsOf(obj.value(QStringLiteral("accounts")).toArray())); + tx.insert(QStringLiteral("newCommitmentsCount"), obj.value(QStringLiteral("new_commitments_count")).toInt()); + tx.insert(QStringLiteral("nullifiersCount"), obj.value(QStringLiteral("nullifiers_count")).toInt()); + tx.insert(QStringLiteral("encryptedStatesCount"), obj.value(QStringLiteral("encrypted_states_count")).toInt()); + tx.insert(QStringLiteral("validityWindowStart"), + static_cast(jsonU64(obj.value(QStringLiteral("validity_window_start"))))); + tx.insert(QStringLiteral("validityWindowEnd"), + static_cast(jsonU64(obj.value(QStringLiteral("validity_window_end"))))); + tx.insert(QStringLiteral("signatureCount"), obj.value(QStringLiteral("signature_count")).toInt()); + tx.insert(QStringLiteral("proofSizeBytes"), obj.value(QStringLiteral("proof_size")).toInt()); + } else if (type == QLatin1String("ProgramDeployment")) { + tx.insert(QStringLiteral("bytecodeSizeBytes"), obj.value(QStringLiteral("bytecode_size")).toInt()); + } + + return tx; +} + +QVariantMap blockObjectToMap(const QJsonObject& obj) +{ + QVariantMap block; + if (obj.isEmpty()) { + return block; + } + + block.insert(QStringLiteral("blockId"), static_cast(jsonU64(obj.value(QStringLiteral("block_id"))))); + block.insert(QStringLiteral("hash"), jsonStr(obj.value(QStringLiteral("hash")))); + block.insert(QStringLiteral("prevBlockHash"), jsonStr(obj.value(QStringLiteral("prev_block_hash")))); + block.insert(QStringLiteral("signature"), jsonStr(obj.value(QStringLiteral("signature")))); + block.insert(QStringLiteral("timestampText"), timestampText(jsonU64(obj.value(QStringLiteral("timestamp"))))); + + const QString status = obj.value(QStringLiteral("bedrock_status")).toString(QStringLiteral("Pending")); + block.insert(QStringLiteral("status"), status); + + const QJsonArray txArray = obj.value(QStringLiteral("transactions")).toArray(); + QVariantList transactions; + transactions.reserve(txArray.size()); + for (const auto& txValue : txArray) { + const QVariantMap tx = txObjectToMap(txValue.toObject()); + if (!tx.isEmpty()) { + transactions.append(tx); + } + } + block.insert(QStringLiteral("transactions"), transactions); + block.insert(QStringLiteral("txCount"), transactions.size()); + + return block; +} + +QVariantMap blockJsonToMap(const QString& json) +{ + if (json.isEmpty()) { + return {}; + } + const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + return doc.isObject() ? blockObjectToMap(doc.object()) : QVariantMap(); +} + +QVariantMap accountJsonToMap(const QString& accountId, const QString& json) +{ + if (json.isEmpty()) { + return {}; + } + const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isObject()) { + return {}; + } + const QJsonObject obj = doc.object(); + + QVariantMap account; + account.insert(QStringLiteral("accountId"), accountId); // payload omits it; inject the queried id + account.insert(QStringLiteral("programOwner"), jsonStr(obj.value(QStringLiteral("program_owner")))); + QString balance = jsonStr(obj.value(QStringLiteral("balance"))); + account.insert(QStringLiteral("balance"), balance.isEmpty() ? QStringLiteral("0") : balance); + QString nonce = jsonStr(obj.value(QStringLiteral("nonce"))); + account.insert(QStringLiteral("nonce"), nonce.isEmpty() ? QStringLiteral("0") : nonce); + account.insert(QStringLiteral("dataSizeBytes"), obj.value(QStringLiteral("data_size")).toInt()); + return account; +} + +QVariantMap txJsonToMap(const QString& json) +{ + if (json.isEmpty()) { + return {}; + } + const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + return doc.isObject() ? txObjectToMap(doc.object()) : QVariantMap(); +} + +} // namespace + +LezExplorerUiBackend::LezExplorerUiBackend() = default; + +LezExplorerUiBackend::~LezExplorerUiBackend() = default; + +void LezExplorerUiBackend::onContextReady() +{ + setConnectionStatus(QStringLiteral("Connecting")); + + // The indexer pushes no events — poll the tip for live head updates. + m_pollTimer = new QTimer(this); + m_pollTimer->setInterval(kPollIntervalMs); + connect(m_pollTimer, &QTimer::timeout, this, &LezExplorerUiBackend::pollTip); + m_pollTimer->start(); + + // Kick an immediate poll so the view fills without waiting a full interval. + pollTip(); +} + +bool LezExplorerUiBackend::applyIndexerConfig(QString configPath, QString port) +{ + const QString trimmedConfig = configPath.trimmed(); + const QString trimmedPort = port.trimmed(); + if (!trimmedPort.isEmpty()) { + m_port = trimmedPort; + } + setIndexerConfig(trimmedConfig); + + bool started = true; + if (!trimmedConfig.isEmpty()) { + const qlonglong code = modules().lez_indexer_module.start_indexer(trimmedConfig, m_port); + started = (code == 0); + if (!started) { + emit error(QStringLiteral("start_indexer failed (code %1) for %2").arg(code).arg(trimmedConfig)); + } + } + + // Re-baseline the poller; a (re)started indexer may ingest afresh. + m_lastPolledTip = 0; + m_polledOnce = false; + refreshBlocks(); + return started; +} + +void LezExplorerUiBackend::refreshBlocks() +{ + const QString json = modules().lez_indexer_module.getBlocks(QString(), QStringLiteral("10")); + m_recentBlocks = parseBlockArrayJson(json); + + m_newestLoadedId = 0; + m_oldestLoadedId = 0; + for (const QVariant& entry : std::as_const(m_recentBlocks)) { + const quint64 id = entry.toMap().value(QStringLiteral("blockId")).toULongLong(); + if (m_newestLoadedId == 0 || id > m_newestLoadedId) { + m_newestLoadedId = id; + } + if (m_oldestLoadedId == 0 || id < m_oldestLoadedId) { + m_oldestLoadedId = id; + } + } + setRecentBlocks(m_recentBlocks); +} + +void LezExplorerUiBackend::loadMoreBlocks() +{ + if (m_oldestLoadedId <= 1) { + return; // already at genesis + } + const QString json + = modules().lez_indexer_module.getBlocks(QString::number(m_oldestLoadedId), QStringLiteral("10")); + const QVariantList older = parseBlockArrayJson(json); + if (older.isEmpty()) { + return; + } + + m_recentBlocks.append(older); + for (const QVariant& entry : older) { + const quint64 id = entry.toMap().value(QStringLiteral("blockId")).toULongLong(); + if (id != 0 && (m_oldestLoadedId == 0 || id < m_oldestLoadedId)) { + m_oldestLoadedId = id; + } + } + setRecentBlocks(m_recentBlocks); +} + +QVariantMap LezExplorerUiBackend::getBlockById(QString id) +{ + return blockJsonToMap(modules().lez_indexer_module.getBlockById(id)); +} + +QVariantMap LezExplorerUiBackend::getBlockByHash(QString hash) +{ + return blockJsonToMap(modules().lez_indexer_module.getBlockByHash(hash)); +} + +QVariantMap LezExplorerUiBackend::getTransaction(QString hash) +{ + return txJsonToMap(modules().lez_indexer_module.getTransaction(hash)); +} + +QVariantMap LezExplorerUiBackend::getAccount(QString accountId) +{ + return accountJsonToMap(accountId, modules().lez_indexer_module.getAccount(accountId)); +} + +QVariantList LezExplorerUiBackend::getTransactionsByAccount(QString accountId, int offset, int limit) +{ + const QString json = modules().lez_indexer_module.getTransactionsByAccount( + accountId, QString::number(offset), QString::number(limit)); + if (json.isEmpty()) { + return {}; + } + const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isArray()) { + return {}; + } + + QVariantList result; + const QJsonArray array = doc.array(); + result.reserve(array.size()); + for (const auto& item : array) { + const QVariantMap tx = txObjectToMap(item.toObject()); + if (!tx.isEmpty()) { + result.append(tx); + } + } + return result; +} + +QVariantMap LezExplorerUiBackend::search(QString query) +{ + QVariantList blocks; + QVariantList transactions; + QVariantList accounts; + + const QString trimmed = query.trimmed(); + if (!trimmed.isEmpty()) { + static const QRegularExpression hashRegex(QStringLiteral("^[0-9a-fA-F]{64}$")); + if (hashRegex.match(trimmed).hasMatch()) { + const QVariantMap block = getBlockByHash(trimmed); + if (!block.isEmpty()) { + blocks.append(block); + } + const QVariantMap tx = getTransaction(trimmed); + if (!tx.isEmpty()) { + transactions.append(tx); + } + const QVariantMap account = getAccount(trimmed); + // getAccount always injects the id; treat "no payload fields" as miss. + if (account.size() > 1) { + accounts.append(account); + } + } + + bool ok = false; + const quint64 blockId = trimmed.toULongLong(&ok); + if (ok) { + const QVariantMap block = getBlockById(QString::number(blockId)); + if (!block.isEmpty()) { + const bool duplicate = std::any_of(blocks.cbegin(), blocks.cend(), [blockId](const QVariant& e) { + return e.toMap().value(QStringLiteral("blockId")).toULongLong() == blockId; + }); + if (!duplicate) { + blocks.append(block); + } + } + } + } + + QVariantMap results; + results.insert(QStringLiteral("blocks"), blocks); + results.insert(QStringLiteral("transactions"), transactions); + results.insert(QStringLiteral("accounts"), accounts); + return results; +} + +void LezExplorerUiBackend::pollTip() +{ + const QString tipStr = modules().lez_indexer_module.getLastFinalizedBlockId(); + bool ok = false; + const quint64 tip = tipStr.toULongLong(&ok); + if (!ok || tip == 0) { + setConnectionStatus(QStringLiteral("Connecting")); + return; + } + + setConnectionStatus(QStringLiteral("Connected")); + setChainHeight(static_cast(tip)); + + // First successful poll: fill the feed and baseline without double-loading. + if (!m_polledOnce) { + m_polledOnce = true; + m_lastPolledTip = tip; + refreshBlocks(); + return; + } + + if (tip <= m_lastPolledTip) { + return; + } + + // Large jump / gap: rebuild the recent list from the backend. + if (tip - m_lastPolledTip > kGapRebuildThreshold) { + m_lastPolledTip = tip; + refreshBlocks(); + return; + } + + // Contiguous growth: fetch the new heads and prepend (newest first). + for (quint64 id = m_lastPolledTip + 1; id <= tip; ++id) { + const QVariantMap block = fetchBlock(id); + if (!block.isEmpty()) { + m_recentBlocks.prepend(block); + if (id > m_newestLoadedId) { + m_newestLoadedId = id; + } + if (m_oldestLoadedId == 0) { + m_oldestLoadedId = id; + } + } + } + m_lastPolledTip = tip; + setRecentBlocks(m_recentBlocks); +} + +QVariantMap LezExplorerUiBackend::fetchBlock(quint64 blockId) +{ + return blockJsonToMap(modules().lez_indexer_module.getBlockById(QString::number(blockId))); +} + +QVariantList LezExplorerUiBackend::parseBlockArrayJson(const QString& json) const +{ + if (json.isEmpty()) { + return {}; + } + const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); + if (!doc.isArray()) { + return {}; + } + + QVariantList blocks; + const QJsonArray array = doc.array(); + blocks.reserve(array.size()); + for (const auto& item : array) { + const QVariantMap block = blockObjectToMap(item.toObject()); + if (!block.isEmpty()) { + blocks.append(block); + } + } + return blocks; +} diff --git a/src/lez_explorer_ui_backend.h b/src/lez_explorer_ui_backend.h new file mode 100644 index 0000000..8bf49ec --- /dev/null +++ b/src/lez_explorer_ui_backend.h @@ -0,0 +1,62 @@ +#pragma once + +#include +#include +#include + +#include "logos_ui_plugin_context.h" +#include "rep_lez_explorer_ui_source.h" + +class QTimer; + +/** + * @brief Backend for the LEZ block explorer ui_qml view (universal model). + * + * The only hand-written C++ in this module: it derives the repc-generated + * `LezExplorerUiSimpleSource` (so it implements the `.rep` view contract) and + * `LogosUiPluginContext` (which supplies `onContextReady()` and the Qt-typed + * `modules()` accessors for the `lez_indexer_module` dependency). The + * `*Plugin` / `*Interface` glue is generated. + * + * It reads from the indexer with typed calls — `modules().lez_indexer_module.*` + * — which return the indexer's compact JSON as QString. This class converts + * that JSON into QVariantMap / QVariantList (schema preserved from the former + * `LpIndexerService`) for the QML view, and runs a 2 s poll on the chain tip to + * keep `recentBlocks` / `chainHeight` live (the indexer emits no events). + */ +class LezExplorerUiBackend : public LezExplorerUiSimpleSource, public LogosUiPluginContext { +public: + LezExplorerUiBackend(); + ~LezExplorerUiBackend() override; + + // Fires when ui-host wires the plugin's LogosAPI: modules() is live, so we + // start the poll here. + void onContextReady() override; + + // .rep SLOTs. + bool applyIndexerConfig(QString configPath, QString port) override; + void refreshBlocks() override; + void loadMoreBlocks() override; + QVariantMap getBlockById(QString id) override; + QVariantMap getBlockByHash(QString hash) override; + QVariantMap getTransaction(QString hash) override; + QVariantMap getAccount(QString accountId) override; + QVariantList getTransactionsByAccount(QString accountId, int offset, int limit) override; + QVariantMap search(QString query) override; + +private: + // Tip poll: detect new heads and append/rebuild the recent-blocks feed. + void pollTip(); + // Fetch a single block as a normalized map ({} if missing). + QVariantMap fetchBlock(quint64 blockId); + // Parse a getBlocks(...) JSON array payload into a list of block maps. + QVariantList parseBlockArrayJson(const QString& json) const; + + QString m_port = QStringLiteral("8779"); + QVariantList m_recentBlocks; + quint64 m_newestLoadedId = 0; // highest block id currently in m_recentBlocks + quint64 m_oldestLoadedId = 0; // lowest block id currently in m_recentBlocks + quint64 m_lastPolledTip = 0; + bool m_polledOnce = false; + QTimer* m_pollTimer = nullptr; +}; diff --git a/src/models/Account.h b/src/models/Account.h deleted file mode 100644 index d7eaaa1..0000000 --- a/src/models/Account.h +++ /dev/null @@ -1,11 +0,0 @@ -#pragma once - -#include - -struct Account { - QString accountId; - QString programOwner; - QString balance; - QString nonce; - int dataSizeBytes = 0; -}; diff --git a/src/models/Block.h b/src/models/Block.h deleted file mode 100644 index e47a6ca..0000000 --- a/src/models/Block.h +++ /dev/null @@ -1,33 +0,0 @@ -#pragma once - -#include -#include -#include - -#include "Transaction.h" - -enum class BedrockStatus { - Pending, - Safe, - Finalized -}; - -inline QString bedrockStatusToString(BedrockStatus status) -{ - switch (status) { - case BedrockStatus::Pending: return "Pending"; - case BedrockStatus::Safe: return "Safe"; - case BedrockStatus::Finalized: return "Finalized"; - } - return "Unknown"; -} - -struct Block { - quint64 blockId = 0; - QString hash; - QString prevBlockHash; - QDateTime timestamp; - QString signature; - QVector transactions; - BedrockStatus bedrockStatus = BedrockStatus::Pending; -}; diff --git a/src/models/Transaction.h b/src/models/Transaction.h deleted file mode 100644 index 09a9246..0000000 --- a/src/models/Transaction.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include -#include - -enum class TransactionType { - Public, - PrivacyPreserving, - ProgramDeployment -}; - -inline QString transactionTypeToString(TransactionType type) -{ - switch (type) { - case TransactionType::Public: return "Public"; - case TransactionType::PrivacyPreserving: return "Privacy-Preserving"; - case TransactionType::ProgramDeployment: return "Program Deployment"; - } - return "Unknown"; -} - -struct AccountRef { - QString accountId; - QString nonce; -}; - -struct Transaction { - QString hash; - TransactionType type = TransactionType::Public; - - // Public transaction fields - QString programId; - QVector accounts; - QVector instructionData; - int signatureCount = 0; - int proofSizeBytes = 0; - - // Privacy-preserving transaction fields - int newCommitmentsCount = 0; - int nullifiersCount = 0; - int encryptedStatesCount = 0; - quint64 validityWindowStart = 0; - quint64 validityWindowEnd = 0; - - // Program deployment fields - int bytecodeSizeBytes = 0; -}; diff --git a/src/pages/AccountPage.cpp b/src/pages/AccountPage.cpp deleted file mode 100644 index c8afb76..0000000 --- a/src/pages/AccountPage.cpp +++ /dev/null @@ -1,160 +0,0 @@ -#include "AccountPage.h" -#include "Style.h" -#include "widgets/ClickableFrame.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace { - -QLabel* makeFieldLabel(const QString& text) -{ - auto* label = new QLabel(text); - label->setStyleSheet(Style::mutedText() + " font-weight: bold;"); - return label; -} - -QLabel* makeValueLabel(const QString& text) -{ - auto* label = new QLabel(text); - label->setTextInteractionFlags(Qt::TextSelectableByMouse); - label->setWordWrap(true); - return label; -} - -} // namespace - -AccountPage::AccountPage(const Account& account, IndexerService* indexer, QWidget* parent) - : QWidget(parent) -{ - auto* outerLayout = new QVBoxLayout(this); - outerLayout->setContentsMargins(0, 0, 0, 0); - - auto* scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); - - auto* scrollContent = new QWidget(); - scrollContent->setObjectName("scrollContent"); - auto* layout = new QVBoxLayout(scrollContent); - layout->setAlignment(Qt::AlignTop); - layout->setContentsMargins(0, 0, 0, 0); - - // Title with icon - auto* titleRow = new QHBoxLayout(); - titleRow->setSpacing(10); - auto* titleIcon = new QLabel(); - titleIcon->setPixmap(QIcon(":/icons/user.svg").pixmap(30, 30)); - auto* title = new QLabel("Account Details"); - QFont titleFont = title->font(); - titleFont.setPointSize(24); - titleFont.setBold(true); - title->setFont(titleFont); - title->setStyleSheet(QString("color: %1;").arg(Style::Color::text())); - titleRow->addWidget(titleIcon); - titleRow->addWidget(title); - titleRow->addStretch(); - layout->addLayout(titleRow); - layout->addSpacing(8); - - // Account info grid - auto* infoFrame = new QFrame(); - infoFrame->setFrameShape(QFrame::NoFrame); - infoFrame->setStyleSheet(Style::cardFrameWithLabels()); - - auto* grid = new QGridLayout(infoFrame); - grid->setColumnStretch(1, 1); - grid->setVerticalSpacing(10); - grid->setHorizontalSpacing(20); - int row = 0; - - grid->addWidget(makeFieldLabel("Account ID"), row, 0); - auto* idVal = makeValueLabel(account.accountId); - idVal->setStyleSheet(Style::monoText()); - grid->addWidget(idVal, row++, 1); - - grid->addWidget(makeFieldLabel("Balance"), row, 0); - auto* balVal = makeValueLabel(account.balance); - balVal->setStyleSheet(QString("color: %1; font-weight: bold;").arg(Style::Color::green())); - grid->addWidget(balVal, row++, 1); - - grid->addWidget(makeFieldLabel("Program Owner"), row, 0); - auto* ownerVal = makeValueLabel(account.programOwner); - ownerVal->setStyleSheet(Style::monoText()); - grid->addWidget(ownerVal, row++, 1); - - grid->addWidget(makeFieldLabel("Nonce"), row, 0); - grid->addWidget(makeValueLabel(account.nonce), row++, 1); - - grid->addWidget(makeFieldLabel("Data Size"), row, 0); - grid->addWidget(makeValueLabel(QString("%1 bytes").arg(account.dataSizeBytes)), row++, 1); - - layout->addWidget(infoFrame); - - // Transaction history - auto transactions = indexer->getTransactionsByAccount(account.accountId, 0, 10); - - if (!transactions.isEmpty()) { - auto* headerRow = new QHBoxLayout(); - headerRow->setContentsMargins(0, 16, 0, 6); - headerRow->setSpacing(8); - auto* headerIcon = new QLabel(); - headerIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(24, 24)); - auto* txHeader = new QLabel("Transaction History"); - QFont headerFont = txHeader->font(); - headerFont.setPointSize(20); - headerFont.setBold(true); - txHeader->setFont(headerFont); - txHeader->setStyleSheet(Style::sectionHeader()); - headerRow->addWidget(headerIcon); - headerRow->addWidget(txHeader); - headerRow->addStretch(); - layout->addLayout(headerRow); - - for (const auto& tx : transactions) { - auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::NoFrame); - frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); - - auto* txRow = new QHBoxLayout(frame); - txRow->setSpacing(10); - - auto* icon = new QLabel(); - icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20)); - txRow->addWidget(icon); - - auto* hashLabel = new QLabel(tx.hash); - QFont boldFont = hashLabel->font(); - boldFont.setBold(true); - hashLabel->setFont(boldFont); - - QString typeStr = transactionTypeToString(tx.type); - auto* typeLabel = new QLabel(typeStr); - typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); - - txRow->addWidget(hashLabel); - txRow->addWidget(typeLabel); - txRow->addStretch(); - - QString txHash = tx.hash; - connect(frame, &ClickableFrame::clicked, this, [this, txHash]() { - emit transactionClicked(txHash); - }); - - layout->addWidget(frame); - } - } else { - auto* noTx = new QLabel("No transactions found for this account."); - noTx->setStyleSheet(Style::mutedText() + " margin-top: 16px;"); - layout->addWidget(noTx); - } - - scrollArea->setWidget(scrollContent); - outerLayout->addWidget(scrollArea); -} diff --git a/src/pages/AccountPage.h b/src/pages/AccountPage.h deleted file mode 100644 index 083216f..0000000 --- a/src/pages/AccountPage.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "models/Account.h" -#include "services/IndexerService.h" - -#include - -class AccountPage : public QWidget { - Q_OBJECT - -public: - explicit AccountPage(const Account& account, IndexerService* indexer, QWidget* parent = nullptr); - -signals: - void transactionClicked(const QString& hash); -}; diff --git a/src/pages/BlockPage.cpp b/src/pages/BlockPage.cpp deleted file mode 100644 index d9396e9..0000000 --- a/src/pages/BlockPage.cpp +++ /dev/null @@ -1,218 +0,0 @@ -#include "BlockPage.h" -#include "Style.h" -#include "widgets/ClickableFrame.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace { - -class PrevHashClickFilter : public QObject { -public: - PrevHashClickFilter(quint64 blockId, BlockPage* page, QObject* parent) - : QObject(parent), m_blockId(blockId), m_page(page) {} - -protected: - bool eventFilter(QObject* obj, QEvent* event) override { - if (event->type() == QEvent::MouseButtonRelease) { - auto* me = static_cast(event); - if (me->button() == Qt::LeftButton) { - emit m_page->blockClicked(m_blockId); - return true; - } - } - return QObject::eventFilter(obj, event); - } - -private: - quint64 m_blockId; - BlockPage* m_page; -}; - -QLabel* makeFieldLabel(const QString& text) -{ - auto* label = new QLabel(text); - label->setStyleSheet(Style::mutedText() + " font-weight: bold;"); - return label; -} - -QLabel* makeValueLabel(const QString& text) -{ - auto* label = new QLabel(text); - label->setTextInteractionFlags(Qt::TextSelectableByMouse); - label->setWordWrap(true); - return label; -} - -} // namespace - -BlockPage::BlockPage(const Block& block, QWidget* parent) - : QWidget(parent) -{ - auto* outerLayout = new QVBoxLayout(this); - outerLayout->setContentsMargins(0, 0, 0, 0); - - auto* scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); - - auto* scrollContent = new QWidget(); - scrollContent->setObjectName("scrollContent"); - auto* layout = new QVBoxLayout(scrollContent); - layout->setAlignment(Qt::AlignTop); - layout->setContentsMargins(0, 0, 0, 0); - - // Title with icon - auto* titleRow = new QHBoxLayout(); - titleRow->setSpacing(10); - auto* titleIcon = new QLabel(); - titleIcon->setPixmap(QIcon(":/icons/box.svg").pixmap(30, 30)); - auto* title = new QLabel(QString("Block #%1").arg(block.blockId)); - QFont titleFont = title->font(); - titleFont.setPointSize(24); - titleFont.setBold(true); - title->setFont(titleFont); - title->setStyleSheet(QString("color: %1;").arg(Style::Color::text())); - titleRow->addWidget(titleIcon); - titleRow->addWidget(title); - titleRow->addStretch(); - layout->addLayout(titleRow); - layout->addSpacing(8); - - // Block info grid - auto* infoFrame = new QFrame(); - infoFrame->setFrameShape(QFrame::NoFrame); - infoFrame->setStyleSheet(Style::cardFrameWithLabels()); - - auto* grid = new QGridLayout(infoFrame); - grid->setColumnStretch(1, 1); - grid->setVerticalSpacing(10); - grid->setHorizontalSpacing(20); - int row = 0; - - grid->addWidget(makeFieldLabel("Block ID"), row, 0); - grid->addWidget(makeValueLabel(QString::number(block.blockId)), row++, 1); - - grid->addWidget(makeFieldLabel("Hash"), row, 0); - auto* hashVal = makeValueLabel(block.hash); - hashVal->setStyleSheet(Style::monoText()); - grid->addWidget(hashVal, row++, 1); - - grid->addWidget(makeFieldLabel("Previous Hash"), row, 0); - if (block.blockId > 1) { - auto* prevHashLabel = new QLabel(block.prevBlockHash); - prevHashLabel->setStyleSheet(QString("color: %1; text-decoration: underline;").arg(Style::Color::accent())); - prevHashLabel->setCursor(Qt::PointingHandCursor); - prevHashLabel->setWordWrap(true); - prevHashLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - quint64 prevBlockId = block.blockId - 1; - prevHashLabel->installEventFilter(new PrevHashClickFilter(prevBlockId, this, this)); - grid->addWidget(prevHashLabel, row++, 1); - } else { - grid->addWidget(makeValueLabel(block.prevBlockHash), row++, 1); - } - - grid->addWidget(makeFieldLabel("Timestamp"), row, 0); - grid->addWidget(makeValueLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")), row++, 1); - - grid->addWidget(makeFieldLabel("Status"), row, 0); - QString statusStr = bedrockStatusToString(block.bedrockStatus); - auto* statusLabel = new QLabel(statusStr); - statusLabel->setStyleSheet(Style::badge(Style::statusColor(statusStr))); - statusLabel->setMaximumWidth(120); - grid->addWidget(statusLabel, row++, 1); - - grid->addWidget(makeFieldLabel("Signature"), row, 0); - // Insert zero-width spaces every 32 chars so QLabel can word-wrap the long hex string - QString wrappableSig = block.signature; - for (int i = 32; i < wrappableSig.size(); i += 33) { - wrappableSig.insert(i, QChar(0x200B)); - } - auto* sigLabel = new QLabel(wrappableSig); - sigLabel->setStyleSheet(Style::monoText()); - sigLabel->setTextInteractionFlags(Qt::TextSelectableByMouse); - sigLabel->setWordWrap(true); - grid->addWidget(sigLabel, row++, 1); - - grid->addWidget(makeFieldLabel("Transactions"), row, 0); - grid->addWidget(makeValueLabel(QString::number(block.transactions.size())), row++, 1); - - layout->addWidget(infoFrame); - - // Transactions section - if (!block.transactions.isEmpty()) { - auto* headerRow = new QHBoxLayout(); - headerRow->setContentsMargins(0, 16, 0, 6); - headerRow->setSpacing(8); - auto* headerIcon = new QLabel(); - headerIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(24, 24)); - auto* txHeader = new QLabel("Transactions"); - QFont headerFont = txHeader->font(); - headerFont.setPointSize(20); - headerFont.setBold(true); - txHeader->setFont(headerFont); - txHeader->setStyleSheet(Style::sectionHeader()); - headerRow->addWidget(headerIcon); - headerRow->addWidget(txHeader); - headerRow->addStretch(); - layout->addLayout(headerRow); - - for (const auto& tx : block.transactions) { - auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::NoFrame); - frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); - - auto* txRow = new QHBoxLayout(frame); - txRow->setSpacing(10); - - auto* icon = new QLabel(); - icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20)); - txRow->addWidget(icon); - - auto* hashLabel = new QLabel(tx.hash); - QFont boldFont = hashLabel->font(); - boldFont.setBold(true); - hashLabel->setFont(boldFont); - - QString typeStr = transactionTypeToString(tx.type); - auto* typeLabel = new QLabel(typeStr); - typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); - - auto* metaLabel = new QLabel(); - switch (tx.type) { - case TransactionType::Public: - metaLabel->setText(QString("%1 accounts").arg(tx.accounts.size())); - break; - case TransactionType::PrivacyPreserving: - metaLabel->setText(QString("%1 accounts, %2 commitments").arg(tx.accounts.size()).arg(tx.newCommitmentsCount)); - break; - case TransactionType::ProgramDeployment: - metaLabel->setText(QString("%1 bytes").arg(tx.bytecodeSizeBytes)); - break; - } - metaLabel->setStyleSheet(Style::mutedText()); - - txRow->addWidget(hashLabel); - txRow->addWidget(typeLabel); - txRow->addWidget(metaLabel, 1); - - QString txHash = tx.hash; - connect(frame, &ClickableFrame::clicked, this, [this, txHash]() { - emit transactionClicked(txHash); - }); - - layout->addWidget(frame); - } - } - - scrollArea->setWidget(scrollContent); - outerLayout->addWidget(scrollArea); -} diff --git a/src/pages/BlockPage.h b/src/pages/BlockPage.h deleted file mode 100644 index d870e0f..0000000 --- a/src/pages/BlockPage.h +++ /dev/null @@ -1,16 +0,0 @@ -#pragma once - -#include "models/Block.h" - -#include - -class BlockPage : public QWidget { - Q_OBJECT - -public: - explicit BlockPage(const Block& block, QWidget* parent = nullptr); - -signals: - void blockClicked(quint64 blockId); - void transactionClicked(const QString& hash); -}; diff --git a/src/pages/MainPage.cpp b/src/pages/MainPage.cpp deleted file mode 100644 index 5d5ee48..0000000 --- a/src/pages/MainPage.cpp +++ /dev/null @@ -1,453 +0,0 @@ -#include "MainPage.h" -#include "Style.h" -#include "widgets/ClickableFrame.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -MainPage::MainPage(IndexerService* indexer, QWidget* parent) - : QWidget(parent) - , m_indexer(indexer) -{ - auto* outerLayout = new QVBoxLayout(this); - outerLayout->setContentsMargins(0, 0, 0, 0); - - // Indexer endpoint selector - auto* endpointRow = new QHBoxLayout(); - endpointRow->setSpacing(8); - - auto* endpointLabel = new QLabel("Indexer config", this); - endpointLabel->setStyleSheet(Style::mutedText() + " font-weight: bold;"); - - m_indexerEndpointInput = new QLineEdit(this); - m_indexerEndpointInput->setText(m_indexer->endpoint()); - m_indexerEndpointInput->setPlaceholderText("absolute path to indexer_config.json (blank = already running)"); - m_indexerEndpointInput->setMinimumHeight(34); - m_indexerEndpointInput->setStyleSheet(Style::searchInput()); - - auto* applyEndpointBtn = new QPushButton("Apply", this); - applyEndpointBtn->setMinimumHeight(34); - applyEndpointBtn->setStyleSheet(Style::searchButton()); - - endpointRow->addWidget(endpointLabel); - endpointRow->addWidget(m_indexerEndpointInput, 1); - endpointRow->addWidget(applyEndpointBtn); - outerLayout->addLayout(endpointRow); - - connect(applyEndpointBtn, &QPushButton::clicked, this, &MainPage::applyIndexerEndpoint); - connect(m_indexerEndpointInput, &QLineEdit::returnPressed, this, &MainPage::applyIndexerEndpoint); - - // Health indicator - auto* healthRow = new QHBoxLayout(); - auto* healthIcon = new QLabel(this); - healthIcon->setPixmap(QIcon(":/icons/activity.svg").pixmap(20, 20)); - m_healthLabel = new QLabel(this); - healthRow->addWidget(healthIcon); - healthRow->addWidget(m_healthLabel); - healthRow->addStretch(); - outerLayout->addLayout(healthRow); - - // Scroll area for content - auto* scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); - - auto* scrollContent = new QWidget(); - scrollContent->setObjectName("scrollContent"); - m_contentLayout = new QVBoxLayout(scrollContent); - m_contentLayout->setAlignment(Qt::AlignTop); - m_contentLayout->setContentsMargins(0, 0, 0, 0); - - scrollArea->setWidget(scrollContent); - outerLayout->addWidget(scrollArea); - - refresh(); -} - -QWidget* MainPage::createSectionHeader(const QString& title, const QString& iconPath) -{ - auto* container = new QWidget(); - auto* layout = new QHBoxLayout(container); - layout->setContentsMargins(0, 12, 0, 6); - layout->setSpacing(8); - layout->setAlignment(Qt::AlignVCenter); - - if (!iconPath.isEmpty()) { - auto* icon = new QLabel(); - icon->setPixmap(QIcon(iconPath).pixmap(24, 24)); - icon->setFixedSize(24, 24); - layout->addWidget(icon); - } - - auto* label = new QLabel(title); - QFont font = label->font(); - font.setPointSize(20); - font.setBold(true); - label->setFont(font); - label->setStyleSheet(Style::sectionHeader()); - layout->addWidget(label); - layout->addStretch(); - - return container; -} - -void MainPage::addBlockRow(QVBoxLayout* layout, const Block& block, int insertIndex) -{ - auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::NoFrame); - frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); - frame->setProperty("blockId", QVariant::fromValue(block.blockId)); - - auto* row = new QHBoxLayout(frame); - row->setSpacing(10); - - auto* icon = new QLabel(); - icon->setPixmap(QIcon(":/icons/box.svg").pixmap(20, 20)); - row->addWidget(icon); - - auto* idLabel = new QLabel(QString("Block #%1").arg(block.blockId)); - QFont boldFont = idLabel->font(); - boldFont.setBold(true); - idLabel->setFont(boldFont); - - QString statusStr = bedrockStatusToString(block.bedrockStatus); - auto* statusLabel = new QLabel(statusStr); - statusLabel->setStyleSheet(Style::badge(Style::statusColor(statusStr))); - - auto* hashLabel = new QLabel(block.hash); - hashLabel->setStyleSheet(Style::mutedText() + " font-family: 'Menlo', 'Courier New'; font-size: 11px;"); - hashLabel->setFixedWidth(470); - - auto* txIcon = new QLabel(); - txIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(18, 18)); - - auto* txCount = new QLabel(QString::number(block.transactions.size())); - - auto* timeLabel = new QLabel(block.timestamp.toString("yyyy-MM-dd hh:mm:ss UTC")); - timeLabel->setStyleSheet(Style::mutedText()); - - statusLabel->setFixedWidth(90); - statusLabel->setAlignment(Qt::AlignCenter); - - row->addWidget(idLabel); - row->addWidget(hashLabel); - row->addStretch(1); - row->addWidget(txIcon); - row->addWidget(txCount); - row->addWidget(timeLabel); - row->addWidget(statusLabel); - - quint64 blockId = block.blockId; - connect(frame, &ClickableFrame::clicked, this, [this, blockId]() { - emit blockClicked(blockId); - }); - - if (insertIndex >= 0) { - layout->insertWidget(insertIndex, frame); - } else { - layout->addWidget(frame); - } -} - -void MainPage::insertRecentBlock(const Block& block) -{ - if (!m_blocksLayout || m_displayedBlockIds.contains(block.blockId)) { - return; - } - - int insertIndex = m_blocksLayout->count(); - for (int i = 0; i < m_blocksLayout->count(); ++i) { - QLayoutItem* item = m_blocksLayout->itemAt(i); - if (!item || !item->widget()) { - continue; - } - - bool ok = false; - const quint64 existingId = item->widget()->property("blockId").toULongLong(&ok); - if (ok && block.blockId > existingId) { - insertIndex = i; - break; - } - } - - addBlockRow(m_blocksLayout, block, insertIndex); - m_displayedBlockIds.insert(block.blockId); - - if (!m_newestLoadedBlockId || block.blockId > *m_newestLoadedBlockId) { - m_newestLoadedBlockId = block.blockId; - } - - if (!m_oldestLoadedBlockId || block.blockId < *m_oldestLoadedBlockId) { - m_oldestLoadedBlockId = block.blockId; - } -} - -QVector MainPage::fetchRecentBlocks(int limit) -{ - if (limit <= 0) { - return {}; - } - - auto blocks = m_indexer->getBlocks(std::nullopt, limit); - - std::sort(blocks.begin(), blocks.end(), [](const Block& lhs, const Block& rhs) { - return lhs.blockId > rhs.blockId; - }); - - if (blocks.size() > limit) { - blocks.resize(limit); - } - - return blocks; -} - -void MainPage::addTransactionRow(QVBoxLayout* layout, const Transaction& tx) -{ - auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::NoFrame); - frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); - - auto* row = new QHBoxLayout(frame); - row->setSpacing(10); - - auto* icon = new QLabel(); - icon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(20, 20)); - row->addWidget(icon); - - auto* hashLabel = new QLabel(tx.hash); - QFont boldFont = hashLabel->font(); - boldFont.setBold(true); - hashLabel->setFont(boldFont); - - QString typeStr = transactionTypeToString(tx.type); - auto* typeLabel = new QLabel(typeStr); - typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); - - auto* metaLabel = new QLabel(); - switch (tx.type) { - case TransactionType::Public: - metaLabel->setText(QString("%1 accounts").arg(tx.accounts.size())); - break; - case TransactionType::PrivacyPreserving: - metaLabel->setText(QString("%1 accounts, %2 commitments").arg(tx.accounts.size()).arg(tx.newCommitmentsCount)); - break; - case TransactionType::ProgramDeployment: - metaLabel->setText(QString("%1 bytes").arg(tx.bytecodeSizeBytes)); - break; - } - metaLabel->setStyleSheet(Style::mutedText()); - - row->addWidget(hashLabel); - row->addWidget(typeLabel); - row->addWidget(metaLabel, 1); - - QString txHash = tx.hash; - connect(frame, &ClickableFrame::clicked, this, [this, txHash]() { - emit transactionClicked(txHash); - }); - - layout->addWidget(frame); -} - -void MainPage::addAccountRow(QVBoxLayout* layout, const Account& account) -{ - auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::NoFrame); - frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); - - auto* row = new QHBoxLayout(frame); - row->setSpacing(10); - - auto* icon = new QLabel(); - icon->setPixmap(QIcon(":/icons/user.svg").pixmap(20, 20)); - row->addWidget(icon); - - auto* idLabel = new QLabel(account.accountId); - QFont boldFont = idLabel->font(); - boldFont.setBold(true); - idLabel->setFont(boldFont); - - auto* balanceLabel = new QLabel(QString("Balance: %1").arg(account.balance)); - auto* nonceLabel = new QLabel(QString("Nonce: %1").arg(account.nonce)); - nonceLabel->setStyleSheet(Style::mutedText()); - - row->addWidget(idLabel); - row->addWidget(balanceLabel, 1); - row->addWidget(nonceLabel); - - QString accId = account.accountId; - connect(frame, &ClickableFrame::clicked, this, [this, accId]() { - emit accountClicked(accId); - }); - - layout->addWidget(frame); -} - -void MainPage::refresh() -{ - clearSearchResults(); - - quint64 latestId = m_indexer->getLastFinalizedBlockId(); - m_latestKnownBlockId = latestId; - m_healthLabel->setText(QString("Chain height: %1").arg(m_latestKnownBlockId)); - m_healthLabel->setStyleSheet(Style::healthLabel()); - - if (m_recentBlocksWidget) { - m_contentLayout->removeWidget(m_recentBlocksWidget); - m_recentBlocksWidget->deleteLater(); - m_recentBlocksWidget = nullptr; - m_blocksLayout = nullptr; - m_loadMoreBtn = nullptr; - } - - m_newestLoadedBlockId = std::nullopt; - m_oldestLoadedBlockId = std::nullopt; - m_displayedBlockIds.clear(); - - m_recentBlocksWidget = new QWidget(); - auto* outerLayout = new QVBoxLayout(m_recentBlocksWidget); - outerLayout->setContentsMargins(0, 0, 0, 0); - outerLayout->addWidget(createSectionHeader("Recent Blocks", ":/icons/box.svg")); - - // Dedicated layout for block rows — button is appended after this in outerLayout - auto* blockRowsWidget = new QWidget(); - m_blocksLayout = new QVBoxLayout(blockRowsWidget); - m_blocksLayout->setContentsMargins(0, 0, 0, 0); - outerLayout->addWidget(blockRowsWidget); - - auto blocks = fetchRecentBlocks(10); - - for (const auto& block : blocks) { - insertRecentBlock(block); - } - - m_loadMoreBtn = new QPushButton("Load more"); - m_loadMoreBtn->setStyleSheet(Style::searchButton()); - m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1); - connect(m_loadMoreBtn, &QPushButton::clicked, this, &MainPage::loadMoreBlocks); - outerLayout->addWidget(m_loadMoreBtn, 0, Qt::AlignHCenter); - - m_contentLayout->addWidget(m_recentBlocksWidget); -} - -void MainPage::loadMoreBlocks() -{ - if (!m_oldestLoadedBlockId || *m_oldestLoadedBlockId <= 1) - return; - - auto blocks = m_indexer->getBlocks(m_oldestLoadedBlockId, 10); - std::sort(blocks.begin(), blocks.end(), [](const Block& lhs, const Block& rhs) { - return lhs.blockId > rhs.blockId; - }); - - for (const auto& block : blocks) { - insertRecentBlock(block); - } - - m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1 && !blocks.isEmpty()); -} - -void MainPage::applyIndexerEndpoint() -{ - // The "endpoint" is the indexer config path now; blank is valid and means - // "the indexer is already running" (start is idempotent provider-side). - const QString configPath = m_indexerEndpointInput->text().trimmed(); - m_indexer->setEndpoint(configPath); - clearSearchResults(); - refresh(); -} - -void MainPage::showSearchResults(const SearchResults& results) -{ - clearSearchResults(); - - if (m_recentBlocksWidget) { - m_recentBlocksWidget->hide(); - } - - m_searchResultsWidget = new QWidget(); - auto* layout = new QVBoxLayout(m_searchResultsWidget); - layout->setContentsMargins(0, 0, 0, 0); - - bool hasResults = false; - - if (!results.blocks.isEmpty()) { - layout->addWidget(createSectionHeader("Blocks", ":/icons/box.svg")); - for (const auto& block : results.blocks) { - addBlockRow(layout, block); - } - hasResults = true; - } - - if (!results.transactions.isEmpty()) { - layout->addWidget(createSectionHeader("Transactions", ":/icons/file-text.svg")); - for (const auto& tx : results.transactions) { - addTransactionRow(layout, tx); - } - hasResults = true; - } - - if (!results.accounts.isEmpty()) { - layout->addWidget(createSectionHeader("Accounts", ":/icons/user.svg")); - for (const auto& acc : results.accounts) { - addAccountRow(layout, acc); - } - hasResults = true; - } - - if (!hasResults) { - auto* noResults = new QLabel("No results found."); - noResults->setAlignment(Qt::AlignCenter); - noResults->setStyleSheet(Style::mutedText() + " padding: 20px; font-size: 14px;"); - layout->addWidget(noResults); - } - - m_contentLayout->insertWidget(0, m_searchResultsWidget); -} - -void MainPage::onNewBlock(const Block& block) -{ - if (!m_blocksLayout) return; - - m_latestKnownBlockId = std::max(m_latestKnownBlockId, block.blockId); - m_healthLabel->setText(QString("Chain height: %1").arg(m_latestKnownBlockId)); - - // If notifications jump ahead, rebuild recent list from backend - // so we do not mix one fresh head block with stale rows. - if (!m_newestLoadedBlockId) { - refresh(); - return; - } - - if (block.blockId > *m_newestLoadedBlockId && (block.blockId - *m_newestLoadedBlockId) > 1) { - refresh(); - return; - } - - insertRecentBlock(block); - - if (m_loadMoreBtn) { - m_loadMoreBtn->setVisible(m_oldestLoadedBlockId && *m_oldestLoadedBlockId > 1); - } -} - -void MainPage::clearSearchResults() -{ - if (m_searchResultsWidget) { - m_contentLayout->removeWidget(m_searchResultsWidget); - m_searchResultsWidget->deleteLater(); - m_searchResultsWidget = nullptr; - } - if (m_recentBlocksWidget) { - m_recentBlocksWidget->show(); - } -} diff --git a/src/pages/MainPage.h b/src/pages/MainPage.h deleted file mode 100644 index 8d151a1..0000000 --- a/src/pages/MainPage.h +++ /dev/null @@ -1,52 +0,0 @@ -#pragma once - -#include "services/IndexerService.h" - -#include -#include -#include - -class QVBoxLayout; -class QLabel; -class QPushButton; -class QLineEdit; - -class MainPage : public QWidget { - Q_OBJECT - -public: - explicit MainPage(IndexerService* indexer, QWidget* parent = nullptr); - - void refresh(); - void showSearchResults(const SearchResults& results); - void clearSearchResults(); - void onNewBlock(const Block& block); - -signals: - void blockClicked(quint64 blockId); - void transactionClicked(const QString& hash); - void accountClicked(const QString& accountId); - -private: - void addBlockRow(QVBoxLayout* layout, const Block& block, int insertIndex = -1); - void addTransactionRow(QVBoxLayout* layout, const Transaction& tx); - void addAccountRow(QVBoxLayout* layout, const Account& account); - QWidget* createSectionHeader(const QString& title, const QString& iconPath = {}); - QVector fetchRecentBlocks(int limit); - void insertRecentBlock(const Block& block); - void loadMoreBlocks(); - void applyIndexerEndpoint(); - - IndexerService* m_indexer = nullptr; - QVBoxLayout* m_contentLayout = nullptr; - QWidget* m_searchResultsWidget = nullptr; - QWidget* m_recentBlocksWidget = nullptr; - QVBoxLayout* m_blocksLayout = nullptr; - QPushButton* m_loadMoreBtn = nullptr; - QLabel* m_healthLabel = nullptr; - QLineEdit* m_indexerEndpointInput = nullptr; - std::optional m_newestLoadedBlockId; - std::optional m_oldestLoadedBlockId; - QSet m_displayedBlockIds; - quint64 m_latestKnownBlockId = 0; -}; diff --git a/src/pages/TransactionPage.cpp b/src/pages/TransactionPage.cpp deleted file mode 100644 index 9e514bc..0000000 --- a/src/pages/TransactionPage.cpp +++ /dev/null @@ -1,185 +0,0 @@ -#include "TransactionPage.h" -#include "Style.h" -#include "widgets/ClickableFrame.h" - -#include -#include -#include -#include -#include -#include -#include - -namespace { - -QLabel* makeFieldLabel(const QString& text) -{ - auto* label = new QLabel(text); - label->setStyleSheet(Style::mutedText() + " font-weight: bold;"); - return label; -} - -QLabel* makeValueLabel(const QString& text) -{ - auto* label = new QLabel(text); - label->setTextInteractionFlags(Qt::TextSelectableByMouse); - label->setWordWrap(true); - return label; -} - -} // namespace - -TransactionPage::TransactionPage(const Transaction& tx, QWidget* parent) - : QWidget(parent) -{ - auto* outerLayout = new QVBoxLayout(this); - outerLayout->setContentsMargins(0, 0, 0, 0); - - auto* scrollArea = new QScrollArea(this); - scrollArea->setWidgetResizable(true); - scrollArea->setFrameShape(QFrame::NoFrame); - scrollArea->setStyleSheet("QScrollArea { background: transparent; border: none; } QWidget#scrollContent { background: transparent; }"); - - auto* scrollContent = new QWidget(); - scrollContent->setObjectName("scrollContent"); - auto* layout = new QVBoxLayout(scrollContent); - layout->setAlignment(Qt::AlignTop); - layout->setContentsMargins(0, 0, 0, 0); - - // Title with icon - auto* titleRow = new QHBoxLayout(); - titleRow->setSpacing(10); - auto* titleIcon = new QLabel(); - titleIcon->setPixmap(QIcon(":/icons/file-text.svg").pixmap(30, 30)); - auto* title = new QLabel("Transaction Details"); - QFont titleFont = title->font(); - titleFont.setPointSize(24); - titleFont.setBold(true); - title->setFont(titleFont); - title->setStyleSheet(QString("color: %1;").arg(Style::Color::text())); - titleRow->addWidget(titleIcon); - titleRow->addWidget(title); - titleRow->addStretch(); - layout->addLayout(titleRow); - layout->addSpacing(8); - - // Transaction info grid - auto* infoFrame = new QFrame(); - infoFrame->setFrameShape(QFrame::NoFrame); - infoFrame->setStyleSheet(Style::cardFrameWithLabels()); - - auto* grid = new QGridLayout(infoFrame); - grid->setColumnStretch(1, 1); - grid->setVerticalSpacing(10); - grid->setHorizontalSpacing(20); - int row = 0; - - grid->addWidget(makeFieldLabel("Hash"), row, 0); - auto* hashVal = makeValueLabel(tx.hash); - hashVal->setStyleSheet(Style::monoText()); - grid->addWidget(hashVal, row++, 1); - - grid->addWidget(makeFieldLabel("Type"), row, 0); - QString typeStr = transactionTypeToString(tx.type); - auto* typeLabel = new QLabel(typeStr); - typeLabel->setStyleSheet(Style::badge(Style::txTypeColor(typeStr))); - typeLabel->setMaximumWidth(180); - grid->addWidget(typeLabel, row++, 1); - - // Type-specific fields - switch (tx.type) { - case TransactionType::Public: - grid->addWidget(makeFieldLabel("Program ID"), row, 0); - { auto* v = makeValueLabel(tx.programId); v->setStyleSheet(Style::monoText()); grid->addWidget(v, row++, 1); } - - grid->addWidget(makeFieldLabel("Instruction Data"), row, 0); - grid->addWidget(makeValueLabel(QString("%1 items").arg(tx.instructionData.size())), row++, 1); - - grid->addWidget(makeFieldLabel("Proof Size"), row, 0); - grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.proofSizeBytes)), row++, 1); - - grid->addWidget(makeFieldLabel("Signatures"), row, 0); - grid->addWidget(makeValueLabel(QString::number(tx.signatureCount)), row++, 1); - break; - - case TransactionType::PrivacyPreserving: - grid->addWidget(makeFieldLabel("Public Accounts"), row, 0); - grid->addWidget(makeValueLabel(QString::number(tx.accounts.size())), row++, 1); - - grid->addWidget(makeFieldLabel("New Commitments"), row, 0); - grid->addWidget(makeValueLabel(QString::number(tx.newCommitmentsCount)), row++, 1); - - grid->addWidget(makeFieldLabel("Nullifiers"), row, 0); - grid->addWidget(makeValueLabel(QString::number(tx.nullifiersCount)), row++, 1); - - grid->addWidget(makeFieldLabel("Encrypted States"), row, 0); - grid->addWidget(makeValueLabel(QString::number(tx.encryptedStatesCount)), row++, 1); - - grid->addWidget(makeFieldLabel("Proof Size"), row, 0); - grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.proofSizeBytes)), row++, 1); - - grid->addWidget(makeFieldLabel("Validity Window"), row, 0); - grid->addWidget(makeValueLabel(QString("[%1, %2)").arg(tx.validityWindowStart).arg(tx.validityWindowEnd)), row++, 1); - break; - - case TransactionType::ProgramDeployment: - grid->addWidget(makeFieldLabel("Bytecode Size"), row, 0); - grid->addWidget(makeValueLabel(QString("%1 bytes").arg(tx.bytecodeSizeBytes)), row++, 1); - break; - } - - layout->addWidget(infoFrame); - - // Accounts section - if (!tx.accounts.isEmpty()) { - auto* headerRow = new QHBoxLayout(); - headerRow->setContentsMargins(0, 16, 0, 6); - headerRow->setSpacing(8); - auto* headerIcon = new QLabel(); - headerIcon->setPixmap(QIcon(":/icons/user.svg").pixmap(24, 24)); - auto* accHeader = new QLabel("Accounts"); - QFont headerFont = accHeader->font(); - headerFont.setPointSize(20); - headerFont.setBold(true); - accHeader->setFont(headerFont); - accHeader->setStyleSheet(Style::sectionHeader()); - headerRow->addWidget(headerIcon); - headerRow->addWidget(accHeader); - headerRow->addStretch(); - layout->addLayout(headerRow); - - for (const auto& accRef : tx.accounts) { - auto* frame = new ClickableFrame(); - frame->setFrameShape(QFrame::NoFrame); - frame->setStyleSheet(Style::clickableRowWithLabels("ClickableFrame")); - - auto* accRow = new QHBoxLayout(frame); - accRow->setSpacing(10); - - auto* icon = new QLabel(); - icon->setPixmap(QIcon(":/icons/user.svg").pixmap(20, 20)); - accRow->addWidget(icon); - - auto* idLabel = new QLabel(accRef.accountId); - QFont boldFont = idLabel->font(); - boldFont.setBold(true); - idLabel->setFont(boldFont); - - auto* nonceLabel = new QLabel(QString("Nonce: %1").arg(accRef.nonce)); - nonceLabel->setStyleSheet(Style::mutedText()); - - accRow->addWidget(idLabel, 1); - accRow->addWidget(nonceLabel); - - QString accId = accRef.accountId; - connect(frame, &ClickableFrame::clicked, this, [this, accId]() { - emit accountClicked(accId); - }); - - layout->addWidget(frame); - } - } - - scrollArea->setWidget(scrollContent); - outerLayout->addWidget(scrollArea); -} diff --git a/src/pages/TransactionPage.h b/src/pages/TransactionPage.h deleted file mode 100644 index 7f6d978..0000000 --- a/src/pages/TransactionPage.h +++ /dev/null @@ -1,15 +0,0 @@ -#pragma once - -#include "models/Transaction.h" - -#include - -class TransactionPage : public QWidget { - Q_OBJECT - -public: - explicit TransactionPage(const Transaction& tx, QWidget* parent = nullptr); - -signals: - void accountClicked(const QString& accountId); -}; diff --git a/src/qml/Main.qml b/src/qml/Main.qml new file mode 100644 index 0000000..1074b7c --- /dev/null +++ b/src/qml/Main.qml @@ -0,0 +1,145 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme +import "components" + +Rectangle { + id: root + color: Theme.palette.background + + // Typed replica of the backend; non-null once the plugin is registered, + // `ready` tracks the live connection. + readonly property var backend: logos.module("lez_explorer_ui") + property bool ready: false + + // Self-managed navigation history (browser-style back/forward). Each entry + // is a descriptor { type, param?, query? }. + property var history: [] + property int histIndex: -1 + + // --- Navigation API (pages call these via the injected `explorer`) --- + function navigateTo(desc) { + var h = root.history.slice(0, root.histIndex + 1); + h.push(desc); + root.history = h; + root.histIndex = h.length - 1; + root.render(); + } + function navigateBlock(id) { root.navigateTo({ type: "block", param: id }); } + function navigateTx(hash) { root.navigateTo({ type: "tx", param: hash }); } + function navigateAccount(id) { root.navigateTo({ type: "account", param: id }); } + function showSearchResults(results, query) { + root.navigateTo({ type: "search", param: results, query: query }); + } + function goHome() { root.navigateTo({ type: "home" }); } + function goBack() { if (root.histIndex > 0) { root.histIndex--; root.render(); } } + function goForward() { if (root.histIndex < root.history.length - 1) { root.histIndex++; root.render(); } } + + function render() { + if (root.histIndex < 0) + return; + var d = root.history[root.histIndex]; + var props = { explorer: root }; + var url = "pages/HomePage.qml"; + if (d.type === "block") { + url = "pages/BlockPage.qml"; + props.blockId = d.param; + } else if (d.type === "tx") { + url = "pages/TransactionPage.qml"; + props.txHash = d.param; + } else if (d.type === "account") { + url = "pages/AccountPage.qml"; + props.accountId = d.param; + } else if (d.type === "search") { + url = "pages/SearchResultsPage.qml"; + props.results = d.param; + props.query = d.query || ""; + } + pageLoader.setSource(Qt.resolvedUrl(url), props); + navBar.canGoBack = root.histIndex > 0; + navBar.canGoForward = root.histIndex < root.history.length - 1; + } + + Connections { + target: logos + function onViewModuleReadyChanged(moduleName, isReady) { + if (moduleName === "lez_explorer_ui") + root.ready = isReady && root.backend !== null; + } + } + Component.onCompleted: { + root.ready = root.backend !== null && logos.isViewModuleReady("lez_explorer_ui"); + root.goHome(); + } + + ColumnLayout { + anchors.fill: parent + anchors.margins: Theme.spacing.large + spacing: Theme.spacing.medium + + NavigationBar { + id: navBar + Layout.fillWidth: true + onBackClicked: root.goBack() + onForwardClicked: root.goForward() + onHomeClicked: root.goHome() + } + + SearchBar { + Layout.fillWidth: true + onSearchRequested: function (query) { + if (!root.backend || query.trim().length === 0) + return; + logos.watch(root.backend.search(query), function (results) { + var blocks = results && results.blocks ? results.blocks : []; + var txs = results && results.transactions ? results.transactions : []; + var accts = results && results.accounts ? results.accounts : []; + var total = blocks.length + txs.length + accts.length; + if (total === 1) { + if (blocks.length === 1) + root.navigateBlock(blocks[0].blockId); + else if (txs.length === 1) + root.navigateTx(txs[0].hash); + else + root.navigateAccount(accts[0].accountId); + } else { + root.showSearchResults(results, query); + } + }, function (err) { + root.showSearchResults({ blocks: [], transactions: [], accounts: [] }, query); + }); + } + } + + Loader { + id: pageLoader + Layout.fillWidth: true + Layout.fillHeight: true + } + } + + // Readiness overlay — covers the view until the backend connects. + Rectangle { + anchors.fill: parent + visible: !root.ready + color: Theme.palette.background + + ColumnLayout { + anchors.centerIn: parent + spacing: Theme.spacing.medium + + BusyIndicator { + running: !root.ready + Layout.alignment: Qt.AlignHCenter + } + LogosText { + text: "Connecting to indexer backend…" + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + Layout.alignment: Qt.AlignHCenter + } + } + } +} diff --git a/src/qml/components/AccountRow.qml b/src/qml/components/AccountRow.qml new file mode 100644 index 0000000..2666ac4 --- /dev/null +++ b/src/qml/components/AccountRow.qml @@ -0,0 +1,57 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// Clickable row for an account reference ({ accountId, nonce?, balance? }). +// Used for tx account lists and account search results. +Rectangle { + id: root + + property var account: ({}) + signal clicked() + + height: layout.implicitHeight + Theme.spacing.medium * 2 + radius: Theme.spacing.radiusMedium + color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent" + border.width: 1 + border.color: hover.hovered ? Theme.palette.borderInteractive : Theme.palette.borderSecondary + + HoverHandler { id: hover } + TapHandler { onTapped: root.clicked() } + + RowLayout { + id: layout + anchors.fill: parent + anchors.margins: Theme.spacing.medium + spacing: Theme.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacing.tiny + + MonoText { + text: root.account.accountId || "" + color: Theme.palette.text + wrapMode: Text.NoWrap + elide: Text.ElideMiddle + Layout.fillWidth: true + } + LogosText { + visible: root.account.nonce !== undefined + text: "Nonce: " + (root.account.nonce !== undefined ? root.account.nonce : "") + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + } + + LogosText { + visible: root.account.balance !== undefined + text: (root.account.balance !== undefined ? root.account.balance : "") + color: Theme.palette.success + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightBold + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/src/qml/components/Badge.qml b/src/qml/components/Badge.qml new file mode 100644 index 0000000..e3af582 --- /dev/null +++ b/src/qml/components/Badge.qml @@ -0,0 +1,27 @@ +import QtQuick +import Logos.Controls +import Logos.Theme + +// A small coloured pill. `accent` tints both the text and a translucent fill. +Rectangle { + id: root + + property string text: "" + property color accent: Theme.palette.textSecondary + + implicitWidth: label.implicitWidth + Theme.spacing.medium * 2 + implicitHeight: label.implicitHeight + Theme.spacing.small + radius: Theme.spacing.radiusSmall + color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.15) + border.width: 1 + border.color: Qt.rgba(root.accent.r, root.accent.g, root.accent.b, 0.4) + + LogosText { + id: label + anchors.centerIn: parent + text: root.text + color: root.accent + font.pixelSize: Theme.typography.badgeText + font.weight: Theme.typography.weightBold + } +} diff --git a/src/qml/components/BlockRow.qml b/src/qml/components/BlockRow.qml new file mode 100644 index 0000000..0135d39 --- /dev/null +++ b/src/qml/components/BlockRow.qml @@ -0,0 +1,59 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// Clickable summary row for a block map ({ blockId, hash, txCount, +// timestampText, status }). Used on the home feed and in search results. +Rectangle { + id: root + + property var block: ({}) + signal clicked() + + height: layout.implicitHeight + Theme.spacing.medium * 2 + radius: Theme.spacing.radiusMedium + color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent" + border.width: 1 + border.color: hover.hovered ? Theme.palette.borderInteractive : Theme.palette.borderSecondary + + HoverHandler { id: hover } + TapHandler { onTapped: root.clicked() } + + RowLayout { + id: layout + anchors.fill: parent + anchors.margins: Theme.spacing.medium + spacing: Theme.spacing.medium + + ColumnLayout { + Layout.fillWidth: true + spacing: Theme.spacing.tiny + + LogosText { + text: "Block #" + (root.block.blockId !== undefined ? root.block.blockId : "?") + color: Theme.palette.text + font.pixelSize: Theme.typography.primaryText + font.weight: Theme.typography.weightBold + } + MonoText { + text: root.block.hash || "" + color: Theme.palette.textSecondary + wrapMode: Text.NoWrap + elide: Text.ElideMiddle + Layout.fillWidth: true + } + LogosText { + text: (root.block.txCount !== undefined ? root.block.txCount : 0) + " txs · " + + (root.block.timestampText || "") + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + } + + StatusBadge { + status: root.block.status || "Pending" + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/src/qml/components/Card.qml b/src/qml/components/Card.qml new file mode 100644 index 0000000..a293d59 --- /dev/null +++ b/src/qml/components/Card.qml @@ -0,0 +1,30 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Theme + +// A themed surface panel that sizes to its content. Children are stacked +// vertically (they become items of the inner ColumnLayout). +Rectangle { + id: root + + property int padding: Theme.spacing.large + property int spacing: Theme.spacing.medium + default property alias content: inner.data + + implicitHeight: inner.implicitHeight + padding * 2 + implicitWidth: inner.implicitWidth + padding * 2 + + color: Theme.palette.backgroundElevated + border.color: Theme.palette.borderSecondary + border.width: 1 + radius: Theme.spacing.radiusLarge + + ColumnLayout { + id: inner + anchors.left: parent.left + anchors.right: parent.right + anchors.top: parent.top + anchors.margins: root.padding + spacing: root.spacing + } +} diff --git a/src/qml/components/FieldLabel.qml b/src/qml/components/FieldLabel.qml new file mode 100644 index 0000000..1946f79 --- /dev/null +++ b/src/qml/components/FieldLabel.qml @@ -0,0 +1,13 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// The fixed-width label used at the start of a detail-card row. Pairs with +// InfoRow (plain values) or a custom delegate built inline in a page. +LogosText { + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + Layout.preferredWidth: 150 + Layout.alignment: Qt.AlignTop +} diff --git a/src/qml/components/IconButton.qml b/src/qml/components/IconButton.qml new file mode 100644 index 0000000..28d0cc1 --- /dev/null +++ b/src/qml/components/IconButton.qml @@ -0,0 +1,33 @@ +import QtQuick +import Logos.Theme + +// Themed square icon button. `source` is an SVG url; emits clicked(). +Rectangle { + id: root + + property url source + property bool enabled: true + property int size: 40 + signal clicked() + + implicitWidth: size + implicitHeight: size + radius: Theme.spacing.radiusMedium + opacity: root.enabled ? 1.0 : 0.4 + color: hover.hovered && root.enabled ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated + border.width: 1 + border.color: Theme.palette.borderSecondary + + HoverHandler { id: hover; enabled: root.enabled } + TapHandler { enabled: root.enabled; onTapped: root.clicked() } + + Image { + anchors.centerIn: parent + source: root.source + sourceSize.width: Math.round(root.size * 0.5) + sourceSize.height: Math.round(root.size * 0.5) + width: Math.round(root.size * 0.5) + height: Math.round(root.size * 0.5) + fillMode: Image.PreserveAspectFit + } +} diff --git a/src/qml/components/InfoRow.qml b/src/qml/components/InfoRow.qml new file mode 100644 index 0000000..04d4a9f --- /dev/null +++ b/src/qml/components/InfoRow.qml @@ -0,0 +1,39 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// One "Label value" row in a detail card. The value can be monospace. For +// non-text values (badges, clickable links) build a small RowLayout in the page +// with a FieldLabel instead. +RowLayout { + id: root + + property string label: "" + property string value: "" + property bool mono: false + property color valueColor: Theme.palette.text + + spacing: Theme.spacing.medium + Layout.fillWidth: true + + FieldLabel { text: root.label } + + LogosText { + visible: !root.mono + text: root.value + color: root.valueColor + font.pixelSize: Theme.typography.secondaryText + wrapMode: Text.WrapAnywhere + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + } + + MonoText { + visible: root.mono + text: root.value + color: root.valueColor + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + } +} diff --git a/src/qml/components/MonoText.qml b/src/qml/components/MonoText.qml new file mode 100644 index 0000000..3a82b93 --- /dev/null +++ b/src/qml/components/MonoText.qml @@ -0,0 +1,12 @@ +import QtQuick +import Logos.Controls +import Logos.Theme + +// Monospace, selectable text for hashes / ids / signatures. +LogosText { + font.family: "Menlo, Monaco, Courier New, monospace" + font.pixelSize: Theme.typography.secondaryText + color: Theme.palette.text + textFormat: Text.PlainText + wrapMode: Text.WrapAnywhere +} diff --git a/src/qml/components/NavigationBar.qml b/src/qml/components/NavigationBar.qml new file mode 100644 index 0000000..5f64c11 --- /dev/null +++ b/src/qml/components/NavigationBar.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// Top navigation: back / forward / home + title. +RowLayout { + id: root + + property bool canGoBack: false + property bool canGoForward: false + signal backClicked() + signal forwardClicked() + signal homeClicked() + + spacing: Theme.spacing.small + + IconButton { + source: Qt.resolvedUrl("../icons/arrow-left.svg") + enabled: root.canGoBack + onClicked: root.backClicked() + } + IconButton { + source: Qt.resolvedUrl("../icons/arrow-right.svg") + enabled: root.canGoForward + onClicked: root.forwardClicked() + } + IconButton { + source: Qt.resolvedUrl("../icons/home.svg") + onClicked: root.homeClicked() + } + + LogosText { + text: "LEZ Explorer" + color: Theme.palette.primary + font.pixelSize: Theme.typography.titleText + font.weight: Theme.typography.weightBold + Layout.leftMargin: Theme.spacing.small + Layout.alignment: Qt.AlignVCenter + } + + Item { Layout.fillWidth: true } +} diff --git a/src/qml/components/SearchBar.qml b/src/qml/components/SearchBar.qml new file mode 100644 index 0000000..0f2b023 --- /dev/null +++ b/src/qml/components/SearchBar.qml @@ -0,0 +1,70 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// Search input + button. Emits searchRequested(query) on Enter or button click. +RowLayout { + id: root + + signal searchRequested(string query) + + spacing: Theme.spacing.small + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 40 + radius: Theme.spacing.radiusMedium + color: Theme.palette.backgroundElevated + border.width: 1 + border.color: field.activeFocus ? Theme.palette.borderInteractive : Theme.palette.borderSecondary + + RowLayout { + anchors.fill: parent + anchors.leftMargin: Theme.spacing.medium + anchors.rightMargin: Theme.spacing.medium + spacing: Theme.spacing.small + + Image { + source: Qt.resolvedUrl("../icons/search.svg") + sourceSize.width: 18 + sourceSize.height: 18 + width: 18 + height: 18 + fillMode: Image.PreserveAspectFit + Layout.alignment: Qt.AlignVCenter + } + + TextField { + id: field + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + placeholderText: "Search by block id / block hash / tx hash / account id…" + color: Theme.palette.text + placeholderTextColor: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + background: Item {} + onAccepted: root.searchRequested(field.text) + } + } + } + + Rectangle { + Layout.preferredHeight: 40 + Layout.preferredWidth: 92 + radius: Theme.spacing.radiusMedium + color: btnHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary + + HoverHandler { id: btnHover } + TapHandler { onTapped: root.searchRequested(field.text) } + + LogosText { + anchors.centerIn: parent + text: "Search" + color: Theme.palette.background + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightBold + } + } +} diff --git a/src/qml/components/SectionHeader.qml b/src/qml/components/SectionHeader.qml new file mode 100644 index 0000000..6e66aea --- /dev/null +++ b/src/qml/components/SectionHeader.qml @@ -0,0 +1,11 @@ +import QtQuick +import Logos.Controls +import Logos.Theme + +LogosText { + property string title: "" + text: title + color: Theme.palette.text + font.pixelSize: Theme.typography.titleText + font.weight: Theme.typography.weightBold +} diff --git a/src/qml/components/StatusBadge.qml b/src/qml/components/StatusBadge.qml new file mode 100644 index 0000000..a1f524f --- /dev/null +++ b/src/qml/components/StatusBadge.qml @@ -0,0 +1,11 @@ +import QtQuick +import Logos.Theme + +// Bedrock status pill: Finalized (success), Safe (warning), Pending (muted). +Badge { + property string status: "Pending" + text: status + accent: status === "Finalized" ? Theme.palette.success + : status === "Safe" ? Theme.palette.warning + : Theme.palette.textMuted +} diff --git a/src/qml/components/TxRow.qml b/src/qml/components/TxRow.qml new file mode 100644 index 0000000..12dca6e --- /dev/null +++ b/src/qml/components/TxRow.qml @@ -0,0 +1,43 @@ +import QtQuick +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme + +// Clickable summary row for a transaction map ({ hash, type, ... }). Used in +// the block-detail tx list, account history, and search results. +Rectangle { + id: root + + property var tx: ({}) + signal clicked() + + height: layout.implicitHeight + Theme.spacing.medium * 2 + radius: Theme.spacing.radiusMedium + color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent" + border.width: 1 + border.color: hover.hovered ? Theme.palette.borderInteractive : Theme.palette.borderSecondary + + HoverHandler { id: hover } + TapHandler { onTapped: root.clicked() } + + RowLayout { + id: layout + anchors.fill: parent + anchors.margins: Theme.spacing.medium + spacing: Theme.spacing.medium + + MonoText { + text: root.tx.hash || "" + color: Theme.palette.text + wrapMode: Text.NoWrap + elide: Text.ElideMiddle + Layout.fillWidth: true + Layout.alignment: Qt.AlignVCenter + } + + TxTypeBadge { + type: root.tx.type || "Public" + Layout.alignment: Qt.AlignVCenter + } + } +} diff --git a/src/qml/components/TxTypeBadge.qml b/src/qml/components/TxTypeBadge.qml new file mode 100644 index 0000000..48be5d0 --- /dev/null +++ b/src/qml/components/TxTypeBadge.qml @@ -0,0 +1,13 @@ +import QtQuick +import Logos.Theme + +// Transaction-type pill, with a friendly label per kind. +Badge { + property string type: "Public" + text: type === "PrivacyPreserving" ? "Privacy-Preserving" + : type === "ProgramDeployment" ? "Program Deployment" + : type + accent: type === "PrivacyPreserving" ? Theme.palette.primary + : type === "ProgramDeployment" ? Theme.palette.accentOrange + : Theme.palette.info +} diff --git a/src/icons/activity.svg b/src/qml/icons/activity.svg similarity index 100% rename from src/icons/activity.svg rename to src/qml/icons/activity.svg diff --git a/src/icons/arrow-left.svg b/src/qml/icons/arrow-left.svg similarity index 100% rename from src/icons/arrow-left.svg rename to src/qml/icons/arrow-left.svg diff --git a/src/icons/arrow-right.svg b/src/qml/icons/arrow-right.svg similarity index 100% rename from src/icons/arrow-right.svg rename to src/qml/icons/arrow-right.svg diff --git a/src/icons/box.svg b/src/qml/icons/box.svg similarity index 100% rename from src/icons/box.svg rename to src/qml/icons/box.svg diff --git a/src/icons/file-text.svg b/src/qml/icons/file-text.svg similarity index 100% rename from src/icons/file-text.svg rename to src/qml/icons/file-text.svg diff --git a/src/icons/home.svg b/src/qml/icons/home.svg similarity index 100% rename from src/icons/home.svg rename to src/qml/icons/home.svg diff --git a/src/icons/search.svg b/src/qml/icons/search.svg similarity index 100% rename from src/icons/search.svg rename to src/qml/icons/search.svg diff --git a/src/icons/user.svg b/src/qml/icons/user.svg similarity index 100% rename from src/icons/user.svg rename to src/qml/icons/user.svg diff --git a/src/qml/pages/AccountPage.qml b/src/qml/pages/AccountPage.qml new file mode 100644 index 0000000..a597ce5 --- /dev/null +++ b/src/qml/pages/AccountPage.qml @@ -0,0 +1,113 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme +import "../components" + +Item { + id: page + + property var explorer + readonly property var backend: explorer ? explorer.backend : null + property string accountId: "" + + property var account: ({}) + property bool loaded: false + property var txs: [] + property bool txsLoaded: false + + Component.onCompleted: page.fetch() + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: col.implicitHeight + clip: true + + ColumnLayout { + id: col + width: parent.width + spacing: Theme.spacing.medium + + SectionHeader { title: "Account Details" } + + LogosText { + visible: !page.loaded + text: "Loading…" + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + LogosText { + visible: page.loaded && Object.keys(page.account).length <= 1 + text: "Account not found." + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + + Card { + Layout.fillWidth: true + visible: page.loaded && Object.keys(page.account).length > 1 + + InfoRow { label: "Account ID"; value: page.account.accountId || ""; mono: true } + InfoRow { + label: "Balance" + value: page.account.balance || "0" + valueColor: Theme.palette.success + } + InfoRow { label: "Program Owner"; value: page.account.programOwner || ""; mono: true } + InfoRow { label: "Nonce"; value: page.account.nonce || "0" } + InfoRow { + label: "Data Size" + value: (page.account.dataSizeBytes !== undefined ? page.account.dataSizeBytes : 0) + " bytes" + } + } + + SectionHeader { + visible: page.loaded && Object.keys(page.account).length > 1 + title: "Transaction History" + } + + LogosText { + visible: page.txsLoaded && page.txs.length === 0 && Object.keys(page.account).length > 1 + text: "No transactions found." + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + + Repeater { + model: page.txs + delegate: TxRow { + Layout.fillWidth: true + tx: modelData + onClicked: page.explorer.navigateTx(modelData.hash) + } + } + + Item { Layout.preferredHeight: Theme.spacing.large } + } + } + + function fetch() { + if (!page.backend) + return; + logos.watch(page.backend.getAccount(page.accountId), + function (result) { + page.account = result || ({}); + page.loaded = true; + }, + function (err) { + page.account = ({}); + page.loaded = true; + }); + logos.watch(page.backend.getTransactionsByAccount(page.accountId, 0, 10), + function (result) { + page.txs = result || []; + page.txsLoaded = true; + }, + function (err) { + page.txs = []; + page.txsLoaded = true; + }); + } +} diff --git a/src/qml/pages/BlockPage.qml b/src/qml/pages/BlockPage.qml new file mode 100644 index 0000000..cf55d36 --- /dev/null +++ b/src/qml/pages/BlockPage.qml @@ -0,0 +1,118 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme +import "../components" + +Item { + id: page + + property var explorer + readonly property var backend: explorer ? explorer.backend : null + property var blockId: 0 + + property var block: ({}) + property bool loaded: false + + Component.onCompleted: page.fetch() + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: col.implicitHeight + clip: true + + ColumnLayout { + id: col + width: parent.width + spacing: Theme.spacing.medium + + SectionHeader { title: "Block #" + page.blockId } + + LogosText { + visible: !page.loaded + text: "Loading…" + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + + LogosText { + visible: page.loaded && Object.keys(page.block).length === 0 + text: "Block not found." + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + + Card { + Layout.fillWidth: true + visible: page.loaded && Object.keys(page.block).length > 0 + + InfoRow { label: "Block ID"; value: String(page.block.blockId !== undefined ? page.block.blockId : "") } + InfoRow { label: "Hash"; value: page.block.hash || ""; mono: true } + + // Previous hash — a clickable link to the previous block when not genesis. + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + FieldLabel { text: "Previous Hash" } + MonoText { + Layout.fillWidth: true + Layout.alignment: Qt.AlignTop + text: page.block.prevBlockHash || "" + color: page.blockId > 1 ? Theme.palette.primary : Theme.palette.text + font.underline: page.blockId > 1 && prevHover.hovered + HoverHandler { id: prevHover; enabled: page.blockId > 1 } + TapHandler { + enabled: page.blockId > 1 + onTapped: page.explorer.navigateBlock(Number(page.blockId) - 1) + } + } + } + + InfoRow { label: "Timestamp"; value: page.block.timestampText || "" } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + FieldLabel { text: "Status" } + StatusBadge { status: page.block.status || "Pending"; Layout.alignment: Qt.AlignTop } + Item { Layout.fillWidth: true } + } + + InfoRow { label: "Signature"; value: page.block.signature || ""; mono: true } + InfoRow { label: "Transactions"; value: String(page.block.txCount !== undefined ? page.block.txCount : 0) } + } + + SectionHeader { + visible: page.loaded && page.block.transactions !== undefined && page.block.transactions.length > 0 + title: "Transactions (" + (page.block.transactions ? page.block.transactions.length : 0) + ")" + } + + Repeater { + model: page.block.transactions !== undefined ? page.block.transactions : [] + delegate: TxRow { + Layout.fillWidth: true + tx: modelData + onClicked: page.explorer.navigateTx(modelData.hash) + } + } + + Item { Layout.preferredHeight: Theme.spacing.large } + } + } + + function fetch() { + if (!page.backend) + return; + logos.watch(page.backend.getBlockById(String(page.blockId)), + function (result) { + page.block = result || ({}); + page.loaded = true; + }, + function (err) { + page.block = ({}); + page.loaded = true; + }); + } +} diff --git a/src/qml/pages/HomePage.qml b/src/qml/pages/HomePage.qml new file mode 100644 index 0000000..fa39ccb --- /dev/null +++ b/src/qml/pages/HomePage.qml @@ -0,0 +1,172 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme +import "../components" + +Item { + id: page + + // Injected by Main: the controller (navigation + backend access). + property var explorer + readonly property var backend: explorer ? explorer.backend : null + + ColumnLayout { + anchors.fill: parent + spacing: Theme.spacing.medium + + // Indexer config + health. + Card { + Layout.fillWidth: true + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + + LogosText { + text: "Indexer config" + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + Layout.alignment: Qt.AlignVCenter + } + + Rectangle { + Layout.fillWidth: true + Layout.preferredHeight: 38 + radius: Theme.spacing.radiusMedium + color: Theme.palette.backgroundSecondary + border.width: 1 + border.color: configField.activeFocus ? Theme.palette.borderInteractive + : Theme.palette.borderSecondary + + TextField { + id: configField + anchors.fill: parent + anchors.leftMargin: Theme.spacing.medium + anchors.rightMargin: Theme.spacing.medium + verticalAlignment: TextInput.AlignVCenter + text: page.backend ? page.backend.indexerConfig : "" + placeholderText: "absolute path to indexer_config.json (blank = already running)" + color: Theme.palette.text + placeholderTextColor: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + background: Item {} + onAccepted: page.applyConfig() + } + } + + Rectangle { + Layout.preferredHeight: 38 + Layout.preferredWidth: 84 + radius: Theme.spacing.radiusMedium + color: applyHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary + + HoverHandler { id: applyHover } + TapHandler { onTapped: page.applyConfig() } + + LogosText { + anchors.centerIn: parent + text: "Apply" + color: Theme.palette.background + font.pixelSize: Theme.typography.secondaryText + font.weight: Theme.typography.weightBold + } + } + } + + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.small + + Rectangle { + width: 9 + height: 9 + radius: 4.5 + Layout.alignment: Qt.AlignVCenter + color: page.backend && page.backend.connectionStatus === "Connected" + ? Theme.palette.success + : Theme.palette.warning + } + LogosText { + text: { + if (!page.backend) + return ""; + var height = page.backend.chainHeight > 0 ? page.backend.chainHeight : "—"; + return "Chain height: " + height + " · " + page.backend.connectionStatus; + } + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + } + } + + SectionHeader { title: "Recent Blocks" } + + ListView { + id: blockList + Layout.fillWidth: true + Layout.fillHeight: true + clip: true + spacing: Theme.spacing.small + model: page.backend ? page.backend.recentBlocks : [] + + delegate: BlockRow { + width: ListView.view.width + block: modelData + onClicked: page.explorer.navigateBlock(modelData.blockId) + } + + footer: Item { + width: blockList.width + height: loadMore.visible ? loadMore.implicitHeight + Theme.spacing.large : 0 + + Rectangle { + id: loadMore + anchors.horizontalCenter: parent.horizontalCenter + anchors.top: parent.top + anchors.topMargin: Theme.spacing.medium + visible: blockList.count > 0 + implicitHeight: 36 + implicitWidth: 140 + radius: Theme.spacing.radiusMedium + color: moreHover.hovered ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated + border.width: 1 + border.color: Theme.palette.borderSecondary + + HoverHandler { id: moreHover } + TapHandler { onTapped: if (page.backend) page.backend.loadMoreBlocks() } + + LogosText { + anchors.centerIn: parent + text: "Load more" + color: Theme.palette.textSecondary + font.pixelSize: Theme.typography.secondaryText + } + } + } + + // Empty state. + LogosText { + anchors.centerIn: parent + visible: blockList.count === 0 + width: parent.width * 0.8 + horizontalAlignment: Text.AlignHCenter + wrapMode: Text.WordWrap + text: page.backend && page.backend.connectionStatus === "Connected" + ? "No blocks indexed yet." + : "Waiting for the indexer… set the config path above if it isn't running." + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + } + } + + function applyConfig() { + if (!page.backend) + return; + // Empty port → backend keeps its default (8779). + logos.watch(page.backend.applyIndexerConfig(configField.text, ""), + function (ok) {}, function (err) {}); + } +} diff --git a/src/qml/pages/SearchResultsPage.qml b/src/qml/pages/SearchResultsPage.qml new file mode 100644 index 0000000..5b74826 --- /dev/null +++ b/src/qml/pages/SearchResultsPage.qml @@ -0,0 +1,88 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme +import "../components" + +Item { + id: page + + property var explorer + readonly property var backend: explorer ? explorer.backend : null + property var results: ({ blocks: [], transactions: [], accounts: [] }) + property string query: "" + + readonly property var blocks: results && results.blocks ? results.blocks : [] + readonly property var transactions: results && results.transactions ? results.transactions : [] + readonly property var accounts: results && results.accounts ? results.accounts : [] + readonly property int total: blocks.length + transactions.length + accounts.length + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: col.implicitHeight + clip: true + + ColumnLayout { + id: col + width: parent.width + spacing: Theme.spacing.medium + + SectionHeader { title: "Search Results" } + + LogosText { + visible: page.total === 0 + text: page.query.length > 0 + ? "No results for \"" + page.query + "\"." + : "No results." + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + + SectionHeader { + visible: page.blocks.length > 0 + title: "Blocks (" + page.blocks.length + ")" + font.pixelSize: Theme.typography.subtitleText + } + Repeater { + model: page.blocks + delegate: BlockRow { + Layout.fillWidth: true + block: modelData + onClicked: page.explorer.navigateBlock(modelData.blockId) + } + } + + SectionHeader { + visible: page.transactions.length > 0 + title: "Transactions (" + page.transactions.length + ")" + font.pixelSize: Theme.typography.subtitleText + } + Repeater { + model: page.transactions + delegate: TxRow { + Layout.fillWidth: true + tx: modelData + onClicked: page.explorer.navigateTx(modelData.hash) + } + } + + SectionHeader { + visible: page.accounts.length > 0 + title: "Accounts (" + page.accounts.length + ")" + font.pixelSize: Theme.typography.subtitleText + } + Repeater { + model: page.accounts + delegate: AccountRow { + Layout.fillWidth: true + account: modelData + onClicked: page.explorer.navigateAccount(modelData.accountId) + } + } + + Item { Layout.preferredHeight: Theme.spacing.large } + } + } +} diff --git a/src/qml/pages/TransactionPage.qml b/src/qml/pages/TransactionPage.qml new file mode 100644 index 0000000..b540a6e --- /dev/null +++ b/src/qml/pages/TransactionPage.qml @@ -0,0 +1,143 @@ +import QtQuick +import QtQuick.Controls +import QtQuick.Layouts +import Logos.Controls +import Logos.Theme +import "../components" + +Item { + id: page + + property var explorer + readonly property var backend: explorer ? explorer.backend : null + property string txHash: "" + + property var tx: ({}) + property bool loaded: false + readonly property string txType: tx.type || "" + + Component.onCompleted: page.fetch() + + Flickable { + anchors.fill: parent + contentWidth: width + contentHeight: col.implicitHeight + clip: true + + ColumnLayout { + id: col + width: parent.width + spacing: Theme.spacing.medium + + SectionHeader { title: "Transaction Details" } + + LogosText { + visible: !page.loaded + text: "Loading…" + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + LogosText { + visible: page.loaded && Object.keys(page.tx).length === 0 + text: "Transaction not found." + color: Theme.palette.textMuted + font.pixelSize: Theme.typography.secondaryText + } + + Card { + Layout.fillWidth: true + visible: page.loaded && Object.keys(page.tx).length > 0 + + InfoRow { label: "Hash"; value: page.tx.hash || ""; mono: true } + RowLayout { + Layout.fillWidth: true + spacing: Theme.spacing.medium + FieldLabel { text: "Type" } + TxTypeBadge { type: page.txType || "Public"; Layout.alignment: Qt.AlignTop } + Item { Layout.fillWidth: true } + } + + // Public. + InfoRow { + visible: page.txType === "Public" + label: "Program ID"; value: page.tx.programId || ""; mono: true + } + InfoRow { + visible: page.txType === "Public" + label: "Instruction Data" + value: (page.tx.instructionDataCount !== undefined ? page.tx.instructionDataCount : 0) + " words" + } + InfoRow { + visible: page.txType === "Public" || page.txType === "PrivacyPreserving" + label: "Signatures" + value: String(page.tx.signatureCount !== undefined ? page.tx.signatureCount : 0) + } + + // Privacy-preserving. + InfoRow { + visible: page.txType === "PrivacyPreserving" + label: "New Commitments" + value: String(page.tx.newCommitmentsCount !== undefined ? page.tx.newCommitmentsCount : 0) + } + InfoRow { + visible: page.txType === "PrivacyPreserving" + label: "Nullifiers" + value: String(page.tx.nullifiersCount !== undefined ? page.tx.nullifiersCount : 0) + } + InfoRow { + visible: page.txType === "PrivacyPreserving" + label: "Encrypted States" + value: String(page.tx.encryptedStatesCount !== undefined ? page.tx.encryptedStatesCount : 0) + } + InfoRow { + visible: page.txType === "PrivacyPreserving" + label: "Proof Size" + value: (page.tx.proofSizeBytes !== undefined ? page.tx.proofSizeBytes : 0) + " bytes" + } + InfoRow { + visible: page.txType === "PrivacyPreserving" + label: "Validity Window" + value: "[" + (page.tx.validityWindowStart !== undefined ? page.tx.validityWindowStart : 0) + + ", " + (page.tx.validityWindowEnd !== undefined ? page.tx.validityWindowEnd : 0) + ")" + } + + // Program deployment. + InfoRow { + visible: page.txType === "ProgramDeployment" + label: "Bytecode Size" + value: (page.tx.bytecodeSizeBytes !== undefined ? page.tx.bytecodeSizeBytes : 0) + " bytes" + } + } + + SectionHeader { + visible: page.loaded && page.tx.accounts !== undefined && page.tx.accounts.length > 0 + title: "Accounts (" + (page.tx.accounts ? page.tx.accounts.length : 0) + ")" + } + + Repeater { + model: page.tx.accounts !== undefined ? page.tx.accounts : [] + delegate: AccountRow { + Layout.fillWidth: true + account: modelData + onClicked: page.explorer.navigateAccount(modelData.accountId) + } + } + + Item { Layout.preferredHeight: Theme.spacing.large } + } + } + + function fetch() { + if (!page.backend) + return; + logos.watch(page.backend.getTransaction(page.txHash), + function (result) { + page.tx = result || ({}); + page.loaded = true; + }, + function (err) { + page.tx = ({}); + page.loaded = true; + }); + } +} diff --git a/src/services/IndexerService.h b/src/services/IndexerService.h deleted file mode 100644 index a5ffc8a..0000000 --- a/src/services/IndexerService.h +++ /dev/null @@ -1,46 +0,0 @@ -#pragma once - -#include "models/Block.h" -#include "models/Transaction.h" -#include "models/Account.h" - -#include -#include -#include - -struct SearchResults { - QVector blocks; - QVector transactions; - QVector accounts; -}; - -class IndexerService : public QObject { - Q_OBJECT - -public: - explicit IndexerService(QObject* parent = nullptr) : QObject(parent) {} - ~IndexerService() override = default; - - // Over the Logos protocol the "endpoint" is repurposed to the indexer - // config path (absolute path to indexer_config.json); blank means the - // indexer is already running. No network URL is involved anymore. - static QString defaultEndpoint() - { - return QString(); - } - - virtual QString endpoint() const = 0; - virtual void setEndpoint(const QString& endpoint) = 0; - - virtual std::optional getAccount(const QString& accountId) = 0; - virtual std::optional getBlockById(quint64 blockId) = 0; - virtual std::optional getBlockByHash(const QString& hash) = 0; - virtual std::optional getTransaction(const QString& hash) = 0; - virtual QVector getBlocks(std::optional before, int limit) = 0; - virtual quint64 getLastFinalizedBlockId() = 0; - virtual QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0; - virtual SearchResults search(const QString& query) = 0; - -signals: - void newBlockAdded(Block block); -}; diff --git a/src/services/LpIndexerService.cpp b/src/services/LpIndexerService.cpp deleted file mode 100644 index 4e5097e..0000000 --- a/src/services/LpIndexerService.cpp +++ /dev/null @@ -1,438 +0,0 @@ -#include "LpIndexerService.h" - -#include "logos_api.h" -#include "logos_api_client.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#include - -namespace { - -constexpr auto kModuleName = "lez_indexer_module"; -constexpr int kPollIntervalMs = 2000; - -// 64/128-bit values arrive as decimal strings (to dodge JSON double precision -// loss), but be lenient and also accept a JSON number. -quint64 jsonU64(const QJsonValue& value) -{ - if (value.isString()) { - bool ok = false; - const qulonglong parsed = value.toString().toULongLong(&ok); - return ok ? parsed : 0; - } - if (value.isDouble()) { - const double number = value.toDouble(); - return number > 0.0 ? static_cast(number) : 0; - } - return 0; -} - -QString jsonStr(const QJsonValue& value) -{ - if (value.isString()) { - return value.toString(); - } - if (value.isDouble()) { - return QString::number(static_cast(value.toDouble())); - } - return {}; -} - -QDateTime timestampFromSeconds(quint64 timestamp) -{ - QDateTime dt; - // Tolerate either seconds or milliseconds since epoch. - if (timestamp > 1000000000000ULL) { - dt = QDateTime::fromMSecsSinceEpoch(static_cast(timestamp), QTimeZone::UTC); - } else { - dt = QDateTime::fromSecsSinceEpoch(static_cast(timestamp), QTimeZone::UTC); - } - return dt.isValid() ? dt : QDateTime::currentDateTimeUtc(); -} - -} // namespace - -LpIndexerService::LpIndexerService(LogosAPI* logosAPI, const QString& configPath, const QString& port, QObject* parent) - : IndexerService(parent) - , m_logosAPI(logosAPI) - , m_configPath(configPath.trimmed()) - , m_port(port.trimmed()) -{ - if (m_logosAPI) { - m_client = m_logosAPI->getClient(kModuleName); - } - if (!m_client) { - qWarning() << "LpIndexerService: no LogosAPI client for" << kModuleName; - } - - // Best-effort boot (no-op provider-side if already running, or if no config). - startIndexer(); - - // The provider has no push event — poll the tip for live head updates. - m_pollTimer = new QTimer(this); - m_pollTimer->setInterval(kPollIntervalMs); - connect(m_pollTimer, &QTimer::timeout, this, &LpIndexerService::pollForNewBlocks); - m_pollTimer->start(); -} - -LpIndexerService::~LpIndexerService() = default; - -QString LpIndexerService::endpoint() const -{ - return m_configPath; -} - -void LpIndexerService::setEndpoint(const QString& endpoint) -{ - const QString trimmed = endpoint.trimmed(); - if (trimmed == m_configPath) { - return; - } - m_configPath = trimmed; - // Re-baseline the live poller; the (re)started indexer may ingest afresh. - m_lastNotifiedBlockId = 0; - m_polledOnce = false; - startIndexer(); -} - -void LpIndexerService::startIndexer() -{ - if (!m_client || m_configPath.isEmpty()) { - return; // assume the indexer was started elsewhere - } - const QVariant result = m_client->invokeRemoteMethod(kModuleName, "start_indexer", m_configPath, m_port); - const int code = result.isValid() ? result.toInt() : -1; - if (code != 0) { - qWarning() << "LpIndexerService: start_indexer returned" << code << "for" << m_configPath; - } -} - -std::optional LpIndexerService::getAccount(const QString& accountId) -{ - if (!m_client) { - return std::nullopt; - } - const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getAccount", accountId); - return parseAccount(accountId, r.isValid() ? r.toString() : QString()); -} - -std::optional LpIndexerService::getBlockById(quint64 blockId) -{ - if (!m_client) { - return std::nullopt; - } - const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getBlockById", QString::number(blockId)); - return parseBlock(r.isValid() ? r.toString() : QString()); -} - -std::optional LpIndexerService::getBlockByHash(const QString& hash) -{ - if (!m_client) { - return std::nullopt; - } - const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getBlockByHash", hash); - return parseBlock(r.isValid() ? r.toString() : QString()); -} - -std::optional LpIndexerService::getTransaction(const QString& hash) -{ - if (!m_client) { - return std::nullopt; - } - const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getTransaction", hash); - const QString json = r.isValid() ? r.toString() : QString(); - if (json.isEmpty()) { - return std::nullopt; - } - const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); - if (!doc.isObject()) { - return std::nullopt; - } - return parseTransaction(doc.object()); -} - -QVector LpIndexerService::getBlocks(std::optional before, int limit) -{ - if (!m_client) { - return {}; - } - const QString beforeArg = before.has_value() ? QString::number(*before) : QString(); - const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getBlocks", beforeArg, QString::number(limit)); - const QString json = r.isValid() ? r.toString() : QString(); - if (json.isEmpty()) { - return {}; - } - - const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); - if (!doc.isArray()) { - return {}; - } - - QVector blocks; - const QJsonArray array = doc.array(); - blocks.reserve(array.size()); - for (const auto& item : array) { - if (auto block = parseBlockObject(item.toObject())) { - blocks.append(*block); - } - } - return blocks; -} - -quint64 LpIndexerService::getLastFinalizedBlockId() -{ - if (!m_client) { - return 0; - } - const QVariant r = m_client->invokeRemoteMethod(kModuleName, "getLastFinalizedBlockId"); - if (!r.isValid()) { - return 0; - } - bool ok = false; - const qulonglong parsed = r.toString().toULongLong(&ok); - return ok ? parsed : 0; -} - -QVector LpIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit) -{ - if (!m_client) { - return {}; - } - const QVariant r = m_client->invokeRemoteMethod( - kModuleName, "getTransactionsByAccount", accountId, QString::number(offset), QString::number(limit)); - const QString json = r.isValid() ? r.toString() : QString(); - if (json.isEmpty()) { - return {}; - } - - const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); - if (!doc.isArray()) { - return {}; - } - - QVector transactions; - const QJsonArray array = doc.array(); - transactions.reserve(array.size()); - for (const auto& item : array) { - if (auto tx = parseTransaction(item.toObject())) { - transactions.append(*tx); - } - } - return transactions; -} - -SearchResults LpIndexerService::search(const QString& query) -{ - SearchResults results; - const QString trimmed = query.trimmed(); - if (trimmed.isEmpty()) { - return results; - } - - static const QRegularExpression hashRegex(QStringLiteral("^[0-9a-fA-F]{64}$")); - if (hashRegex.match(trimmed).hasMatch()) { - if (auto block = getBlockByHash(trimmed)) { - results.blocks.append(*block); - } - if (auto tx = getTransaction(trimmed)) { - results.transactions.append(*tx); - } - if (auto account = getAccount(trimmed)) { - results.accounts.append(*account); - } - } - - bool ok = false; - const quint64 blockId = trimmed.toULongLong(&ok); - if (ok) { - if (auto block = getBlockById(blockId)) { - const bool duplicate = std::any_of(results.blocks.begin(), results.blocks.end(), - [blockId](const Block& existing) { return existing.blockId == blockId; }); - if (!duplicate) { - results.blocks.append(*block); - } - } - } - - return results; -} - -void LpIndexerService::pollForNewBlocks() -{ - const quint64 latest = getLastFinalizedBlockId(); - if (latest == 0) { - return; - } - - // First successful poll establishes a baseline without re-emitting blocks - // the initial MainPage::refresh already loaded. - if (!m_polledOnce) { - m_polledOnce = true; - m_lastNotifiedBlockId = latest; - return; - } - - if (latest <= m_lastNotifiedBlockId) { - return; - } - - // For a large jump (or a gap), emit only the new head: MainPage::onNewBlock - // detects the discontinuity and rebuilds the recent list from the backend. - if (latest - m_lastNotifiedBlockId > 8) { - if (auto block = getBlockById(latest)) { - m_lastNotifiedBlockId = latest; - emit newBlockAdded(*block); - } - return; - } - - for (quint64 id = m_lastNotifiedBlockId + 1; id <= latest; ++id) { - if (auto block = getBlockById(id)) { - emit newBlockAdded(*block); - } - } - m_lastNotifiedBlockId = latest; -} - -std::optional LpIndexerService::parseBlock(const QString& json) const -{ - if (json.isEmpty()) { - return std::nullopt; - } - const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); - if (!doc.isObject()) { - return std::nullopt; - } - return parseBlockObject(doc.object()); -} - -std::optional LpIndexerService::parseBlockObject(const QJsonObject& obj) const -{ - if (obj.isEmpty()) { - return std::nullopt; - } - - Block block; - block.blockId = jsonU64(obj.value("block_id")); - block.hash = jsonStr(obj.value("hash")); - block.prevBlockHash = jsonStr(obj.value("prev_block_hash")); - block.signature = jsonStr(obj.value("signature")); - block.timestamp = timestampFromSeconds(jsonU64(obj.value("timestamp"))); - - const QString status = obj.value("bedrock_status").toString("Pending"); - if (status == "Finalized") { - block.bedrockStatus = BedrockStatus::Finalized; - } else if (status == "Safe") { - block.bedrockStatus = BedrockStatus::Safe; - } else { - block.bedrockStatus = BedrockStatus::Pending; - } - - const QJsonArray txArray = obj.value("transactions").toArray(); - block.transactions.reserve(txArray.size()); - for (const auto& txValue : txArray) { - if (auto tx = parseTransaction(txValue.toObject())) { - block.transactions.append(*tx); - } - } - - return block; -} - -std::optional LpIndexerService::parseTransaction(const QJsonObject& obj) const -{ - if (obj.isEmpty()) { - return std::nullopt; - } - - Transaction tx; - tx.hash = jsonStr(obj.value("hash")); - - const QString type = obj.value("type").toString(); - - auto appendAccounts = [&tx](const QJsonArray& accounts) { - for (const auto& entry : accounts) { - const QJsonObject account = entry.toObject(); - AccountRef ref; - ref.accountId = jsonStr(account.value("account_id")); - ref.nonce = jsonStr(account.value("nonce")); - if (ref.nonce.isEmpty()) { - ref.nonce = "0"; - } - tx.accounts.append(ref); - } - }; - - if (type == "Public") { - tx.type = TransactionType::Public; - tx.programId = jsonStr(obj.value("program_id")); - appendAccounts(obj.value("accounts").toArray()); - - const QJsonArray instructionData = obj.value("instruction_data").toArray(); - tx.instructionData.reserve(instructionData.size()); - for (const auto& v : instructionData) { - tx.instructionData.append(static_cast(jsonU64(v))); - } - tx.signatureCount = obj.value("signature_count").toInt(); - return tx; - } - - if (type == "PrivacyPreserving") { - tx.type = TransactionType::PrivacyPreserving; - appendAccounts(obj.value("accounts").toArray()); - tx.newCommitmentsCount = obj.value("new_commitments_count").toInt(); - tx.nullifiersCount = obj.value("nullifiers_count").toInt(); - tx.encryptedStatesCount = obj.value("encrypted_states_count").toInt(); - tx.validityWindowStart = jsonU64(obj.value("validity_window_start")); - tx.validityWindowEnd = jsonU64(obj.value("validity_window_end")); - tx.signatureCount = obj.value("signature_count").toInt(); - tx.proofSizeBytes = obj.value("proof_size").toInt(); - return tx; - } - - if (type == "ProgramDeployment") { - tx.type = TransactionType::ProgramDeployment; - tx.bytecodeSizeBytes = obj.value("bytecode_size").toInt(); - return tx; - } - - return std::nullopt; -} - -std::optional LpIndexerService::parseAccount(const QString& accountId, const QString& json) const -{ - if (json.isEmpty()) { - return std::nullopt; - } - const QJsonDocument doc = QJsonDocument::fromJson(json.toUtf8()); - if (!doc.isObject()) { - return std::nullopt; - } - const QJsonObject obj = doc.object(); - - Account account; - account.accountId = accountId; // provider payload omits it; inject the queried id - account.programOwner = jsonStr(obj.value("program_owner")); - account.balance = jsonStr(obj.value("balance")); - account.nonce = jsonStr(obj.value("nonce")); - account.dataSizeBytes = obj.value("data_size").toInt(); - - if (account.balance.isEmpty()) { - account.balance = "0"; - } - if (account.nonce.isEmpty()) { - account.nonce = "0"; - } - - return account; -} diff --git a/src/services/LpIndexerService.h b/src/services/LpIndexerService.h deleted file mode 100644 index 3d94754..0000000 --- a/src/services/LpIndexerService.h +++ /dev/null @@ -1,69 +0,0 @@ -#pragma once - -#include "IndexerService.h" - -#include -#include - -class LogosAPI; -class LogosAPIClient; -class QTimer; -class QJsonObject; - -// IndexerService implementation that talks to `lez_indexer_module` IN-PROCESS -// over the Logos protocol (Qt Remote Objects), via -// LogosAPI::getClient(...)->invokeRemoteMethod(...). Replaces the old WebSocket -// JSON-RPC RpcIndexerService. -// -// The provider returns a flat, compact JSON schema (hex hashes; 64/128-bit -// values as decimal strings; counts/sizes as numbers; "" == not-found), which -// differs from the old RPC schema — so this class carries its OWN parsers. -// -// The provider exposes no new-block push event, so live head updates are POLLED -// on a 2 s timer over getLastFinalizedBlockId. -// -// `endpoint()`/`setEndpoint()` are repurposed to the indexer CONFIG PATH (an -// absolute path to indexer_config.json) used to best-effort start the indexer; -// blank means "the indexer is already running" (start is idempotent provider-side). -class LpIndexerService : public IndexerService { - Q_OBJECT - -public: - explicit LpIndexerService( - LogosAPI* logosAPI, - const QString& configPath = QString(), - const QString& port = QStringLiteral("8779"), - QObject* parent = nullptr - ); - ~LpIndexerService() override; - - QString endpoint() const override; // the indexer config path - void setEndpoint(const QString& endpoint) override; // set config path + (re)start - - std::optional getAccount(const QString& accountId) override; - std::optional getBlockById(quint64 blockId) override; - std::optional getBlockByHash(const QString& hash) override; - std::optional getTransaction(const QString& hash) override; - QVector getBlocks(std::optional before, int limit) override; - quint64 getLastFinalizedBlockId() override; - QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) override; - SearchResults search(const QString& query) override; - -private: - void startIndexer(); - void pollForNewBlocks(); - - // Parsers for the provider's compact schema. - std::optional parseBlock(const QString& json) const; - std::optional parseBlockObject(const QJsonObject& obj) const; - std::optional parseTransaction(const QJsonObject& obj) const; - std::optional parseAccount(const QString& accountId, const QString& json) const; - - LogosAPI* m_logosAPI = nullptr; - LogosAPIClient* m_client = nullptr; - QString m_configPath; - QString m_port; - QTimer* m_pollTimer = nullptr; - quint64 m_lastNotifiedBlockId = 0; - bool m_polledOnce = false; -}; diff --git a/src/services/MockIndexerService.cpp b/src/services/MockIndexerService.cpp deleted file mode 100644 index 2176feb..0000000 --- a/src/services/MockIndexerService.cpp +++ /dev/null @@ -1,378 +0,0 @@ -#include "MockIndexerService.h" - -#include -#include - -namespace { - -QString randomHexString(int bytes) -{ - QString result; - result.reserve(bytes * 2); - auto* rng = QRandomGenerator::global(); - for (int i = 0; i < bytes; ++i) { - result += QString("%1").arg(rng->bounded(256), 2, 16, QChar('0')); - } - return result; -} - -// Generate a base58-like string (simplified — uses alphanumeric chars without 0/O/I/l) -QString randomBase58String(int length) -{ - static const char chars[] = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz"; - QString result; - result.reserve(length); - auto* rng = QRandomGenerator::global(); - for (int i = 0; i < length; ++i) { - result += chars[rng->bounded(static_cast(sizeof(chars) - 1))]; - } - return result; -} - -} // namespace - -MockIndexerService::MockIndexerService(QObject* parent) - : IndexerService(parent) -{ - generateData(); - - connect(&m_blockTimer, &QTimer::timeout, this, &MockIndexerService::onGenerateBlock); - m_blockTimer.start(30000); // 30 seconds -} - -QString MockIndexerService::endpoint() const -{ - return m_endpoint; -} - -void MockIndexerService::setEndpoint(const QString& endpoint) -{ - m_endpoint = endpoint.trimmed(); - if (m_endpoint.isEmpty()) { - m_endpoint = IndexerService::defaultEndpoint(); - } -} - -Block MockIndexerService::generateBlock(quint64 blockId, const QString& prevHash) -{ - auto* rng = QRandomGenerator::global(); - - Block block; - block.blockId = blockId; - block.prevBlockHash = prevHash; - block.hash = randomHash(); - block.timestamp = QDateTime::currentDateTimeUtc(); - block.signature = randomHexString(64); - block.bedrockStatus = BedrockStatus::Pending; - - int txCount = rng->bounded(1, 6); - for (int t = 0; t < txCount; ++t) { - Transaction tx; - int typeRoll = rng->bounded(100); - if (typeRoll < 60) { - tx = generatePublicTransaction(); - } else if (typeRoll < 85) { - tx = generatePrivacyPreservingTransaction(); - } else { - tx = generateProgramDeploymentTransaction(); - } - block.transactions.append(tx); - m_transactionsByHash[tx.hash] = tx; - - for (const auto& accRef : tx.accounts) { - if (!m_accounts.contains(accRef.accountId)) { - Account acc; - acc.accountId = accRef.accountId; - acc.programOwner = randomBase58String(44); - acc.balance = QString::number(rng->bounded(0, 1000000)); - acc.nonce = accRef.nonce; - acc.dataSizeBytes = rng->bounded(0, 4096); - m_accounts[acc.accountId] = acc; - } - } - } - - return block; -} - -void MockIndexerService::onGenerateBlock() -{ - quint64 newId = m_blocks.isEmpty() ? 1 : m_blocks.last().blockId + 1; - QString prevHash = m_blocks.isEmpty() ? QString(64, '0') : m_blocks.last().hash; - - Block block = generateBlock(newId, prevHash); - m_blocks.append(block); - m_blocksByHash[block.hash] = block; - - emit newBlockAdded(block); -} - -QString MockIndexerService::randomHash() -{ - return randomHexString(32); -} - -QString MockIndexerService::randomAccountId() -{ - return randomBase58String(44); -} - -Transaction MockIndexerService::generatePublicTransaction() -{ - auto* rng = QRandomGenerator::global(); - - Transaction tx; - tx.hash = randomHash(); - tx.type = TransactionType::Public; - tx.programId = randomBase58String(44); - tx.signatureCount = rng->bounded(1, 4); - tx.proofSizeBytes = rng->bounded(0, 2048); - - int accountCount = rng->bounded(1, 5); - for (int i = 0; i < accountCount; ++i) { - // Reuse existing accounts sometimes - QString accId; - if (!m_accounts.isEmpty() && rng->bounded(100) < 60) { - auto keys = m_accounts.keys(); - accId = keys[rng->bounded(keys.size())]; - } else { - accId = randomAccountId(); - } - tx.accounts.append({accId, QString::number(rng->bounded(1, 100))}); - } - - int instrCount = rng->bounded(1, 8); - for (int i = 0; i < instrCount; ++i) { - tx.instructionData.append(rng->generate()); - } - - return tx; -} - -Transaction MockIndexerService::generatePrivacyPreservingTransaction() -{ - auto* rng = QRandomGenerator::global(); - - Transaction tx; - tx.hash = randomHash(); - tx.type = TransactionType::PrivacyPreserving; - tx.proofSizeBytes = rng->bounded(512, 4096); - tx.newCommitmentsCount = rng->bounded(1, 6); - tx.nullifiersCount = rng->bounded(1, 4); - tx.encryptedStatesCount = rng->bounded(1, 3); - tx.validityWindowStart = rng->bounded(1, 50); - tx.validityWindowEnd = tx.validityWindowStart + rng->bounded(50, 200); - - int accountCount = rng->bounded(1, 4); - for (int i = 0; i < accountCount; ++i) { - QString accId; - if (!m_accounts.isEmpty() && rng->bounded(100) < 60) { - auto keys = m_accounts.keys(); - accId = keys[rng->bounded(keys.size())]; - } else { - accId = randomAccountId(); - } - tx.accounts.append({accId, QString::number(rng->bounded(1, 100))}); - } - - return tx; -} - -Transaction MockIndexerService::generateProgramDeploymentTransaction() -{ - auto* rng = QRandomGenerator::global(); - - Transaction tx; - tx.hash = randomHash(); - tx.type = TransactionType::ProgramDeployment; - tx.bytecodeSizeBytes = rng->bounded(1024, 65536); - tx.signatureCount = 1; - - return tx; -} - -void MockIndexerService::generateData() -{ - auto* rng = QRandomGenerator::global(); - - // Generate accounts - for (int i = 0; i < 15; ++i) { - Account acc; - acc.accountId = randomAccountId(); - acc.programOwner = randomBase58String(44); - acc.balance = QString::number(rng->bounded(0, 1000000)); - acc.nonce = QString::number(rng->bounded(0, 500)); - acc.dataSizeBytes = rng->bounded(0, 4096); - m_accounts[acc.accountId] = acc; - } - - // Generate blocks - QString prevHash = QString(64, '0'); - QDateTime timestamp = QDateTime::currentDateTimeUtc().addSecs(-25 * 12); // ~5 min ago - - for (quint64 id = 1; id <= 25; ++id) { - Block block; - block.blockId = id; - block.prevBlockHash = prevHash; - block.hash = randomHash(); - block.timestamp = timestamp; - block.signature = randomHexString(64); - - // Older blocks are finalized, middle ones safe, recent ones pending - if (id <= 15) { - block.bedrockStatus = BedrockStatus::Finalized; - } else if (id <= 22) { - block.bedrockStatus = BedrockStatus::Safe; - } else { - block.bedrockStatus = BedrockStatus::Pending; - } - - // Generate 1-5 transactions per block - int txCount = rng->bounded(1, 6); - for (int t = 0; t < txCount; ++t) { - Transaction tx; - int typeRoll = rng->bounded(100); - if (typeRoll < 60) { - tx = generatePublicTransaction(); - } else if (typeRoll < 85) { - tx = generatePrivacyPreservingTransaction(); - } else { - tx = generateProgramDeploymentTransaction(); - } - block.transactions.append(tx); - m_transactionsByHash[tx.hash] = tx; - - // Ensure accounts referenced in transactions exist - for (const auto& accRef : tx.accounts) { - if (!m_accounts.contains(accRef.accountId)) { - Account acc; - acc.accountId = accRef.accountId; - acc.programOwner = randomBase58String(44); - acc.balance = QString::number(rng->bounded(0, 1000000)); - acc.nonce = accRef.nonce; - acc.dataSizeBytes = rng->bounded(0, 4096); - m_accounts[acc.accountId] = acc; - } - } - } - - m_blocks.append(block); - m_blocksByHash[block.hash] = block; - prevHash = block.hash; - timestamp = timestamp.addSecs(12); - } -} - -std::optional MockIndexerService::getAccount(const QString& accountId) -{ - auto it = m_accounts.find(accountId); - if (it != m_accounts.end()) { - return *it; - } - return std::nullopt; -} - -std::optional MockIndexerService::getBlockById(quint64 blockId) -{ - if (blockId >= 1 && blockId <= static_cast(m_blocks.size())) { - return m_blocks[static_cast(blockId - 1)]; - } - return std::nullopt; -} - -std::optional MockIndexerService::getBlockByHash(const QString& hash) -{ - auto it = m_blocksByHash.find(hash); - if (it != m_blocksByHash.end()) { - return *it; - } - return std::nullopt; -} - -std::optional MockIndexerService::getTransaction(const QString& hash) -{ - auto it = m_transactionsByHash.find(hash); - if (it != m_transactionsByHash.end()) { - return *it; - } - return std::nullopt; -} - -QVector MockIndexerService::getBlocks(std::optional before, int limit) -{ - QVector result; - int startIdx = m_blocks.size() - 1; - - if (before.has_value()) { - startIdx = static_cast(*before) - 2; // -1 for 0-index, -1 for "before" - } - - for (int i = startIdx; i >= 0 && result.size() < limit; --i) { - result.append(m_blocks[i]); - } - - return result; -} - -quint64 MockIndexerService::getLastFinalizedBlockId() -{ - if (m_blocks.isEmpty()) return 0; - return m_blocks.last().blockId; -} - -QVector MockIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit) -{ - QVector result; - - // Search all transactions for ones referencing this account - for (auto it = m_transactionsByHash.begin(); it != m_transactionsByHash.end(); ++it) { - for (const auto& accRef : it->accounts) { - if (accRef.accountId == accountId) { - result.append(*it); - break; - } - } - } - - // Apply offset and limit - if (offset >= result.size()) return {}; - return result.mid(offset, limit); -} - -SearchResults MockIndexerService::search(const QString& query) -{ - SearchResults results; - QString trimmed = query.trimmed(); - - if (trimmed.isEmpty()) return results; - - // Try as block ID (numeric) - bool ok = false; - quint64 blockId = trimmed.toULongLong(&ok); - if (ok) { - auto block = getBlockById(blockId); - if (block) { - results.blocks.append(*block); - } - } - - // Try as hash (hex string, 64 chars) - if (trimmed.size() == 64) { - auto block = getBlockByHash(trimmed); - if (block) { - results.blocks.append(*block); - } - auto tx = getTransaction(trimmed); - if (tx) { - results.transactions.append(*tx); - } - } - - // Try as account ID - auto account = getAccount(trimmed); - if (account) { - results.accounts.append(*account); - } - - return results; -} diff --git a/src/services/MockIndexerService.h b/src/services/MockIndexerService.h deleted file mode 100644 index 5c57c3e..0000000 --- a/src/services/MockIndexerService.h +++ /dev/null @@ -1,44 +0,0 @@ -#pragma once - -#include "IndexerService.h" - -#include -#include - -class MockIndexerService : public IndexerService { - Q_OBJECT - -public: - explicit MockIndexerService(QObject* parent = nullptr); - - QString endpoint() const override; - void setEndpoint(const QString& endpoint) override; - - std::optional getAccount(const QString& accountId) override; - std::optional getBlockById(quint64 blockId) override; - std::optional getBlockByHash(const QString& hash) override; - std::optional getTransaction(const QString& hash) override; - QVector getBlocks(std::optional before, int limit) override; - quint64 getLastFinalizedBlockId() override; - QVector getTransactionsByAccount(const QString& accountId, int offset, int limit) override; - SearchResults search(const QString& query) override; - -private slots: - void onGenerateBlock(); - -private: - void generateData(); - QString randomHash(); - QString randomAccountId(); - Transaction generatePublicTransaction(); - Transaction generatePrivacyPreservingTransaction(); - Transaction generateProgramDeploymentTransaction(); - Block generateBlock(quint64 blockId, const QString& prevHash); - - QVector m_blocks; - QMap m_blocksByHash; - QMap m_transactionsByHash; - QMap m_accounts; - QTimer m_blockTimer; - QString m_endpoint = IndexerService::defaultEndpoint(); -}; diff --git a/src/widgets/ClickableFrame.h b/src/widgets/ClickableFrame.h deleted file mode 100644 index c250672..0000000 --- a/src/widgets/ClickableFrame.h +++ /dev/null @@ -1,47 +0,0 @@ -#pragma once - -#include "Style.h" - -#include -#include - -class ClickableFrame : public QFrame { - Q_OBJECT - -public: - explicit ClickableFrame(QWidget* parent = nullptr) - : QFrame(parent) - { - setCursor(Qt::PointingHandCursor); - } - -signals: - void clicked(); - -protected: - void mousePressEvent(QMouseEvent* event) override - { - if (event->button() == Qt::LeftButton) { - emit clicked(); - } - QFrame::mousePressEvent(event); - } - - void enterEvent(QEnterEvent* event) override - { - m_baseStyleSheet = styleSheet(); - setStyleSheet(m_baseStyleSheet + - QString(" ClickableFrame { background: %1 !important; border-color: %2 !important; }") - .arg(Style::Color::surfaceHover(), Style::Color::accent())); - QFrame::enterEvent(event); - } - - void leaveEvent(QEvent* event) override - { - setStyleSheet(m_baseStyleSheet); - QFrame::leaveEvent(event); - } - -private: - QString m_baseStyleSheet; -}; diff --git a/src/widgets/NavigationBar.cpp b/src/widgets/NavigationBar.cpp deleted file mode 100644 index c45045e..0000000 --- a/src/widgets/NavigationBar.cpp +++ /dev/null @@ -1,63 +0,0 @@ -#include "NavigationBar.h" -#include "Style.h" - -#include -#include -#include -#include - -NavigationBar::NavigationBar(QWidget* parent) - : QWidget(parent) -{ - auto* layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 0, 0, 0); - layout->setSpacing(6); - - const QSize iconSize(22, 22); - - m_backBtn = new QPushButton(this); - m_backBtn->setIcon(QIcon(":/icons/arrow-left.svg")); - m_backBtn->setIconSize(iconSize); - m_backBtn->setFixedSize(42, 42); - m_backBtn->setEnabled(false); - m_backBtn->setToolTip("Back"); - m_backBtn->setStyleSheet(Style::navButton()); - - m_forwardBtn = new QPushButton(this); - m_forwardBtn->setIcon(QIcon(":/icons/arrow-right.svg")); - m_forwardBtn->setIconSize(iconSize); - m_forwardBtn->setFixedSize(42, 42); - m_forwardBtn->setEnabled(false); - m_forwardBtn->setToolTip("Forward"); - m_forwardBtn->setStyleSheet(Style::navButton()); - - auto* homeBtn = new QPushButton(this); - homeBtn->setIcon(QIcon(":/icons/home.svg")); - homeBtn->setIconSize(iconSize); - homeBtn->setFixedSize(42, 42); - homeBtn->setToolTip("Home"); - homeBtn->setStyleSheet(Style::navButton()); - - auto* titleLabel = new QLabel("LEZ Explorer"); - titleLabel->setStyleSheet(QString("color: %1; font-size: 14px; font-weight: bold; margin-left: 8px;").arg(Style::Color::accent())); - - layout->addWidget(m_backBtn); - layout->addWidget(m_forwardBtn); - layout->addWidget(homeBtn); - layout->addWidget(titleLabel); - layout->addStretch(); - - connect(m_backBtn, &QPushButton::clicked, this, &NavigationBar::backClicked); - connect(m_forwardBtn, &QPushButton::clicked, this, &NavigationBar::forwardClicked); - connect(homeBtn, &QPushButton::clicked, this, &NavigationBar::homeClicked); -} - -void NavigationBar::setBackEnabled(bool enabled) -{ - m_backBtn->setEnabled(enabled); -} - -void NavigationBar::setForwardEnabled(bool enabled) -{ - m_forwardBtn->setEnabled(enabled); -} diff --git a/src/widgets/NavigationBar.h b/src/widgets/NavigationBar.h deleted file mode 100644 index 836efe3..0000000 --- a/src/widgets/NavigationBar.h +++ /dev/null @@ -1,24 +0,0 @@ -#pragma once - -#include - -class QPushButton; - -class NavigationBar : public QWidget { - Q_OBJECT - -public: - explicit NavigationBar(QWidget* parent = nullptr); - - void setBackEnabled(bool enabled); - void setForwardEnabled(bool enabled); - -signals: - void backClicked(); - void forwardClicked(); - void homeClicked(); - -private: - QPushButton* m_backBtn = nullptr; - QPushButton* m_forwardBtn = nullptr; -}; diff --git a/src/widgets/SearchBar.cpp b/src/widgets/SearchBar.cpp deleted file mode 100644 index 3f9aece..0000000 --- a/src/widgets/SearchBar.cpp +++ /dev/null @@ -1,38 +0,0 @@ -#include "SearchBar.h" -#include "Style.h" - -#include -#include -#include - -SearchBar::SearchBar(QWidget* parent) - : QWidget(parent) -{ - auto* layout = new QHBoxLayout(this); - layout->setContentsMargins(0, 4, 0, 4); - layout->setSpacing(8); - - m_input = new QLineEdit(this); - m_input->setPlaceholderText("Search by block ID / block hash / tx hash / account ID..."); - m_input->setMinimumHeight(38); - m_input->setStyleSheet(Style::searchInput()); - - auto* searchBtn = new QPushButton("Search", this); - searchBtn->setMinimumHeight(38); - searchBtn->setStyleSheet(Style::searchButton()); - - layout->addWidget(m_input, 1); - layout->addWidget(searchBtn); - - connect(m_input, &QLineEdit::returnPressed, this, [this]() { - emit searchRequested(m_input->text()); - }); - connect(searchBtn, &QPushButton::clicked, this, [this]() { - emit searchRequested(m_input->text()); - }); -} - -void SearchBar::clear() -{ - m_input->clear(); -} diff --git a/src/widgets/SearchBar.h b/src/widgets/SearchBar.h deleted file mode 100644 index 09a7235..0000000 --- a/src/widgets/SearchBar.h +++ /dev/null @@ -1,20 +0,0 @@ -#pragma once - -#include - -class QLineEdit; - -class SearchBar : public QWidget { - Q_OBJECT - -public: - explicit SearchBar(QWidget* parent = nullptr); - - void clear(); - -signals: - void searchRequested(const QString& query); - -private: - QLineEdit* m_input = nullptr; -}; From 205938088ca437c7c4abc802809ac7ee214a8b90 Mon Sep 17 00:00:00 2001 From: erhant Date: Thu, 18 Jun 2026 18:06:44 +0300 Subject: [PATCH 2/4] chore: add integration test --- README.md | 31 +++++++++++++++++++------------ tests/ui-tests.mjs | 28 ++++++++++++++++++++++++++++ 2 files changed, 47 insertions(+), 12 deletions(-) create mode 100644 tests/ui-tests.mjs diff --git a/README.md b/README.md index 66e7612..21626a0 100644 --- a/README.md +++ b/README.md @@ -1,11 +1,6 @@ # LEZ Explorer UI -A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a -`logos-module-builder` **`ui_qml`** module (universal authoring model). The QML -view runs process-isolated in a `ui-host`, backed by a small C++ backend that -reads from the [`lez_indexer_module`](https://github.com/logos-blockchain/logos-execution-zone-indexer-module) -**in-process over the Logos protocol** (Qt Remote Objects) — no WebSocket, no -external RPC socket. +A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a `logos-module-builder` **`ui_qml`** module (universal authoring model). The QML view runs process-isolated in a `ui-host`, backed by a small C++ backend that reads from the [`lez_indexer_module`](https://github.com/logos-blockchain/logos-execution-zone-indexer-module) **in-process over the Logos protocol** (Qt Remote Objects) — no WebSocket, no external RPC socket. ## Features @@ -28,8 +23,7 @@ external RPC socket. into QVariant for the view, and polls the chain tip for live updates. - `src/qml/` — the view (`Main.qml`, `pages/`, `components/`, `icons/`). -The `*Plugin` / `*Interface` glue is generated by the builder from the `.rep` + -`metadata.json` (`interface: "universal"`). +The `*Plugin` / `*Interface` glue is generated by the builder from the `.rep` + `metadata.json` (`interface: "universal"`). ## Building with Nix @@ -40,10 +34,23 @@ nix run . # preview in logos-standalone-app (spawns the ui-host) nix build .#lgx # bundle as a .lgx for logos-basecamp ``` -Running against live data needs the indexer reachable; enter its -`indexer_config.json` path in the explorer's config field (leave blank if the -indexer is already running). Load the explorer and indexer `.lgx` bundles -together in logos-basecamp for the full experience. +Running against live data needs the indexer reachable; enter its `indexer_config.json` path in the explorer's config field (leave blank if the indexer is already running). Load the explorer and indexer `.lgx` bundles together in logos-basecamp for the full experience. + +## Testing + +`tests/ui-tests.mjs` holds UI smoke tests (auto-detected by `mkLogosQmlModule`): they launch the view, wait for it to come up, and check the home page renders. They don't require a running indexer — with none ingesting, the backend stays in its "Connecting" empty state, which is expected. + +```sh +git add -A # nix only sees tracked files +nix build .#integration-test -L # build + run the tests (logs streamed with -L) +``` + +To iterate interactively against a local, un-pushed indexer branch, add the override used during development: + +```sh +nix build .#integration-test -L \ + --override-input lez_indexer_module path:../logos-execution-zone-indexer-module +``` ## License diff --git a/tests/ui-tests.mjs b/tests/ui-tests.mjs new file mode 100644 index 0000000..4147571 --- /dev/null +++ b/tests/ui-tests.mjs @@ -0,0 +1,28 @@ +import { resolve } from "node:path"; + +// CI sets LOGOS_QT_MCP automatically; for interactive use: +// nix build .#test-framework -o result-mcp +const root = process.env.LOGOS_QT_MCP || new URL("../result-mcp", import.meta.url).pathname; +const { test, run } = await import(resolve(root, "test-framework/framework.mjs")); + +// Smoke tests: the view loads and the home page renders. They do NOT assert a +// live indexer connection — with no indexer ingesting, the backend stays in +// "Connecting" (chain tip is 0), which is the correct empty state. They only +// verify the QML view comes up and the backend is wired (readiness overlay +// clears, revealing the navigation bar + home page). + +test("lez_explorer_ui: loads the view", async (app) => { + await app.waitFor( + async () => { await app.expectTexts(["LEZ Explorer"]); }, + { timeout: 30000, interval: 500, description: "the explorer view to load" } + ); +}); + +test("lez_explorer_ui: renders the home page", async (app) => { + await app.waitFor( + async () => { await app.expectTexts(["Recent Blocks", "Indexer config"]); }, + { timeout: 15000, interval: 500, description: "the home page to render" } + ); +}); + +run(); From e77bf6153c52203186d3ee6e078a04eab00d12a0 Mon Sep 17 00:00:00 2001 From: erhant Date: Thu, 18 Jun 2026 18:15:57 +0300 Subject: [PATCH 3/4] chore: update repo name for indexer module --- README.md | 16 +++++++++------- flake.lock | 4 ++-- flake.nix | 6 +----- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/README.md b/README.md index 21626a0..ce66e39 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # LEZ Explorer UI -A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a `logos-module-builder` **`ui_qml`** module (universal authoring model). The QML view runs process-isolated in a `ui-host`, backed by a small C++ backend that reads from the [`lez_indexer_module`](https://github.com/logos-blockchain/logos-execution-zone-indexer-module) **in-process over the Logos protocol** (Qt Remote Objects) — no WebSocket, no external RPC socket. +A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a `logos-module-builder` **`ui_qml`** module (universal authoring model). The QML view runs process-isolated in a `ui-host`, backed by a small C++ backend that reads from the [`lez_indexer_module`](https://github.com/logos-blockchain/lez-indexer-module) **in-process over the Logos protocol** (Qt Remote Objects) — no WebSocket, no external RPC socket. ## Features @@ -45,12 +45,14 @@ git add -A # nix only sees tracked files nix build .#integration-test -L # build + run the tests (logs streamed with -L) ``` -To iterate interactively against a local, un-pushed indexer branch, add the override used during development: - -```sh -nix build .#integration-test -L \ - --override-input lez_indexer_module path:../logos-execution-zone-indexer-module -``` +> [!TIP] +> +> To iterate interactively against a local, un-pushed indexer branch, add the override used during development: +> +> ```sh +> nix build .#integration-test -L \ +> --override-input lez_indexer_module path:../lez-indexer-module +> ``` ## License diff --git a/flake.lock b/flake.lock index c4375e9..214dca6 100644 --- a/flake.lock +++ b/flake.lock @@ -27,12 +27,12 @@ "rev": "7ba107e7f5b46ba871210f28a562863198b9b8a8", "revCount": 36, "type": "git", - "url": "https://github.com/logos-blockchain/logos-execution-zone-indexer-module" + "url": "https://github.com/logos-blockchain/lez-indexer-module" }, "original": { "ref": "erhant/migr-to-logos-module-builder", "type": "git", - "url": "https://github.com/logos-blockchain/logos-execution-zone-indexer-module" + "url": "https://github.com/logos-blockchain/lez-indexer-module" } }, "logos-blockchain-circuits": { diff --git a/flake.nix b/flake.nix index b6cf57b..e5359fe 100644 --- a/flake.nix +++ b/flake.nix @@ -5,14 +5,10 @@ logos-module-builder.url = "github:logos-co/logos-module-builder"; nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx"; - # The provider it reads from, over the Logos protocol. Input name MUST match - # the metadata.json "dependencies" entry. For local development against an - # un-pushed branch, override with a path: - # lez_indexer_module.url = "path:../logos-execution-zone-indexer-module"; # NOTE: git+https (not github:) because the branch name contains a slash — # the github: fetcher mis-routes slashed refs through the commits API (404). # TODO: repoint to the merge target once this branch lands / goes public. - lez_indexer_module.url = "git+https://github.com/logos-blockchain/logos-execution-zone-indexer-module?ref=erhant/migr-to-logos-module-builder"; + lez_indexer_module.url = "git+https://github.com/logos-blockchain/lez-indexer-module?ref=erhant/migr-to-logos-module-builder"; }; outputs = From ec2ba7791266881646d09f216593784474ee078d Mon Sep 17 00:00:00 2001 From: erhant Date: Thu, 18 Jun 2026 18:31:51 +0300 Subject: [PATCH 4/4] fix: existing ui bugs fixed + polished --- src/qml/Main.qml | 16 +++++++++ src/qml/components/CopyButton.qml | 55 +++++++++++++++++++++++++++++++ src/qml/components/InfoRow.qml | 7 ++++ src/qml/icons/check.svg | 3 ++ src/qml/icons/copy.svg | 4 +++ src/qml/pages/AccountPage.qml | 4 +-- src/qml/pages/BlockPage.qml | 9 +++-- src/qml/pages/TransactionPage.qml | 4 +-- 8 files changed, 96 insertions(+), 6 deletions(-) create mode 100644 src/qml/components/CopyButton.qml create mode 100644 src/qml/icons/check.svg create mode 100644 src/qml/icons/copy.svg diff --git a/src/qml/Main.qml b/src/qml/Main.qml index 1074b7c..11c5800 100644 --- a/src/qml/Main.qml +++ b/src/qml/Main.qml @@ -20,7 +20,23 @@ Rectangle { property int histIndex: -1 // --- Navigation API (pages call these via the injected `explorer`) --- + function sameDescriptor(a, b) { + if (!a || !b) + return false; + if (a.type !== b.type) + return false; + if (a.type === "home") + return true; + if (a.type === "search") + return a.query === b.query; + return a.param === b.param; + } function navigateTo(desc) { + // Navigating to the page we're already on is a no-op — don't grow the + // history (so e.g. Home-while-home doesn't wake the Back button). + var current = root.histIndex >= 0 ? root.history[root.histIndex] : null; + if (root.sameDescriptor(current, desc)) + return; var h = root.history.slice(0, root.histIndex + 1); h.push(desc); root.history = h; diff --git a/src/qml/components/CopyButton.qml b/src/qml/components/CopyButton.qml new file mode 100644 index 0000000..7ba6d66 --- /dev/null +++ b/src/qml/components/CopyButton.qml @@ -0,0 +1,55 @@ +import QtQuick +import Logos.Theme + +// Tiny copy-to-clipboard button. Self-contained: writes `value` to the system +// clipboard via a hidden TextEdit (which runs in the host GUI process, so it +// reaches the real desktop clipboard), then flips to a check mark briefly as +// confirmation. +Item { + id: root + + property string value: "" + property int size: 22 + implicitWidth: size + implicitHeight: size + + // Off-screen helper used purely to push text onto the clipboard. + TextEdit { id: clip; visible: false } + + Rectangle { + anchors.fill: parent + radius: Theme.spacing.radiusSmall + color: hover.hovered ? Theme.palette.backgroundSecondary : "transparent" + + HoverHandler { id: hover } + TapHandler { + onTapped: { + clip.text = root.value; + clip.selectAll(); + clip.copy(); + clip.text = ""; + icon.source = icon.checkUrl; + resetTimer.restart(); + } + } + + Image { + id: icon + anchors.centerIn: parent + property url copyUrl: Qt.resolvedUrl("../icons/copy.svg") + property url checkUrl: Qt.resolvedUrl("../icons/check.svg") + source: copyUrl + sourceSize.width: Math.round(root.size * 0.72) + sourceSize.height: Math.round(root.size * 0.72) + width: Math.round(root.size * 0.72) + height: Math.round(root.size * 0.72) + fillMode: Image.PreserveAspectFit + } + + Timer { + id: resetTimer + interval: 1200 + onTriggered: icon.source = icon.copyUrl + } + } +} diff --git a/src/qml/components/InfoRow.qml b/src/qml/components/InfoRow.qml index 04d4a9f..f6b83f4 100644 --- a/src/qml/components/InfoRow.qml +++ b/src/qml/components/InfoRow.qml @@ -12,6 +12,7 @@ RowLayout { property string label: "" property string value: "" property bool mono: false + property bool copyable: false property color valueColor: Theme.palette.text spacing: Theme.spacing.medium @@ -36,4 +37,10 @@ RowLayout { Layout.fillWidth: true Layout.alignment: Qt.AlignTop } + + CopyButton { + visible: root.copyable && root.value.length > 0 + value: root.value + Layout.alignment: Qt.AlignTop + } } diff --git a/src/qml/icons/check.svg b/src/qml/icons/check.svg new file mode 100644 index 0000000..41ec420 --- /dev/null +++ b/src/qml/icons/check.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/qml/icons/copy.svg b/src/qml/icons/copy.svg new file mode 100644 index 0000000..3f133fb --- /dev/null +++ b/src/qml/icons/copy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/qml/pages/AccountPage.qml b/src/qml/pages/AccountPage.qml index a597ce5..32a6ff6 100644 --- a/src/qml/pages/AccountPage.qml +++ b/src/qml/pages/AccountPage.qml @@ -49,13 +49,13 @@ Item { Layout.fillWidth: true visible: page.loaded && Object.keys(page.account).length > 1 - InfoRow { label: "Account ID"; value: page.account.accountId || ""; mono: true } + InfoRow { label: "Account ID"; value: page.account.accountId || ""; mono: true; copyable: true } InfoRow { label: "Balance" value: page.account.balance || "0" valueColor: Theme.palette.success } - InfoRow { label: "Program Owner"; value: page.account.programOwner || ""; mono: true } + InfoRow { label: "Program Owner"; value: page.account.programOwner || ""; mono: true; copyable: true } InfoRow { label: "Nonce"; value: page.account.nonce || "0" } InfoRow { label: "Data Size" diff --git a/src/qml/pages/BlockPage.qml b/src/qml/pages/BlockPage.qml index cf55d36..9c37d8d 100644 --- a/src/qml/pages/BlockPage.qml +++ b/src/qml/pages/BlockPage.qml @@ -49,7 +49,7 @@ Item { visible: page.loaded && Object.keys(page.block).length > 0 InfoRow { label: "Block ID"; value: String(page.block.blockId !== undefined ? page.block.blockId : "") } - InfoRow { label: "Hash"; value: page.block.hash || ""; mono: true } + InfoRow { label: "Hash"; value: page.block.hash || ""; mono: true; copyable: true } // Previous hash — a clickable link to the previous block when not genesis. RowLayout { @@ -68,6 +68,11 @@ Item { onTapped: page.explorer.navigateBlock(Number(page.blockId) - 1) } } + CopyButton { + visible: (page.block.prevBlockHash || "").length > 0 + value: page.block.prevBlockHash || "" + Layout.alignment: Qt.AlignTop + } } InfoRow { label: "Timestamp"; value: page.block.timestampText || "" } @@ -80,7 +85,7 @@ Item { Item { Layout.fillWidth: true } } - InfoRow { label: "Signature"; value: page.block.signature || ""; mono: true } + InfoRow { label: "Signature"; value: page.block.signature || ""; mono: true; copyable: true } InfoRow { label: "Transactions"; value: String(page.block.txCount !== undefined ? page.block.txCount : 0) } } diff --git a/src/qml/pages/TransactionPage.qml b/src/qml/pages/TransactionPage.qml index b540a6e..35747ba 100644 --- a/src/qml/pages/TransactionPage.qml +++ b/src/qml/pages/TransactionPage.qml @@ -48,7 +48,7 @@ Item { Layout.fillWidth: true visible: page.loaded && Object.keys(page.tx).length > 0 - InfoRow { label: "Hash"; value: page.tx.hash || ""; mono: true } + InfoRow { label: "Hash"; value: page.tx.hash || ""; mono: true; copyable: true } RowLayout { Layout.fillWidth: true spacing: Theme.spacing.medium @@ -60,7 +60,7 @@ Item { // Public. InfoRow { visible: page.txType === "Public" - label: "Program ID"; value: page.tx.programId || ""; mono: true + label: "Program ID"; value: page.tx.programId || ""; mono: true; copyable: true } InfoRow { visible: page.txType === "Public"