Merge pull request #4 from logos-blockchain/erhant/migr-to-module-builder
feat!: use logos protocol with indexer dependency
43
.github/pull_request_template.md
vendored
Normal file
@ -0,0 +1,43 @@
|
||||
## 🎯 Purpose
|
||||
|
||||
*What problem does this PR solve or what feature does it add? Mention issues related to it*
|
||||
|
||||
TO COMPLETE
|
||||
|
||||
## ⚙️ Approach
|
||||
|
||||
*Describe the core changes introduced by this PR.*
|
||||
|
||||
TO COMPLETE
|
||||
|
||||
- [ ] Change ...
|
||||
- [ ] Add ...
|
||||
- [ ] Fix ...
|
||||
- [ ] ...
|
||||
|
||||
## 🧪 How to Test
|
||||
|
||||
*How to verify that this PR works as intended.*
|
||||
|
||||
TO COMPLETE
|
||||
|
||||
## 🔗 Dependencies
|
||||
|
||||
*Link PRs that must be merged before this one.*
|
||||
|
||||
TO COMPLETE IF APPLICABLE
|
||||
|
||||
## 🔜 Future Work
|
||||
|
||||
*List any work intentionally left out of this PR, whether due to scope, prioritization, or pending decisions.*
|
||||
|
||||
TO COMPLETE IF APPLICABLE
|
||||
|
||||
## 📋 PR Completion Checklist
|
||||
|
||||
*Mark only completed items. A complete PR should have all boxes ticked.*
|
||||
|
||||
- [ ] Complete PR description
|
||||
- [ ] Implement the core functionality
|
||||
- [ ] Add/update tests
|
||||
- [ ] Add/update documentation and inline comments
|
||||
5
.gitignore
vendored
@ -2,4 +2,7 @@ result
|
||||
build
|
||||
|
||||
# ignore lgx builds
|
||||
lez-explorer-ui-*-lgx-*
|
||||
lez-explorer-ui-*-lgx-*
|
||||
|
||||
# ignore rocksdb (may appear here during indexer tests)
|
||||
rocksdb
|
||||
131
CMakeLists.txt
@ -1,116 +1,31 @@
|
||||
cmake_minimum_required(VERSION 3.16)
|
||||
project(LEZExplorerUIPlugin VERSION 1.0.0 LANGUAGES CXX)
|
||||
cmake_minimum_required(VERSION 3.14)
|
||||
project(LezExplorerUiPlugin LANGUAGES CXX)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 17)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
set(CMAKE_AUTOMOC ON)
|
||||
set(CMAKE_AUTORCC ON)
|
||||
set(CMAKE_AUTOUIC ON)
|
||||
|
||||
# Find Qt packages
|
||||
find_package(Qt6 REQUIRED COMPONENTS Core Widgets Svg SvgWidgets Network WebSockets)
|
||||
|
||||
# Try to find the component-interfaces package first
|
||||
find_package(component-interfaces QUIET)
|
||||
|
||||
# If not found, use the local interfaces folder
|
||||
if(NOT component-interfaces_FOUND)
|
||||
include_directories(${CMAKE_CURRENT_SOURCE_DIR}/interfaces)
|
||||
add_library(component-interfaces INTERFACE)
|
||||
target_include_directories(component-interfaces INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}/interfaces)
|
||||
endif()
|
||||
|
||||
# Source files
|
||||
set(SOURCES
|
||||
src/ExplorerPlugin.cpp
|
||||
src/ExplorerPlugin.h
|
||||
src/ExplorerWidget.cpp
|
||||
src/ExplorerWidget.h
|
||||
src/Style.h
|
||||
src/services/MockIndexerService.cpp
|
||||
src/services/MockIndexerService.h
|
||||
src/services/RpcIndexerService.cpp
|
||||
src/services/RpcIndexerService.h
|
||||
src/services/IndexerService.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
|
||||
)
|
||||
|
||||
# Create the plugin library
|
||||
add_library(lez_explorer_ui SHARED ${SOURCES})
|
||||
|
||||
# Set output name without lib prefix
|
||||
set_target_properties(lez_explorer_ui PROPERTIES
|
||||
PREFIX ""
|
||||
OUTPUT_NAME "lez_explorer_ui"
|
||||
)
|
||||
|
||||
# Include directories
|
||||
target_include_directories(lez_explorer_ui PRIVATE
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
${CMAKE_CURRENT_SOURCE_DIR}/src
|
||||
${CMAKE_CURRENT_BINARY_DIR}
|
||||
)
|
||||
|
||||
# Link against libraries
|
||||
target_link_libraries(lez_explorer_ui PRIVATE
|
||||
Qt6::Core
|
||||
Qt6::Widgets
|
||||
Qt6::Svg
|
||||
Qt6::SvgWidgets
|
||||
Qt6::Network
|
||||
Qt6::WebSockets
|
||||
component-interfaces
|
||||
)
|
||||
|
||||
# Set common properties for both platforms
|
||||
set_target_properties(lez_explorer_ui PROPERTIES
|
||||
LIBRARY_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/modules"
|
||||
RUNTIME_OUTPUT_DIRECTORY "${CMAKE_BINARY_DIR}/modules"
|
||||
BUILD_WITH_INSTALL_RPATH TRUE
|
||||
SKIP_BUILD_RPATH FALSE
|
||||
)
|
||||
|
||||
if(APPLE)
|
||||
set_target_properties(lez_explorer_ui PROPERTIES
|
||||
INSTALL_RPATH "@loader_path"
|
||||
INSTALL_NAME_DIR "@rpath"
|
||||
BUILD_WITH_INSTALL_NAME_DIR TRUE
|
||||
)
|
||||
add_custom_command(TARGET lez_explorer_ui POST_BUILD
|
||||
COMMAND install_name_tool -id "@rpath/lez_explorer_ui.dylib" $<TARGET_FILE:lez_explorer_ui>
|
||||
COMMENT "Updating library paths for macOS"
|
||||
)
|
||||
# Logos Module CMake helper. For nix builds this is provided via
|
||||
# LOGOS_MODULE_BUILDER_ROOT.
|
||||
if(DEFINED ENV{LOGOS_MODULE_BUILDER_ROOT})
|
||||
include($ENV{LOGOS_MODULE_BUILDER_ROOT}/cmake/LogosModule.cmake)
|
||||
elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/cmake/LogosModule.cmake")
|
||||
include(cmake/LogosModule.cmake)
|
||||
else()
|
||||
set_target_properties(lez_explorer_ui PROPERTIES
|
||||
INSTALL_RPATH "$ORIGIN"
|
||||
INSTALL_RPATH_USE_LINK_PATH FALSE
|
||||
)
|
||||
message(FATAL_ERROR "LogosModule.cmake not found. Set LOGOS_MODULE_BUILDER_ROOT.")
|
||||
endif()
|
||||
|
||||
install(TARGETS lez_explorer_ui
|
||||
LIBRARY DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules
|
||||
ARCHIVE DESTINATION ${CMAKE_INSTALL_LIBDIR}/logos/modules
|
||||
# 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/lez_explorer_ui_backend.h
|
||||
src/lez_explorer_ui_backend.cpp
|
||||
INCLUDE_DIRS
|
||||
src
|
||||
)
|
||||
|
||||
install(FILES ${CMAKE_CURRENT_SOURCE_DIR}/metadata.json
|
||||
DESTINATION ${CMAKE_INSTALL_DATADIR}/lez-explorer-ui
|
||||
)
|
||||
|
||||
message(STATUS "LEZ Explorer UI Plugin configured successfully")
|
||||
|
||||
60
README.md
@ -1,39 +1,59 @@
|
||||
# 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/lez-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
|
||||
- Copy buttons on hashes / ids; in-app **Settings** JSON editor to configure & (re)start the indexer (no file paths; persists across launches)
|
||||
- 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)
|
||||
Running against live data: open **Settings** (gear icon, top-right), edit the indexer config JSON (pre-filled with a working template) and press **Save & Start** — the explorer persists the config and (re)starts the indexer from it, so no file paths are involved, and it auto-restarts from the saved config on the next launch. 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
|
||||
nix build '.#lib'
|
||||
git add -A # nix only sees tracked files
|
||||
nix build .#integration-test -L # build + run the tests (logs streamed with -L)
|
||||
```
|
||||
|
||||
### Development shell
|
||||
|
||||
```sh
|
||||
nix develop
|
||||
```
|
||||
> [!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
|
||||
|
||||
|
||||
@ -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
|
||||
)
|
||||
13
app/main.cpp
@ -1,13 +0,0 @@
|
||||
#include "mainwindow.h"
|
||||
|
||||
#include <QApplication>
|
||||
|
||||
int main(int argc, char* argv[])
|
||||
{
|
||||
QApplication app(argc, argv);
|
||||
|
||||
MainWindow window;
|
||||
window.show();
|
||||
|
||||
return app.exec();
|
||||
}
|
||||
@ -1,60 +0,0 @@
|
||||
#include "mainwindow.h"
|
||||
|
||||
#include <QtWidgets>
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1,14 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMainWindow>
|
||||
|
||||
class MainWindow : public QMainWindow {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MainWindow(QWidget* parent = nullptr);
|
||||
~MainWindow() override;
|
||||
|
||||
private:
|
||||
void setupUi();
|
||||
};
|
||||
19717
flake.lock
generated
68
flake.nix
@ -1,61 +1,21 @@
|
||||
{
|
||||
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 = {
|
||||
# Pinned to the EXACT nixpkgs revision basecamp resolves via logos-nix, so this
|
||||
# in-process `type: ui` module links the identical qtbase (6.9.2) and does not
|
||||
# load a second QtCore into basecamp's process (which aborts with "Must construct
|
||||
# a QApplication before a QWidget"). Matching the version string is not enough —
|
||||
# the store path must match, so this must be re-pinned whenever basecamp bumps its
|
||||
# logos-nix/nixpkgs lock.
|
||||
nixpkgs.url = "github:NixOS/nixpkgs/e9f00bd893984bc8ce46c895c3bf7cac95331127";
|
||||
logos-module-builder.url = "github:logos-co/logos-module-builder";
|
||||
nix-bundle-lgx.url = "github:logos-co/nix-bundle-lgx";
|
||||
|
||||
# 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/lez-indexer-module?ref=erhant/migr-to-logos-module-builder";
|
||||
};
|
||||
|
||||
outputs = { self, nixpkgs }:
|
||||
let
|
||||
systems = [ "aarch64-darwin" "x86_64-darwin" "aarch64-linux" "x86_64-linux" ];
|
||||
forAllSystems = f: nixpkgs.lib.genAttrs systems (system: f {
|
||||
pkgs = import nixpkgs { inherit system; };
|
||||
});
|
||||
in
|
||||
{
|
||||
packages = forAllSystems ({ pkgs }:
|
||||
let
|
||||
common = import ./nix/default.nix { inherit pkgs; };
|
||||
src = ./.;
|
||||
|
||||
lib = import ./nix/lib.nix { inherit pkgs common src; };
|
||||
|
||||
app = import ./nix/app.nix {
|
||||
inherit pkgs common src;
|
||||
lezExplorerUI = lib;
|
||||
};
|
||||
in
|
||||
{
|
||||
lez-explorer-ui-lib = lib;
|
||||
app = app;
|
||||
lib = lib;
|
||||
default = lib;
|
||||
}
|
||||
);
|
||||
|
||||
devShells = forAllSystems ({ pkgs }: {
|
||||
default = pkgs.mkShell {
|
||||
nativeBuildInputs = [
|
||||
pkgs.cmake
|
||||
pkgs.ninja
|
||||
pkgs.pkg-config
|
||||
];
|
||||
buildInputs = [
|
||||
pkgs.qt6.qtbase
|
||||
pkgs.qt6.qtsvg
|
||||
pkgs.qt6.qtwebsockets
|
||||
];
|
||||
|
||||
shellHook = ''
|
||||
echo "LEZ Explorer UI development environment"
|
||||
'';
|
||||
};
|
||||
});
|
||||
outputs =
|
||||
inputs@{ logos-module-builder, ... }:
|
||||
logos-module-builder.lib.mkLogosQmlModule {
|
||||
src = ./.;
|
||||
configFile = ./metadata.json;
|
||||
flakeInputs = inputs;
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,17 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QObject>
|
||||
#include <QWidget>
|
||||
#include <QtPlugin>
|
||||
|
||||
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)
|
||||
@ -1,10 +1,26 @@
|
||||
{
|
||||
"name": "lez_explorer_ui",
|
||||
"version": "1.0.0",
|
||||
"description": "LEZ Explorer UI - A block explorer for the Logos Execution Zone",
|
||||
"type": "ui",
|
||||
"main": "lez_explorer_ui",
|
||||
"dependencies": [],
|
||||
"type": "ui_qml",
|
||||
"interface": "universal",
|
||||
"category": "explorer",
|
||||
"capabilities": ["ui_components"]
|
||||
"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": [],
|
||||
"runtime": ["qt6.qtdeclarative", "zstd", "krb5", "abseil-cpp"]
|
||||
},
|
||||
"external_libraries": [],
|
||||
"cmake": {
|
||||
"find_packages": [],
|
||||
"extra_sources": [],
|
||||
"extra_include_dirs": [],
|
||||
"extra_link_libraries": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
65
nix/app.nix
@ -1,65 +0,0 @@
|
||||
# Builds the lez-explorer-ui-app standalone application
|
||||
{ pkgs, common, src, lezExplorerUI }:
|
||||
|
||||
pkgs.stdenv.mkDerivation rec {
|
||||
pname = "lez-explorer-ui-app";
|
||||
version = common.version;
|
||||
|
||||
inherit src;
|
||||
inherit (common) buildInputs meta;
|
||||
|
||||
nativeBuildInputs = common.nativeBuildInputs ++ pkgs.lib.optionals pkgs.stdenv.isLinux [ pkgs.patchelf ];
|
||||
|
||||
# This is a GUI application, enable Qt wrapping
|
||||
dontWrapQtApps = false;
|
||||
|
||||
qtLibPath = pkgs.lib.makeLibraryPath ([
|
||||
pkgs.qt6.qtbase
|
||||
pkgs.qt6.qtwebsockets
|
||||
] ++ pkgs.lib.optionals pkgs.stdenv.isLinux [
|
||||
pkgs.libglvnd
|
||||
]);
|
||||
|
||||
qtPluginPath = "${pkgs.qt6.qtbase}/lib/qt-6/plugins:${pkgs.qt6.qtsvg}/lib/qt-6/plugins";
|
||||
|
||||
qtWrapperArgs = [
|
||||
"--prefix" "LD_LIBRARY_PATH" ":" qtLibPath
|
||||
"--prefix" "QT_PLUGIN_PATH" ":" qtPluginPath
|
||||
];
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
cmake -S app -B build \
|
||||
-GNinja \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
runHook postConfigure
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
cmake --build build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/bin $out/lib
|
||||
|
||||
# Install the app binary
|
||||
cp build/bin/lez-explorer-ui-app $out/bin/
|
||||
|
||||
# Determine platform-specific plugin extension
|
||||
OS_EXT="so"
|
||||
case "$(uname -s)" in
|
||||
Darwin) OS_EXT="dylib";;
|
||||
esac
|
||||
|
||||
# Copy the explorer UI plugin to root directory (loaded relative to binary)
|
||||
if [ -f "${lezExplorerUI}/lib/lez_explorer_ui.$OS_EXT" ]; then
|
||||
cp -L "${lezExplorerUI}/lib/lez_explorer_ui.$OS_EXT" "$out/"
|
||||
fi
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
@ -1,29 +0,0 @@
|
||||
# Common build configuration shared across all packages
|
||||
{ pkgs }:
|
||||
|
||||
{
|
||||
pname = "lez-explorer-ui";
|
||||
version = "1.0.0";
|
||||
|
||||
nativeBuildInputs = [
|
||||
pkgs.cmake
|
||||
pkgs.ninja
|
||||
pkgs.pkg-config
|
||||
pkgs.qt6.wrapQtAppsHook
|
||||
];
|
||||
|
||||
buildInputs = [
|
||||
pkgs.qt6.qtbase
|
||||
pkgs.qt6.qtsvg
|
||||
pkgs.qt6.qtwebsockets
|
||||
];
|
||||
|
||||
cmakeFlags = [
|
||||
"-GNinja"
|
||||
];
|
||||
|
||||
meta = with pkgs.lib; {
|
||||
description = "LEZ Explorer UI - A Qt block explorer for the Logos Execution Zone";
|
||||
platforms = platforms.unix;
|
||||
};
|
||||
}
|
||||
45
nix/lib.nix
@ -1,45 +0,0 @@
|
||||
# Builds the lez-explorer-ui library (Qt plugin)
|
||||
{ pkgs, common, src }:
|
||||
|
||||
pkgs.stdenv.mkDerivation {
|
||||
pname = "${common.pname}-lib";
|
||||
version = common.version;
|
||||
|
||||
inherit src;
|
||||
inherit (common) buildInputs cmakeFlags meta;
|
||||
nativeBuildInputs = common.nativeBuildInputs;
|
||||
|
||||
# Library (Qt plugin), not an app — no Qt wrapper
|
||||
dontWrapQtApps = true;
|
||||
|
||||
configurePhase = ''
|
||||
runHook preConfigure
|
||||
cmake -S . -B build \
|
||||
-GNinja \
|
||||
-DCMAKE_BUILD_TYPE=Release
|
||||
runHook postConfigure
|
||||
'';
|
||||
|
||||
buildPhase = ''
|
||||
runHook preBuild
|
||||
cmake --build build
|
||||
runHook postBuild
|
||||
'';
|
||||
|
||||
installPhase = ''
|
||||
runHook preInstall
|
||||
|
||||
mkdir -p $out/lib
|
||||
if [ -f build/modules/lez_explorer_ui.dylib ]; then
|
||||
cp build/modules/lez_explorer_ui.dylib $out/lib/
|
||||
elif [ -f build/modules/lez_explorer_ui.so ]; then
|
||||
cp build/modules/lez_explorer_ui.so $out/lib/
|
||||
else
|
||||
echo "Error: No library file found in build/modules/"
|
||||
ls -la build/modules/ 2>/dev/null || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
runHook postInstall
|
||||
'';
|
||||
}
|
||||
@ -1,17 +0,0 @@
|
||||
#include "ExplorerPlugin.h"
|
||||
#include "ExplorerWidget.h"
|
||||
|
||||
ExplorerPlugin::ExplorerPlugin(QObject* parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
QWidget* ExplorerPlugin::createWidget(LogosAPI* /*logosAPI*/)
|
||||
{
|
||||
return new ExplorerWidget();
|
||||
}
|
||||
|
||||
void ExplorerPlugin::destroyWidget(QWidget* widget)
|
||||
{
|
||||
delete widget;
|
||||
}
|
||||
@ -1,18 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "IComponent.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QtPlugin>
|
||||
|
||||
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;
|
||||
};
|
||||
@ -1,213 +0,0 @@
|
||||
#include "ExplorerWidget.h"
|
||||
#include "Style.h"
|
||||
#include "services/RpcIndexerService.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 <QVBoxLayout>
|
||||
#include <QStackedWidget>
|
||||
#include <QLabel>
|
||||
|
||||
ExplorerWidget::ExplorerWidget(QWidget* parent)
|
||||
: QWidget(parent)
|
||||
, m_indexer(std::make_unique<RpcIndexerService>())
|
||||
{
|
||||
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<MainPage*>(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<BlockPage*>(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<TransactionPage*>(page)) {
|
||||
connect(txPage, &TransactionPage::accountClicked, this, [this](const QString& id) {
|
||||
navigateTo(NavAccount{id});
|
||||
});
|
||||
} else if (auto* accPage = qobject_cast<AccountPage*>(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<decltype(t)>;
|
||||
|
||||
if constexpr (std::is_same_v<T, NavHome>) {
|
||||
m_mainPage->clearSearchResults();
|
||||
m_mainPage->refresh();
|
||||
showPage(m_mainPage);
|
||||
} else if constexpr (std::is_same_v<T, NavBlock>) {
|
||||
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<unsigned long long>(t.blockId));
|
||||
showPage(m_mainPage);
|
||||
}
|
||||
} else if constexpr (std::is_same_v<T, NavTransaction>) {
|
||||
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<T, NavAccount>) {
|
||||
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());
|
||||
}
|
||||
@ -1,49 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "services/IndexerService.h"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QStack>
|
||||
#include <memory>
|
||||
#include <variant>
|
||||
|
||||
class QStackedWidget;
|
||||
class NavigationBar;
|
||||
class SearchBar;
|
||||
class MainPage;
|
||||
|
||||
struct NavHome {};
|
||||
struct NavBlock { quint64 blockId; };
|
||||
struct NavTransaction { QString hash; };
|
||||
struct NavAccount { QString accountId; };
|
||||
|
||||
using NavTarget = std::variant<NavHome, NavBlock, NavTransaction, NavAccount>;
|
||||
|
||||
class ExplorerWidget : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ExplorerWidget(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<IndexerService> m_indexer;
|
||||
NavigationBar* m_navBar = nullptr;
|
||||
SearchBar* m_searchBar = nullptr;
|
||||
QStackedWidget* m_stack = nullptr;
|
||||
MainPage* m_mainPage = nullptr;
|
||||
|
||||
QStack<NavTarget> m_backHistory;
|
||||
QStack<NavTarget> m_forwardHistory;
|
||||
NavTarget m_currentTarget;
|
||||
};
|
||||
142
src/Style.h
@ -1,142 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
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
|
||||
@ -1,12 +0,0 @@
|
||||
<RCC>
|
||||
<qresource prefix="/">
|
||||
<file>icons/arrow-left.svg</file>
|
||||
<file>icons/arrow-right.svg</file>
|
||||
<file>icons/home.svg</file>
|
||||
<file>icons/search.svg</file>
|
||||
<file>icons/box.svg</file>
|
||||
<file>icons/file-text.svg</file>
|
||||
<file>icons/user.svg</file>
|
||||
<file>icons/activity.svg</file>
|
||||
</qresource>
|
||||
</RCC>
|
||||
48
src/lez_explorer_ui.rep
Normal file
@ -0,0 +1,48 @@
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
|
||||
// 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)
|
||||
// "Connecting" / "Connected" / "Error" — drives the health indicator.
|
||||
PROP(QString connectionStatus READONLY)
|
||||
// The indexer config JSON currently in effect ("" until one is saved). The
|
||||
// Settings editor seeds from this (falling back to defaultConfig).
|
||||
PROP(QString configText READONLY)
|
||||
// Built-in starter config template (seeded by the backend) for the Settings
|
||||
// editor's "Reset to default".
|
||||
PROP(QString defaultConfig READONLY)
|
||||
|
||||
// Persist `json` as the indexer config and (re)start ingestion from it, then
|
||||
// refresh the feed. `port` is the indexer RPC port (blank keeps the current
|
||||
// one). The backend writes the JSON to a file itself — the UI never deals
|
||||
// with paths. Returns true on success.
|
||||
SLOT(bool applyConfigJson(QString json, 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))
|
||||
}
|
||||
548
src/lez_explorer_ui_backend.cpp
Normal file
@ -0,0 +1,548 @@
|
||||
#include "lez_explorer_ui_backend.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <utility>
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QRegularExpression>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimeZone>
|
||||
#include <QTimer>
|
||||
|
||||
// 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;
|
||||
|
||||
// Starter config shown in the Settings editor (and its "Reset to default").
|
||||
// A working local-dev indexer config; the user edits the fields (bedrock addr,
|
||||
// channel id, initial accounts/commitments, signing key, ...) and saves.
|
||||
const char* const kDefaultConfig = R"JSON({
|
||||
"home": ".",
|
||||
"consensus_info_polling_interval": "1s",
|
||||
"bedrock_config": {
|
||||
"addr": "http://localhost:8080",
|
||||
"backoff": {
|
||||
"start_delay": "100ms",
|
||||
"max_retries": 5
|
||||
}
|
||||
},
|
||||
"channel_id": "0101010101010101010101010101010101010101010101010101010101010101",
|
||||
"initial_accounts": [
|
||||
{ "account_id": "CbgR6tj5kWx5oziiFptM7jMvrQeYY3Mzaao6ciuhSr2r", "balance": 10000 },
|
||||
{ "account_id": "2RHZhw9h534Zr3eq2RGhQete2Hh667foECzXPmSkGni2", "balance": 20000 }
|
||||
],
|
||||
"initial_commitments": [],
|
||||
"signing_key": [
|
||||
37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37,
|
||||
37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37, 37
|
||||
]
|
||||
})JSON";
|
||||
|
||||
// 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<quint64>(number) : 0;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
QString jsonStr(const QJsonValue& value)
|
||||
{
|
||||
if (value.isString()) {
|
||||
return value.toString();
|
||||
}
|
||||
if (value.isDouble()) {
|
||||
return QString::number(static_cast<qulonglong>(value.toDouble()));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString timestampText(quint64 timestamp)
|
||||
{
|
||||
QDateTime dt;
|
||||
// Tolerate either seconds or milliseconds since epoch.
|
||||
if (timestamp > 1000000000000ULL) {
|
||||
dt = QDateTime::fromMSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
|
||||
} else {
|
||||
dt = QDateTime::fromSecsSinceEpoch(static_cast<qint64>(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<qlonglong>(jsonU64(obj.value(QStringLiteral("validity_window_start")))));
|
||||
tx.insert(QStringLiteral("validityWindowEnd"),
|
||||
static_cast<qlonglong>(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<qlonglong>(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()
|
||||
{
|
||||
setDefaultConfig(QString::fromUtf8(kDefaultConfig));
|
||||
}
|
||||
|
||||
LezExplorerUiBackend::~LezExplorerUiBackend() = default;
|
||||
|
||||
void LezExplorerUiBackend::onContextReady()
|
||||
{
|
||||
setConnectionStatus(QStringLiteral("Connecting"));
|
||||
|
||||
// Reload + auto-start the previously-saved config, if any (set-once UX).
|
||||
const QString saved = readConfigFile();
|
||||
if (!saved.isEmpty()) {
|
||||
setConfigText(saved);
|
||||
startIndexerFromFile();
|
||||
}
|
||||
|
||||
// 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::applyConfigJson(QString json, QString port)
|
||||
{
|
||||
const QString trimmedPort = port.trimmed();
|
||||
if (!trimmedPort.isEmpty()) {
|
||||
m_port = trimmedPort;
|
||||
}
|
||||
|
||||
const QString trimmed = json.trimmed();
|
||||
if (trimmed.isEmpty()) {
|
||||
emit error(QStringLiteral("Config is empty."));
|
||||
return false;
|
||||
}
|
||||
// Reject invalid JSON up front so we never start the indexer from garbage.
|
||||
QJsonParseError parseError;
|
||||
QJsonDocument::fromJson(trimmed.toUtf8(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError) {
|
||||
emit error(QStringLiteral("Invalid JSON: %1").arg(parseError.errorString()));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!writeConfigFile(json)) {
|
||||
emit error(QStringLiteral("Could not write config to %1").arg(configFilePath()));
|
||||
return false;
|
||||
}
|
||||
setConfigText(json);
|
||||
|
||||
const bool ok = startIndexerFromFile();
|
||||
|
||||
// Re-baseline the poller; a (re)started indexer may ingest afresh.
|
||||
m_lastPolledTip = 0;
|
||||
m_polledOnce = false;
|
||||
refreshBlocks();
|
||||
return ok;
|
||||
}
|
||||
|
||||
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<qlonglong>(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;
|
||||
}
|
||||
|
||||
namespace {
|
||||
// The writable path where the edited config JSON is persisted and the indexer
|
||||
// is started from (so the UI never deals with paths). Same logic, shared by the
|
||||
// const and non-const accessors.
|
||||
QString resolveConfigFilePath()
|
||||
{
|
||||
QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
if (dir.isEmpty()) {
|
||||
dir = QDir::tempPath();
|
||||
}
|
||||
return QDir(dir).filePath(QStringLiteral("indexer_config.json"));
|
||||
}
|
||||
} // namespace
|
||||
|
||||
QString LezExplorerUiBackend::configFilePath()
|
||||
{
|
||||
if (m_configFilePath.isEmpty()) {
|
||||
m_configFilePath = resolveConfigFilePath();
|
||||
}
|
||||
return m_configFilePath;
|
||||
}
|
||||
|
||||
bool LezExplorerUiBackend::writeConfigFile(const QString& json)
|
||||
{
|
||||
const QString path = configFilePath();
|
||||
const QDir dir = QFileInfo(path).dir();
|
||||
if (!dir.exists() && !dir.mkpath(QStringLiteral("."))) {
|
||||
return false;
|
||||
}
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
return false;
|
||||
}
|
||||
const QByteArray bytes = json.toUtf8();
|
||||
const bool ok = file.write(bytes) == bytes.size();
|
||||
file.close();
|
||||
return ok;
|
||||
}
|
||||
|
||||
QString LezExplorerUiBackend::readConfigFile() const
|
||||
{
|
||||
QFile file(m_configFilePath.isEmpty() ? resolveConfigFilePath() : m_configFilePath);
|
||||
if (!file.exists() || !file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return {};
|
||||
}
|
||||
const QString content = QString::fromUtf8(file.readAll());
|
||||
file.close();
|
||||
return content;
|
||||
}
|
||||
|
||||
bool LezExplorerUiBackend::startIndexerFromFile()
|
||||
{
|
||||
const qlonglong code = modules().lez_indexer_module.start_indexer(configFilePath(), m_port);
|
||||
if (code != 0) {
|
||||
emit error(QStringLiteral("start_indexer failed (code %1)").arg(code));
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
76
src/lez_explorer_ui_backend.h
Normal file
@ -0,0 +1,76 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVariantList>
|
||||
#include <QVariantMap>
|
||||
|
||||
#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).
|
||||
*
|
||||
* Config is edited in-app (Settings page) as JSON, not as a path: the indexer's
|
||||
* `start_indexer` takes a file path, so this class persists the edited JSON to a
|
||||
* writable file and starts the indexer from there. The last-saved config is
|
||||
* reloaded and auto-started on the next launch.
|
||||
*/
|
||||
class LezExplorerUiBackend : public LezExplorerUiSimpleSource, public LogosUiPluginContext {
|
||||
public:
|
||||
LezExplorerUiBackend();
|
||||
~LezExplorerUiBackend() override;
|
||||
|
||||
// Fires when ui-host wires the plugin's LogosAPI: modules() is live, so we
|
||||
// load any saved config, start it, and start the poll here.
|
||||
void onContextReady() override;
|
||||
|
||||
// .rep SLOTs.
|
||||
bool applyConfigJson(QString json, 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;
|
||||
|
||||
// Writable path where the edited config JSON is persisted, and where the
|
||||
// indexer is started from. Computed once (lazily).
|
||||
QString configFilePath();
|
||||
bool writeConfigFile(const QString& json);
|
||||
QString readConfigFile() const;
|
||||
// start_indexer from configFilePath(); returns true on success.
|
||||
bool startIndexerFromFile();
|
||||
|
||||
QString m_port = QStringLiteral("8779");
|
||||
QString m_configFilePath;
|
||||
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;
|
||||
};
|
||||
@ -1,11 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
|
||||
struct Account {
|
||||
QString accountId;
|
||||
QString programOwner;
|
||||
QString balance;
|
||||
QString nonce;
|
||||
int dataSizeBytes = 0;
|
||||
};
|
||||
@ -1,34 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
#include <QDateTime>
|
||||
|
||||
#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<Transaction> transactions;
|
||||
BedrockStatus bedrockStatus = BedrockStatus::Pending;
|
||||
QString bedrockParentId;
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QString>
|
||||
#include <QVector>
|
||||
|
||||
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<AccountRef> accounts;
|
||||
QVector<quint32> 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;
|
||||
};
|
||||
@ -1,160 +0,0 @@
|
||||
#include "AccountPage.h"
|
||||
#include "Style.h"
|
||||
#include "widgets/ClickableFrame.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QIcon>
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "models/Account.h"
|
||||
#include "services/IndexerService.h"
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class AccountPage : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AccountPage(const Account& account, IndexerService* indexer, QWidget* parent = nullptr);
|
||||
|
||||
signals:
|
||||
void transactionClicked(const QString& hash);
|
||||
};
|
||||
@ -1,218 +0,0 @@
|
||||
#include "BlockPage.h"
|
||||
#include "Style.h"
|
||||
#include "widgets/ClickableFrame.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QIcon>
|
||||
#include <QEvent>
|
||||
#include <QMouseEvent>
|
||||
|
||||
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<QMouseEvent*>(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);
|
||||
}
|
||||
@ -1,16 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "models/Block.h"
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
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);
|
||||
};
|
||||
@ -1,456 +0,0 @@
|
||||
#include "MainPage.h"
|
||||
#include "Style.h"
|
||||
#include "widgets/ClickableFrame.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QFrame>
|
||||
#include <QPixmap>
|
||||
#include <QPushButton>
|
||||
#include <QLineEdit>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
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 RPC", this);
|
||||
endpointLabel->setStyleSheet(Style::mutedText() + " font-weight: bold;");
|
||||
|
||||
m_indexerEndpointInput = new QLineEdit(this);
|
||||
m_indexerEndpointInput->setText(m_indexer->endpoint());
|
||||
m_indexerEndpointInput->setPlaceholderText(IndexerService::defaultEndpoint());
|
||||
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<qulonglong>(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<Block> 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()
|
||||
{
|
||||
QString endpoint = m_indexerEndpointInput->text().trimmed();
|
||||
if (endpoint.isEmpty()) {
|
||||
endpoint = IndexerService::defaultEndpoint();
|
||||
m_indexerEndpointInput->setText(endpoint);
|
||||
}
|
||||
|
||||
m_indexer->setEndpoint(endpoint);
|
||||
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();
|
||||
}
|
||||
}
|
||||
@ -1,52 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "services/IndexerService.h"
|
||||
|
||||
#include <QWidget>
|
||||
#include <QSet>
|
||||
#include <optional>
|
||||
|
||||
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<Block> 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<quint64> m_newestLoadedBlockId;
|
||||
std::optional<quint64> m_oldestLoadedBlockId;
|
||||
QSet<quint64> m_displayedBlockIds;
|
||||
quint64 m_latestKnownBlockId = 0;
|
||||
};
|
||||
@ -1,185 +0,0 @@
|
||||
#include "TransactionPage.h"
|
||||
#include "Style.h"
|
||||
#include "widgets/ClickableFrame.h"
|
||||
|
||||
#include <QVBoxLayout>
|
||||
#include <QHBoxLayout>
|
||||
#include <QLabel>
|
||||
#include <QScrollArea>
|
||||
#include <QFrame>
|
||||
#include <QGridLayout>
|
||||
#include <QIcon>
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1,15 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "models/Transaction.h"
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
class TransactionPage : public QWidget {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit TransactionPage(const Transaction& tx, QWidget* parent = nullptr);
|
||||
|
||||
signals:
|
||||
void accountClicked(const QString& accountId);
|
||||
};
|
||||
165
src/qml/Main.qml
Normal file
@ -0,0 +1,165 @@
|
||||
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 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;
|
||||
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 navigateSettings() { root.navigateTo({ type: "settings" }); }
|
||||
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 || "";
|
||||
} else if (d.type === "settings") {
|
||||
url = "pages/SettingsPage.qml";
|
||||
}
|
||||
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()
|
||||
onSettingsClicked: root.navigateSettings()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
src/qml/components/AccountRow.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
27
src/qml/components/Badge.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
59
src/qml/components/BlockRow.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
30
src/qml/components/Card.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
55
src/qml/components/CopyButton.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/qml/components/FieldLabel.qml
Normal file
@ -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
|
||||
}
|
||||
33
src/qml/components/IconButton.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
46
src/qml/components/InfoRow.qml
Normal file
@ -0,0 +1,46 @@
|
||||
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 bool copyable: 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
|
||||
}
|
||||
|
||||
CopyButton {
|
||||
visible: root.copyable && root.value.length > 0
|
||||
value: root.value
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
}
|
||||
12
src/qml/components/MonoText.qml
Normal file
@ -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
|
||||
}
|
||||
49
src/qml/components/NavigationBar.qml
Normal file
@ -0,0 +1,49 @@
|
||||
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()
|
||||
signal settingsClicked()
|
||||
|
||||
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 }
|
||||
|
||||
IconButton {
|
||||
source: Qt.resolvedUrl("../icons/settings.svg")
|
||||
onClicked: root.settingsClicked()
|
||||
}
|
||||
}
|
||||
70
src/qml/components/SearchBar.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
11
src/qml/components/SectionHeader.qml
Normal file
@ -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
|
||||
}
|
||||
11
src/qml/components/StatusBadge.qml
Normal file
@ -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
|
||||
}
|
||||
43
src/qml/components/TxRow.qml
Normal file
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/qml/components/TxTypeBadge.qml
Normal file
@ -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
|
||||
}
|
||||
|
Before Width: | Height: | Size: 216 B After Width: | Height: | Size: 216 B |
|
Before Width: | Height: | Size: 241 B After Width: | Height: | Size: 241 B |
|
Before Width: | Height: | Size: 242 B After Width: | Height: | Size: 242 B |
|
Before Width: | Height: | Size: 395 B After Width: | Height: | Size: 395 B |
3
src/qml/icons/check.svg
Normal file
@ -0,0 +1,3 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a6e3a1" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="20 6 9 17 4 12"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 201 B |
4
src/qml/icons/copy.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 290 B |
|
Before Width: | Height: | Size: 390 B After Width: | Height: | Size: 390 B |
|
Before Width: | Height: | Size: 267 B After Width: | Height: | Size: 267 B |
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 243 B |
4
src/qml/icons/settings.svg
Normal file
@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#cdd6f4" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="3"/>
|
||||
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 832 B |
|
Before Width: | Height: | Size: 250 B After Width: | Height: | Size: 250 B |
113
src/qml/pages/AccountPage.qml
Normal file
@ -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; copyable: true }
|
||||
InfoRow {
|
||||
label: "Balance"
|
||||
value: page.account.balance || "0"
|
||||
valueColor: Theme.palette.success
|
||||
}
|
||||
InfoRow { label: "Program Owner"; value: page.account.programOwner || ""; mono: true; copyable: 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
123
src/qml/pages/BlockPage.qml
Normal file
@ -0,0 +1,123 @@
|
||||
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; copyable: 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)
|
||||
}
|
||||
}
|
||||
CopyButton {
|
||||
visible: (page.block.prevBlockHash || "").length > 0
|
||||
value: page.block.prevBlockHash || ""
|
||||
Layout.alignment: Qt.AlignTop
|
||||
}
|
||||
}
|
||||
|
||||
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; copyable: 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
155
src/qml/pages/HomePage.qml
Normal file
@ -0,0 +1,155 @@
|
||||
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
|
||||
readonly property bool connected: backend && backend.connectionStatus === "Connected"
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.spacing.medium
|
||||
|
||||
// Health bar.
|
||||
Card {
|
||||
Layout.fillWidth: true
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.small
|
||||
|
||||
Rectangle {
|
||||
width: 9
|
||||
height: 9
|
||||
radius: 4.5
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
color: page.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
|
||||
Layout.alignment: Qt.AlignVCenter
|
||||
}
|
||||
|
||||
Item { Layout.fillWidth: true }
|
||||
|
||||
Rectangle {
|
||||
Layout.preferredHeight: 34
|
||||
Layout.preferredWidth: 100
|
||||
radius: Theme.spacing.radiusMedium
|
||||
color: settingsHover.hovered ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated
|
||||
border.width: 1
|
||||
border.color: Theme.palette.borderSecondary
|
||||
|
||||
HoverHandler { id: settingsHover }
|
||||
TapHandler { onTapped: if (page.explorer) page.explorer.navigateSettings() }
|
||||
|
||||
LogosText {
|
||||
anchors.centerIn: parent
|
||||
text: "Settings"
|
||||
color: Theme.palette.textSecondary
|
||||
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.
|
||||
ColumnLayout {
|
||||
anchors.centerIn: parent
|
||||
visible: blockList.count === 0
|
||||
width: parent.width * 0.8
|
||||
spacing: Theme.spacing.medium
|
||||
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
horizontalAlignment: Text.AlignHCenter
|
||||
wrapMode: Text.WordWrap
|
||||
text: page.connected
|
||||
? "No blocks indexed yet."
|
||||
: "No indexer running yet. Open Settings to configure and start it."
|
||||
color: Theme.palette.textMuted
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
}
|
||||
|
||||
Rectangle {
|
||||
visible: !page.connected
|
||||
Layout.alignment: Qt.AlignHCenter
|
||||
implicitHeight: 38
|
||||
implicitWidth: 160
|
||||
radius: Theme.spacing.radiusMedium
|
||||
color: openHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary
|
||||
|
||||
HoverHandler { id: openHover }
|
||||
TapHandler { onTapped: if (page.explorer) page.explorer.navigateSettings() }
|
||||
|
||||
LogosText {
|
||||
anchors.centerIn: parent
|
||||
text: "Open Settings"
|
||||
color: Theme.palette.background
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
font.weight: Theme.typography.weightBold
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
88
src/qml/pages/SearchResultsPage.qml
Normal file
@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/qml/pages/SettingsPage.qml
Normal file
@ -0,0 +1,158 @@
|
||||
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 bool statusIsError: false
|
||||
|
||||
Component.onCompleted: {
|
||||
// Seed the editor with the saved config, falling back to the template.
|
||||
if (page.backend) {
|
||||
var current = page.backend.configText;
|
||||
editor.text = (current && current.length > 0) ? current : page.backend.defaultConfig;
|
||||
}
|
||||
}
|
||||
|
||||
// Surface backend-side failures (write/start) in the status line.
|
||||
Connections {
|
||||
target: page.backend
|
||||
ignoreUnknownSignals: true
|
||||
function onError(message) {
|
||||
page.setStatus(message, true);
|
||||
}
|
||||
}
|
||||
|
||||
ColumnLayout {
|
||||
anchors.fill: parent
|
||||
spacing: Theme.spacing.medium
|
||||
|
||||
SectionHeader { title: "Indexer Settings" }
|
||||
|
||||
LogosText {
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
text: "Edit the indexer configuration below and press Save. The config is "
|
||||
+ "persisted and the indexer is (re)started from it — no file paths needed. "
|
||||
+ "It is reloaded automatically the next time you open the explorer."
|
||||
color: Theme.palette.textSecondary
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
}
|
||||
|
||||
// JSON editor. The TextArea is anchored to fill its container directly
|
||||
// (NOT wrapped in a ScrollView — there it doesn't inherit the viewport
|
||||
// size and collapses to an invisible implicit size). It scrolls its
|
||||
// viewport to follow the cursor; that's enough for a config-sized doc.
|
||||
Rectangle {
|
||||
Layout.fillWidth: true
|
||||
Layout.fillHeight: true
|
||||
radius: Theme.spacing.radiusLarge
|
||||
color: Theme.palette.backgroundElevated
|
||||
border.width: 1
|
||||
border.color: editor.activeFocus ? Theme.palette.borderInteractive
|
||||
: Theme.palette.borderSecondary
|
||||
clip: true
|
||||
|
||||
TextArea {
|
||||
id: editor
|
||||
anchors.fill: parent
|
||||
anchors.margins: Theme.spacing.medium
|
||||
font.family: "Menlo, Monaco, Courier New, monospace"
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
color: Theme.palette.text
|
||||
wrapMode: TextArea.Wrap
|
||||
background: Item {}
|
||||
}
|
||||
}
|
||||
|
||||
RowLayout {
|
||||
Layout.fillWidth: true
|
||||
spacing: Theme.spacing.medium
|
||||
|
||||
LogosText {
|
||||
id: statusLabel
|
||||
Layout.fillWidth: true
|
||||
wrapMode: Text.WordWrap
|
||||
color: page.statusIsError ? Theme.palette.error : Theme.palette.success
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
}
|
||||
|
||||
// Reset to the built-in template.
|
||||
Rectangle {
|
||||
Layout.preferredHeight: 38
|
||||
Layout.preferredWidth: 150
|
||||
radius: Theme.spacing.radiusMedium
|
||||
color: resetHover.hovered ? Theme.palette.backgroundSecondary : Theme.palette.backgroundElevated
|
||||
border.width: 1
|
||||
border.color: Theme.palette.borderSecondary
|
||||
|
||||
HoverHandler { id: resetHover }
|
||||
TapHandler { onTapped: if (page.backend) { editor.text = page.backend.defaultConfig; page.setStatus("", false); } }
|
||||
|
||||
LogosText {
|
||||
anchors.centerIn: parent
|
||||
text: "Reset to default"
|
||||
color: Theme.palette.textSecondary
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
}
|
||||
}
|
||||
|
||||
// Save + (re)start.
|
||||
Rectangle {
|
||||
Layout.preferredHeight: 38
|
||||
Layout.preferredWidth: 150
|
||||
radius: Theme.spacing.radiusMedium
|
||||
color: saveHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary
|
||||
|
||||
HoverHandler { id: saveHover }
|
||||
TapHandler { onTapped: page.save() }
|
||||
|
||||
LogosText {
|
||||
anchors.centerIn: parent
|
||||
text: "Save & Start"
|
||||
color: Theme.palette.background
|
||||
font.pixelSize: Theme.typography.secondaryText
|
||||
font.weight: Theme.typography.weightBold
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setStatus(message, isError) {
|
||||
page.statusIsError = isError;
|
||||
statusLabel.text = message;
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (!page.backend)
|
||||
return;
|
||||
// Validate locally first for an immediate, precise error.
|
||||
try {
|
||||
JSON.parse(editor.text);
|
||||
} catch (e) {
|
||||
page.setStatus("Invalid JSON: " + e, true);
|
||||
return;
|
||||
}
|
||||
page.setStatus("Saving…", false);
|
||||
// Port is an internal detail of the indexer's own RPC listener; the
|
||||
// explorer reads over the Logos protocol, so we pass "" (keep default).
|
||||
logos.watch(page.backend.applyConfigJson(editor.text, ""),
|
||||
function (ok) {
|
||||
if (ok) {
|
||||
page.setStatus("Saved. Indexer starting…", false);
|
||||
page.explorer.goHome();
|
||||
} else {
|
||||
page.setStatus("Save failed — check the config and try again.", true);
|
||||
}
|
||||
},
|
||||
function (err) {
|
||||
page.setStatus("Error: " + err, true);
|
||||
});
|
||||
}
|
||||
}
|
||||
143
src/qml/pages/TransactionPage.qml
Normal file
@ -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; copyable: 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; copyable: 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -1,43 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "models/Block.h"
|
||||
#include "models/Transaction.h"
|
||||
#include "models/Account.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QVector>
|
||||
#include <optional>
|
||||
|
||||
struct SearchResults {
|
||||
QVector<Block> blocks;
|
||||
QVector<Transaction> transactions;
|
||||
QVector<Account> accounts;
|
||||
};
|
||||
|
||||
class IndexerService : public QObject {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit IndexerService(QObject* parent = nullptr) : QObject(parent) {}
|
||||
~IndexerService() override = default;
|
||||
|
||||
static QString defaultEndpoint()
|
||||
{
|
||||
return "ws://localhost:8779";
|
||||
}
|
||||
|
||||
virtual QString endpoint() const = 0;
|
||||
virtual void setEndpoint(const QString& endpoint) = 0;
|
||||
|
||||
virtual std::optional<Account> getAccount(const QString& accountId) = 0;
|
||||
virtual std::optional<Block> getBlockById(quint64 blockId) = 0;
|
||||
virtual std::optional<Block> getBlockByHash(const QString& hash) = 0;
|
||||
virtual std::optional<Transaction> getTransaction(const QString& hash) = 0;
|
||||
virtual QVector<Block> getBlocks(std::optional<quint64> before, int limit) = 0;
|
||||
virtual quint64 getLastFinalizedBlockId() = 0;
|
||||
virtual QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) = 0;
|
||||
virtual SearchResults search(const QString& query) = 0;
|
||||
|
||||
signals:
|
||||
void newBlockAdded(Block block);
|
||||
};
|
||||
@ -1,380 +0,0 @@
|
||||
#include "MockIndexerService.h"
|
||||
|
||||
#include <QRandomGenerator>
|
||||
#include <algorithm>
|
||||
|
||||
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<int>(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.bedrockParentId = randomHexString(32);
|
||||
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);
|
||||
block.bedrockParentId = randomHexString(32);
|
||||
|
||||
// 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<Account> MockIndexerService::getAccount(const QString& accountId)
|
||||
{
|
||||
auto it = m_accounts.find(accountId);
|
||||
if (it != m_accounts.end()) {
|
||||
return *it;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Block> MockIndexerService::getBlockById(quint64 blockId)
|
||||
{
|
||||
if (blockId >= 1 && blockId <= static_cast<quint64>(m_blocks.size())) {
|
||||
return m_blocks[static_cast<int>(blockId - 1)];
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Block> MockIndexerService::getBlockByHash(const QString& hash)
|
||||
{
|
||||
auto it = m_blocksByHash.find(hash);
|
||||
if (it != m_blocksByHash.end()) {
|
||||
return *it;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Transaction> MockIndexerService::getTransaction(const QString& hash)
|
||||
{
|
||||
auto it = m_transactionsByHash.find(hash);
|
||||
if (it != m_transactionsByHash.end()) {
|
||||
return *it;
|
||||
}
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QVector<Block> MockIndexerService::getBlocks(std::optional<quint64> before, int limit)
|
||||
{
|
||||
QVector<Block> result;
|
||||
int startIdx = m_blocks.size() - 1;
|
||||
|
||||
if (before.has_value()) {
|
||||
startIdx = static_cast<int>(*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<Transaction> MockIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit)
|
||||
{
|
||||
QVector<Transaction> 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;
|
||||
}
|
||||
@ -1,44 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "IndexerService.h"
|
||||
|
||||
#include <QMap>
|
||||
#include <QTimer>
|
||||
|
||||
class MockIndexerService : public IndexerService {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit MockIndexerService(QObject* parent = nullptr);
|
||||
|
||||
QString endpoint() const override;
|
||||
void setEndpoint(const QString& endpoint) override;
|
||||
|
||||
std::optional<Account> getAccount(const QString& accountId) override;
|
||||
std::optional<Block> getBlockById(quint64 blockId) override;
|
||||
std::optional<Block> getBlockByHash(const QString& hash) override;
|
||||
std::optional<Transaction> getTransaction(const QString& hash) override;
|
||||
QVector<Block> getBlocks(std::optional<quint64> before, int limit) override;
|
||||
quint64 getLastFinalizedBlockId() override;
|
||||
QVector<Transaction> 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<Block> m_blocks;
|
||||
QMap<QString, Block> m_blocksByHash;
|
||||
QMap<QString, Transaction> m_transactionsByHash;
|
||||
QMap<QString, Account> m_accounts;
|
||||
QTimer m_blockTimer;
|
||||
QString m_endpoint = IndexerService::defaultEndpoint();
|
||||
};
|
||||
@ -1,741 +0,0 @@
|
||||
#include "RpcIndexerService.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QEventLoop>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonValue>
|
||||
#include <QRegularExpression>
|
||||
#include <QTimer>
|
||||
#include <QTimeZone>
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
namespace {
|
||||
|
||||
QString compactJson(const QJsonValue& value)
|
||||
{
|
||||
if (value.isObject()) {
|
||||
return QString::fromUtf8(QJsonDocument(value.toObject()).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
if (value.isArray()) {
|
||||
return QString::fromUtf8(QJsonDocument(value.toArray()).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
quint64 extractFinalizedBlockId(const QJsonValue& value)
|
||||
{
|
||||
if (value.isDouble()) {
|
||||
const double number = value.toDouble();
|
||||
if (number <= 0.0) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<quint64>(number);
|
||||
}
|
||||
|
||||
if (value.isString()) {
|
||||
bool ok = false;
|
||||
const qulonglong parsed = value.toString().toULongLong(&ok);
|
||||
return ok ? parsed : 0;
|
||||
}
|
||||
|
||||
if (value.isObject()) {
|
||||
const QJsonObject obj = value.toObject();
|
||||
if (obj.contains("block_id")) {
|
||||
return extractFinalizedBlockId(obj.value("block_id"));
|
||||
}
|
||||
if (obj.contains("blockId")) {
|
||||
return extractFinalizedBlockId(obj.value("blockId"));
|
||||
}
|
||||
if (obj.contains("id")) {
|
||||
return extractFinalizedBlockId(obj.value("id"));
|
||||
}
|
||||
if (obj.contains("result")) {
|
||||
return extractFinalizedBlockId(obj.value("result"));
|
||||
}
|
||||
if (obj.contains("header")) {
|
||||
return extractFinalizedBlockId(obj.value("header").toObject().value("block_id"));
|
||||
}
|
||||
}
|
||||
|
||||
if (value.isArray()) {
|
||||
const QJsonArray arr = value.toArray();
|
||||
for (const auto& item : arr) {
|
||||
const quint64 blockId = extractFinalizedBlockId(item);
|
||||
if (blockId) {
|
||||
return blockId;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
RpcIndexerService::RpcIndexerService(const QString& endpoint, QObject* parent)
|
||||
: IndexerService(parent)
|
||||
, m_endpoint(QUrl(endpoint.trimmed()))
|
||||
{
|
||||
connect(&m_socket, &QWebSocket::connected, this, &RpcIndexerService::onConnected);
|
||||
connect(&m_socket, &QWebSocket::disconnected, this, &RpcIndexerService::onDisconnected);
|
||||
connect(&m_socket, &QWebSocket::textMessageReceived, this, &RpcIndexerService::onTextMessageReceived);
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
|
||||
connect(&m_socket, &QWebSocket::errorOccurred, this, &RpcIndexerService::onSocketError);
|
||||
#else
|
||||
connect(&m_socket,
|
||||
QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error),
|
||||
this,
|
||||
&RpcIndexerService::onSocketError);
|
||||
#endif
|
||||
}
|
||||
|
||||
RpcIndexerService::~RpcIndexerService()
|
||||
{
|
||||
m_socket.close();
|
||||
}
|
||||
|
||||
QString RpcIndexerService::endpoint() const
|
||||
{
|
||||
return m_endpoint.toString();
|
||||
}
|
||||
|
||||
void RpcIndexerService::setEndpoint(const QString& endpoint)
|
||||
{
|
||||
const QString trimmed = endpoint.trimmed();
|
||||
const QUrl newEndpoint(trimmed.isEmpty() ? IndexerService::defaultEndpoint() : trimmed);
|
||||
if (!newEndpoint.isValid()) {
|
||||
qWarning() << "Invalid indexer endpoint URL:" << endpoint;
|
||||
return;
|
||||
}
|
||||
|
||||
if (newEndpoint == m_endpoint) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_endpoint = newEndpoint;
|
||||
m_nextRequestId = 1;
|
||||
m_responses.clear();
|
||||
m_subscriptionRequestId = 0;
|
||||
m_subscriptionId = QJsonValue();
|
||||
m_subscriptionActive = false;
|
||||
m_lastNotifiedBlockId = 0;
|
||||
|
||||
for (auto it = m_waiters.begin(); it != m_waiters.end(); ++it) {
|
||||
if (it.value()) {
|
||||
it.value()->quit();
|
||||
}
|
||||
}
|
||||
m_waiters.clear();
|
||||
|
||||
if (m_socket.state() != QAbstractSocket::UnconnectedState) {
|
||||
m_socket.close();
|
||||
}
|
||||
}
|
||||
|
||||
std::optional<Account> RpcIndexerService::getAccount(const QString& accountId)
|
||||
{
|
||||
auto result = callRpc("getAccount", QJsonArray{accountId});
|
||||
if (!result) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return parseAccount(accountId, *result);
|
||||
}
|
||||
|
||||
std::optional<Block> RpcIndexerService::getBlockById(quint64 blockId)
|
||||
{
|
||||
auto result = callRpc("getBlockById", QJsonArray{static_cast<qint64>(blockId)});
|
||||
if (!result || result->isNull()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return parseBlock(*result);
|
||||
}
|
||||
|
||||
std::optional<Block> RpcIndexerService::getBlockByHash(const QString& hash)
|
||||
{
|
||||
auto result = callRpc("getBlockByHash", QJsonArray{hash});
|
||||
if (!result || result->isNull()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return parseBlock(*result);
|
||||
}
|
||||
|
||||
std::optional<Transaction> RpcIndexerService::getTransaction(const QString& hash)
|
||||
{
|
||||
auto result = callRpc("getTransaction", QJsonArray{hash});
|
||||
if (!result || result->isNull()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
return parseTransaction(*result);
|
||||
}
|
||||
|
||||
QVector<Block> RpcIndexerService::getBlocks(std::optional<quint64> before, int limit)
|
||||
{
|
||||
QJsonArray params;
|
||||
if (before.has_value()) {
|
||||
params.append(static_cast<qint64>(*before));
|
||||
} else {
|
||||
params.append(QJsonValue::Null);
|
||||
}
|
||||
params.append(limit);
|
||||
|
||||
auto result = callRpc("getBlocks", params);
|
||||
if (!result || !result->isArray()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QVector<Block> blocks;
|
||||
const QJsonArray array = result->toArray();
|
||||
blocks.reserve(array.size());
|
||||
for (const auto& item : array) {
|
||||
auto block = parseBlock(item);
|
||||
if (block) {
|
||||
blocks.append(*block);
|
||||
}
|
||||
}
|
||||
return blocks;
|
||||
}
|
||||
|
||||
quint64 RpcIndexerService::getLastFinalizedBlockId()
|
||||
{
|
||||
auto result = callRpc("getLastFinalizedBlockId");
|
||||
if (!result) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const quint64 latestFinalized = jsonValueToU64(*result);
|
||||
if (latestFinalized > m_lastNotifiedBlockId) {
|
||||
m_lastNotifiedBlockId = latestFinalized;
|
||||
}
|
||||
|
||||
return latestFinalized;
|
||||
}
|
||||
|
||||
QVector<Transaction> RpcIndexerService::getTransactionsByAccount(const QString& accountId, int offset, int limit)
|
||||
{
|
||||
auto result = callRpc("getTransactionsByAccount", QJsonArray{accountId, offset, limit});
|
||||
if (!result || !result->isArray()) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QVector<Transaction> transactions;
|
||||
const QJsonArray array = result->toArray();
|
||||
transactions.reserve(array.size());
|
||||
for (const auto& item : array) {
|
||||
auto tx = parseTransaction(item);
|
||||
if (tx) {
|
||||
transactions.append(*tx);
|
||||
}
|
||||
}
|
||||
return transactions;
|
||||
}
|
||||
|
||||
SearchResults RpcIndexerService::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 auto 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 RpcIndexerService::onConnected()
|
||||
{
|
||||
qInfo() << "Connected to indexer RPC:" << m_endpoint;
|
||||
ensureFinalizedBlocksSubscription();
|
||||
}
|
||||
|
||||
void RpcIndexerService::onDisconnected()
|
||||
{
|
||||
m_subscriptionRequestId = 0;
|
||||
m_subscriptionId = QJsonValue();
|
||||
m_subscriptionActive = false;
|
||||
|
||||
for (auto it = m_waiters.begin(); it != m_waiters.end(); ++it) {
|
||||
if (it.value()) {
|
||||
it.value()->quit();
|
||||
}
|
||||
}
|
||||
m_waiters.clear();
|
||||
}
|
||||
|
||||
void RpcIndexerService::onSocketError(QAbstractSocket::SocketError error)
|
||||
{
|
||||
Q_UNUSED(error);
|
||||
qWarning() << "Indexer RPC socket error:" << m_socket.errorString();
|
||||
}
|
||||
|
||||
void RpcIndexerService::onTextMessageReceived(const QString& message)
|
||||
{
|
||||
QJsonParseError parseError;
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8(), &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
qWarning() << "Invalid JSON-RPC payload:" << parseError.errorString();
|
||||
return;
|
||||
}
|
||||
|
||||
const QJsonObject payload = doc.object();
|
||||
|
||||
const QString method = payload.value("method").toString();
|
||||
if (method == "subscribeToFinalizedBlocks") {
|
||||
const QJsonObject params = payload.value("params").toObject();
|
||||
if (params.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!m_subscriptionId.isNull() && params.contains("subscription")) {
|
||||
const QJsonValue subscriptionValue = params.value("subscription");
|
||||
if (subscriptionValue != m_subscriptionId) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
handleFinalizedBlockNotification(params.value("result"));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!payload.contains("id")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quint64 id = jsonValueToU64(payload.value("id"));
|
||||
if (!id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (id == m_subscriptionRequestId) {
|
||||
m_subscriptionRequestId = 0;
|
||||
if (payload.contains("error")) {
|
||||
m_subscriptionActive = false;
|
||||
m_subscriptionId = QJsonValue();
|
||||
qWarning() << "Failed to subscribe to finalized blocks:" << compactJson(payload.value("error"));
|
||||
} else {
|
||||
m_subscriptionId = payload.value("result");
|
||||
m_subscriptionActive = !m_subscriptionId.isUndefined() && !m_subscriptionId.isNull();
|
||||
if (!m_subscriptionActive) {
|
||||
qWarning() << "Unexpected finalized blocks subscription result";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
m_responses.insert(id, payload);
|
||||
auto* waiter = m_waiters.value(id, nullptr);
|
||||
if (waiter) {
|
||||
waiter->quit();
|
||||
}
|
||||
}
|
||||
|
||||
void RpcIndexerService::handleFinalizedBlockNotification(const QJsonValue& result)
|
||||
{
|
||||
const quint64 blockId = extractFinalizedBlockId(result);
|
||||
if (!blockId || blockId <= m_lastNotifiedBlockId) {
|
||||
return;
|
||||
}
|
||||
|
||||
QTimer::singleShot(0, this, [this, blockId]() {
|
||||
fetchAndEmitFinalizedBlock(blockId);
|
||||
});
|
||||
}
|
||||
|
||||
void RpcIndexerService::fetchAndEmitFinalizedBlock(quint64 blockId)
|
||||
{
|
||||
auto block = getBlockById(blockId);
|
||||
if (block) {
|
||||
if (blockId <= m_lastNotifiedBlockId) {
|
||||
return;
|
||||
}
|
||||
m_lastNotifiedBlockId = blockId;
|
||||
emit newBlockAdded(*block);
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback to latest block snapshot if the specific id is briefly unavailable.
|
||||
auto latestBlocks = getBlocks(std::nullopt, 1);
|
||||
if (!latestBlocks.isEmpty()) {
|
||||
const Block& latest = latestBlocks.first();
|
||||
if (latest.blockId >= blockId && latest.blockId > m_lastNotifiedBlockId) {
|
||||
m_lastNotifiedBlockId = latest.blockId;
|
||||
emit newBlockAdded(latest);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
qWarning() << "Finalized block announced but not yet available:" << blockId;
|
||||
}
|
||||
|
||||
bool RpcIndexerService::ensureConnected()
|
||||
{
|
||||
if (!m_endpoint.isValid() || m_endpoint.scheme().isEmpty()) {
|
||||
qWarning() << "Invalid indexer RPC URL:" << m_endpoint;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (m_socket.state() == QAbstractSocket::ConnectedState) {
|
||||
ensureFinalizedBlocksSubscription();
|
||||
return true;
|
||||
}
|
||||
|
||||
if (m_socket.state() == QAbstractSocket::UnconnectedState) {
|
||||
m_socket.open(m_endpoint);
|
||||
}
|
||||
|
||||
QEventLoop waitLoop;
|
||||
QTimer timeout;
|
||||
bool timedOut = false;
|
||||
|
||||
timeout.setSingleShot(true);
|
||||
connect(&timeout, &QTimer::timeout, &waitLoop, [&]() {
|
||||
timedOut = true;
|
||||
waitLoop.quit();
|
||||
});
|
||||
|
||||
connect(&m_socket, &QWebSocket::connected, &waitLoop, &QEventLoop::quit);
|
||||
connect(&m_socket, &QWebSocket::disconnected, &waitLoop, &QEventLoop::quit);
|
||||
#if QT_VERSION >= QT_VERSION_CHECK(6, 5, 0)
|
||||
connect(&m_socket, &QWebSocket::errorOccurred, &waitLoop, &QEventLoop::quit);
|
||||
#else
|
||||
connect(&m_socket,
|
||||
QOverload<QAbstractSocket::SocketError>::of(&QWebSocket::error),
|
||||
&waitLoop,
|
||||
&QEventLoop::quit);
|
||||
#endif
|
||||
|
||||
timeout.start(4000);
|
||||
waitLoop.exec();
|
||||
|
||||
if (timedOut) {
|
||||
qWarning() << "Timeout connecting to indexer RPC:" << m_endpoint;
|
||||
m_socket.abort();
|
||||
return false;
|
||||
}
|
||||
|
||||
const bool isConnected = m_socket.state() == QAbstractSocket::ConnectedState;
|
||||
if (isConnected) {
|
||||
ensureFinalizedBlocksSubscription();
|
||||
}
|
||||
|
||||
return isConnected;
|
||||
}
|
||||
|
||||
void RpcIndexerService::ensureFinalizedBlocksSubscription()
|
||||
{
|
||||
if (m_socket.state() != QAbstractSocket::ConnectedState) {
|
||||
return;
|
||||
}
|
||||
if (m_subscriptionActive || m_subscriptionRequestId != 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const quint64 requestId = m_nextRequestId++;
|
||||
m_subscriptionRequestId = requestId;
|
||||
|
||||
QJsonObject request;
|
||||
request.insert("jsonrpc", "2.0");
|
||||
request.insert("id", static_cast<qint64>(requestId));
|
||||
request.insert("method", "subscribeToFinalizedBlocks");
|
||||
request.insert("params", QJsonArray());
|
||||
|
||||
m_socket.sendTextMessage(QString::fromUtf8(QJsonDocument(request).toJson(QJsonDocument::Compact)));
|
||||
}
|
||||
|
||||
std::optional<QJsonValue> RpcIndexerService::callRpc(const QString& method, const QJsonArray& params)
|
||||
{
|
||||
if (!ensureConnected()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const quint64 id = m_nextRequestId++;
|
||||
QJsonObject request;
|
||||
request.insert("jsonrpc", "2.0");
|
||||
request.insert("id", static_cast<qint64>(id));
|
||||
request.insert("method", method);
|
||||
request.insert("params", params);
|
||||
|
||||
QEventLoop waitLoop;
|
||||
QTimer timeout;
|
||||
bool timedOut = false;
|
||||
|
||||
timeout.setSingleShot(true);
|
||||
connect(&timeout, &QTimer::timeout, &waitLoop, [&]() {
|
||||
timedOut = true;
|
||||
waitLoop.quit();
|
||||
});
|
||||
connect(&m_socket, &QWebSocket::disconnected, &waitLoop, &QEventLoop::quit);
|
||||
|
||||
m_waiters.insert(id, &waitLoop);
|
||||
m_socket.sendTextMessage(QString::fromUtf8(QJsonDocument(request).toJson(QJsonDocument::Compact)));
|
||||
|
||||
timeout.start(5000);
|
||||
waitLoop.exec();
|
||||
m_waiters.remove(id);
|
||||
|
||||
if (timedOut) {
|
||||
qWarning() << "RPC call timeout:" << method;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
if (!m_responses.contains(id)) {
|
||||
qWarning() << "No RPC response for" << method;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const QJsonObject response = m_responses.take(id);
|
||||
if (response.contains("error")) {
|
||||
qWarning() << "RPC error for" << method << compactJson(response.value("error"));
|
||||
return std::nullopt;
|
||||
}
|
||||
if (!response.contains("result")) {
|
||||
qWarning() << "RPC result missing for" << method;
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const std::optional<QJsonValue> result = response.value("result");
|
||||
qDebug() << "RPC call result for" << method << "(" << params << "):" << jsonValueToString(*result);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<Block> RpcIndexerService::parseBlock(const QJsonValue& value) const
|
||||
{
|
||||
if (!value.isObject()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const QJsonObject blockObj = value.toObject();
|
||||
const QJsonObject headerObj = blockObj.value("header").toObject();
|
||||
const QJsonObject bodyObj = blockObj.value("body").toObject();
|
||||
if (headerObj.isEmpty()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
Block block;
|
||||
block.blockId = jsonValueToU64(headerObj.value("block_id"));
|
||||
block.hash = jsonValueToString(headerObj.value("hash"));
|
||||
block.prevBlockHash = jsonValueToString(headerObj.value("prev_block_hash"));
|
||||
block.signature = jsonValueToString(headerObj.value("signature"));
|
||||
block.bedrockParentId = jsonValueToString(blockObj.value("bedrock_parent_id"));
|
||||
|
||||
const quint64 timestamp = jsonValueToU64(headerObj.value("timestamp"));
|
||||
if (timestamp > 1000000000000ULL) {
|
||||
block.timestamp = QDateTime::fromMSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
|
||||
} else {
|
||||
block.timestamp = QDateTime::fromSecsSinceEpoch(static_cast<qint64>(timestamp), QTimeZone::UTC);
|
||||
}
|
||||
if (!block.timestamp.isValid()) {
|
||||
block.timestamp = QDateTime::currentDateTimeUtc();
|
||||
}
|
||||
|
||||
const QString status = blockObj.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 = bodyObj.value("transactions").toArray();
|
||||
block.transactions.reserve(txArray.size());
|
||||
for (const auto& txValue : txArray) {
|
||||
auto tx = parseTransaction(txValue);
|
||||
if (tx) {
|
||||
block.transactions.append(*tx);
|
||||
}
|
||||
}
|
||||
|
||||
return block;
|
||||
}
|
||||
|
||||
std::optional<Transaction> RpcIndexerService::parseTransaction(const QJsonValue& value) const
|
||||
{
|
||||
if (!value.isObject()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const QJsonObject wrapper = value.toObject();
|
||||
Transaction tx;
|
||||
|
||||
auto appendAccounts = [&tx](const QJsonArray& accountIds, const QJsonArray& nonces) {
|
||||
const int pairedCount = std::min(accountIds.size(), nonces.size());
|
||||
for (int i = 0; i < pairedCount; ++i) {
|
||||
AccountRef account;
|
||||
account.accountId = jsonValueToString(accountIds.at(i));
|
||||
account.nonce = jsonValueToString(nonces.at(i));
|
||||
tx.accounts.append(account);
|
||||
}
|
||||
for (int i = pairedCount; i < accountIds.size(); ++i) {
|
||||
AccountRef account;
|
||||
account.accountId = jsonValueToString(accountIds.at(i));
|
||||
account.nonce = "0";
|
||||
tx.accounts.append(account);
|
||||
}
|
||||
};
|
||||
|
||||
auto fillWitnessFields = [&tx](const QJsonObject& witnessSet) {
|
||||
tx.signatureCount = witnessSet.value("signatures_and_public_keys").toArray().size();
|
||||
if (witnessSet.value("proof").isString()) {
|
||||
tx.proofSizeBytes = base64Size(witnessSet.value("proof").toString());
|
||||
}
|
||||
};
|
||||
|
||||
if (wrapper.contains("Public")) {
|
||||
const QJsonObject publicTx = wrapper.value("Public").toObject();
|
||||
const QJsonObject message = publicTx.value("message").toObject();
|
||||
|
||||
tx.type = TransactionType::Public;
|
||||
tx.hash = jsonValueToString(publicTx.value("hash"));
|
||||
tx.programId = jsonValueToString(message.value("program_id"));
|
||||
appendAccounts(message.value("account_ids").toArray(), message.value("nonces").toArray());
|
||||
|
||||
const QJsonArray instructionData = message.value("instruction_data").toArray();
|
||||
tx.instructionData.reserve(instructionData.size());
|
||||
for (const auto& v : instructionData) {
|
||||
tx.instructionData.append(static_cast<quint32>(jsonValueToU64(v)));
|
||||
}
|
||||
|
||||
fillWitnessFields(publicTx.value("witness_set").toObject());
|
||||
return tx;
|
||||
}
|
||||
|
||||
if (wrapper.contains("PrivacyPreserving")) {
|
||||
const QJsonObject privateTx = wrapper.value("PrivacyPreserving").toObject();
|
||||
const QJsonObject message = privateTx.value("message").toObject();
|
||||
|
||||
tx.type = TransactionType::PrivacyPreserving;
|
||||
tx.hash = jsonValueToString(privateTx.value("hash"));
|
||||
appendAccounts(message.value("public_account_ids").toArray(), message.value("nonces").toArray());
|
||||
|
||||
tx.newCommitmentsCount = message.value("new_commitments").toArray().size();
|
||||
tx.nullifiersCount = message.value("new_nullifiers").toArray().size();
|
||||
tx.encryptedStatesCount = message.value("encrypted_private_post_states").toArray().size();
|
||||
|
||||
const QJsonArray validityWindow = message.value("block_validity_window").toArray();
|
||||
if (!validityWindow.isEmpty()) {
|
||||
tx.validityWindowStart = jsonValueToU64(validityWindow.at(0));
|
||||
}
|
||||
if (validityWindow.size() > 1) {
|
||||
tx.validityWindowEnd = jsonValueToU64(validityWindow.at(1));
|
||||
}
|
||||
|
||||
fillWitnessFields(privateTx.value("witness_set").toObject());
|
||||
return tx;
|
||||
}
|
||||
|
||||
if (wrapper.contains("ProgramDeployment")) {
|
||||
const QJsonObject deployTx = wrapper.value("ProgramDeployment").toObject();
|
||||
const QJsonObject message = deployTx.value("message").toObject();
|
||||
|
||||
tx.type = TransactionType::ProgramDeployment;
|
||||
tx.hash = jsonValueToString(deployTx.value("hash"));
|
||||
tx.bytecodeSizeBytes = base64Size(message.value("bytecode").toString());
|
||||
tx.signatureCount = 0;
|
||||
tx.proofSizeBytes = 0;
|
||||
return tx;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
std::optional<Account> RpcIndexerService::parseAccount(const QString& accountId, const QJsonValue& value) const
|
||||
{
|
||||
if (!value.isObject()) {
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
const QJsonObject accountObj = value.toObject();
|
||||
|
||||
Account account;
|
||||
account.accountId = accountId;
|
||||
account.programOwner = jsonValueToString(accountObj.value("program_owner"));
|
||||
account.balance = jsonValueToString(accountObj.value("balance"));
|
||||
account.nonce = jsonValueToString(accountObj.value("nonce"));
|
||||
account.dataSizeBytes = base64Size(accountObj.value("data").toString());
|
||||
|
||||
if (account.balance.isEmpty()) {
|
||||
account.balance = "0";
|
||||
}
|
||||
if (account.nonce.isEmpty()) {
|
||||
account.nonce = "0";
|
||||
}
|
||||
|
||||
return account;
|
||||
}
|
||||
|
||||
QString RpcIndexerService::jsonValueToString(const QJsonValue& value)
|
||||
{
|
||||
if (value.isString()) {
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
if (value.isDouble()) {
|
||||
const double number = value.toDouble();
|
||||
const qulonglong integer = static_cast<qulonglong>(number);
|
||||
if (number == static_cast<double>(integer)) {
|
||||
return QString::number(integer);
|
||||
}
|
||||
return QString::number(number, 'g', 16);
|
||||
}
|
||||
|
||||
if (value.isBool()) {
|
||||
return value.toBool() ? "true" : "false";
|
||||
}
|
||||
|
||||
return compactJson(value);
|
||||
}
|
||||
|
||||
quint64 RpcIndexerService::jsonValueToU64(const QJsonValue& value)
|
||||
{
|
||||
if (value.isDouble()) {
|
||||
const double number = value.toDouble();
|
||||
if (number <= 0.0) {
|
||||
return 0;
|
||||
}
|
||||
return static_cast<quint64>(number);
|
||||
}
|
||||
|
||||
if (value.isString()) {
|
||||
bool ok = false;
|
||||
const qulonglong parsed = value.toString().toULongLong(&ok);
|
||||
return ok ? parsed : 0;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
int RpcIndexerService::base64Size(const QString& value)
|
||||
{
|
||||
if (value.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
return QByteArray::fromBase64(value.toUtf8()).size();
|
||||
}
|
||||
@ -1,66 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "IndexerService.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QUrl>
|
||||
#include <QWebSocket>
|
||||
|
||||
#include <optional>
|
||||
|
||||
class QEventLoop;
|
||||
|
||||
class RpcIndexerService : public IndexerService {
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit RpcIndexerService(const QString& endpoint = IndexerService::defaultEndpoint(), QObject* parent = nullptr);
|
||||
~RpcIndexerService() override;
|
||||
|
||||
QString endpoint() const override;
|
||||
void setEndpoint(const QString& endpoint) override;
|
||||
|
||||
std::optional<Account> getAccount(const QString& accountId) override;
|
||||
std::optional<Block> getBlockById(quint64 blockId) override;
|
||||
std::optional<Block> getBlockByHash(const QString& hash) override;
|
||||
std::optional<Transaction> getTransaction(const QString& hash) override;
|
||||
QVector<Block> getBlocks(std::optional<quint64> before, int limit) override;
|
||||
quint64 getLastFinalizedBlockId() override;
|
||||
QVector<Transaction> getTransactionsByAccount(const QString& accountId, int offset, int limit) override;
|
||||
SearchResults search(const QString& query) override;
|
||||
|
||||
private slots:
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
void onSocketError(QAbstractSocket::SocketError error);
|
||||
void onTextMessageReceived(const QString& message);
|
||||
|
||||
private:
|
||||
void handleFinalizedBlockNotification(const QJsonValue& result);
|
||||
void fetchAndEmitFinalizedBlock(quint64 blockId);
|
||||
|
||||
bool ensureConnected();
|
||||
void ensureFinalizedBlocksSubscription();
|
||||
std::optional<QJsonValue> callRpc(const QString& method, const QJsonArray& params = QJsonArray());
|
||||
|
||||
std::optional<Block> parseBlock(const QJsonValue& value) const;
|
||||
std::optional<Transaction> parseTransaction(const QJsonValue& value) const;
|
||||
std::optional<Account> parseAccount(const QString& accountId, const QJsonValue& value) const;
|
||||
|
||||
static QString jsonValueToString(const QJsonValue& value);
|
||||
static quint64 jsonValueToU64(const QJsonValue& value);
|
||||
static int base64Size(const QString& value);
|
||||
|
||||
QWebSocket m_socket;
|
||||
QUrl m_endpoint;
|
||||
quint64 m_nextRequestId = 1;
|
||||
QHash<quint64, QJsonObject> m_responses;
|
||||
QHash<quint64, QEventLoop*> m_waiters;
|
||||
quint64 m_subscriptionRequestId = 0;
|
||||
QJsonValue m_subscriptionId;
|
||||
bool m_subscriptionActive = false;
|
||||
quint64 m_lastNotifiedBlockId = 0;
|
||||
};
|
||||
@ -1,47 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include "Style.h"
|
||||
|
||||
#include <QFrame>
|
||||
#include <QMouseEvent>
|
||||
|
||||
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;
|
||||
};
|
||||
@ -1,63 +0,0 @@
|
||||
#include "NavigationBar.h"
|
||||
#include "Style.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QPushButton>
|
||||
#include <QLabel>
|
||||
#include <QIcon>
|
||||
|
||||
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);
|
||||
}
|
||||
@ -1,24 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
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;
|
||||
};
|
||||
@ -1,38 +0,0 @@
|
||||
#include "SearchBar.h"
|
||||
#include "Style.h"
|
||||
|
||||
#include <QHBoxLayout>
|
||||
#include <QLineEdit>
|
||||
#include <QPushButton>
|
||||
|
||||
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();
|
||||
}
|
||||
@ -1,20 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
|
||||
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;
|
||||
};
|
||||
28
tests/ui-tests.mjs
Normal file
@ -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", "Settings"]); },
|
||||
{ timeout: 15000, interval: 500, description: "the home page to render" }
|
||||
);
|
||||
});
|
||||
|
||||
run();
|
||||