feat: copy buttons and add config editor

This commit is contained in:
erhant 2026-06-18 20:19:13 +03:00
parent 576881fb53
commit 42d6093010
10 changed files with 415 additions and 97 deletions

View File

@ -12,6 +12,7 @@ A QML block explorer for the **Logos Execution Zone** (LEZ), packaged as a `logo
- Search by block id, block hash, transaction hash, or account id
- Browser-style back/forward navigation
- "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
@ -34,7 +35,7 @@ nix run . # preview in logos-standalone-app (spawns the ui-host)
nix build .#lgx # bundle as a .lgx for logos-basecamp
```
Running against live data needs the indexer reachable; enter its `indexer_config.json` path in the explorer's config field (leave blank if the indexer is already running). Load the explorer and indexer `.lgx` bundles together in logos-basecamp for the full experience.
Running against live data: 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

View File

@ -15,15 +15,20 @@ class LezExplorerUi
PROP(QVariantList recentBlocks READONLY)
// Tip block id (from getLastFinalizedBlockId).
PROP(qlonglong chainHeight=0 READONLY)
// Absolute path of the indexer config currently in use ("" if none set).
PROP(QString indexerConfig READONLY)
// "Connecting" / "Connected" / "Error" — drives the health indicator.
PROP(QString connectionStatus READONLY)
// 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)
// Point the indexer at a config and (re)start ingestion, then refresh the
// feed. port is the indexer RPC port. Returns true on success. A blank
// configPath skips start_indexer (the indexer is assumed already running).
SLOT(bool applyIndexerConfig(QString configPath, QString port))
// 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.

View File

@ -4,11 +4,16 @@
#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>
@ -23,6 +28,31 @@ constexpr int kPollIntervalMs = 2000;
// 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)
@ -186,7 +216,10 @@ QVariantMap txJsonToMap(const QString& json)
} // namespace
LezExplorerUiBackend::LezExplorerUiBackend() = default;
LezExplorerUiBackend::LezExplorerUiBackend()
{
setDefaultConfig(QString::fromUtf8(kDefaultConfig));
}
LezExplorerUiBackend::~LezExplorerUiBackend() = default;
@ -194,6 +227,13 @@ 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);
@ -204,29 +244,39 @@ void LezExplorerUiBackend::onContextReady()
pollTip();
}
bool LezExplorerUiBackend::applyIndexerConfig(QString configPath, QString port)
bool LezExplorerUiBackend::applyConfigJson(QString json, QString port)
{
const QString trimmedConfig = configPath.trimmed();
const QString trimmedPort = port.trimmed();
if (!trimmedPort.isEmpty()) {
m_port = trimmedPort;
}
setIndexerConfig(trimmedConfig);
bool started = true;
if (!trimmedConfig.isEmpty()) {
const qlonglong code = modules().lez_indexer_module.start_indexer(trimmedConfig, m_port);
started = (code == 0);
if (!started) {
emit error(QStringLiteral("start_indexer failed (code %1) for %2").arg(code).arg(trimmedConfig));
}
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 started;
return ok;
}
void LezExplorerUiBackend::refreshBlocks()
@ -436,3 +486,63 @@ QVariantList LezExplorerUiBackend::parseBlockArrayJson(const QString& json) cons
}
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;
}

View File

@ -23,6 +23,11 @@ class QTimer;
* 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:
@ -30,11 +35,11 @@ public:
~LezExplorerUiBackend() override;
// Fires when ui-host wires the plugin's LogosAPI: modules() is live, so we
// start the poll here.
// load any saved config, start it, and start the poll here.
void onContextReady() override;
// .rep SLOTs.
bool applyIndexerConfig(QString configPath, QString port) override;
bool applyConfigJson(QString json, QString port) override;
void refreshBlocks() override;
void loadMoreBlocks() override;
QVariantMap getBlockById(QString id) override;
@ -52,7 +57,16 @@ private:
// 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

View File

@ -46,6 +46,7 @@ Rectangle {
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 });
}
@ -72,6 +73,8 @@ Rectangle {
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;
@ -101,6 +104,7 @@ Rectangle {
onBackClicked: root.goBack()
onForwardClicked: root.goForward()
onHomeClicked: root.goHome()
onSettingsClicked: root.navigateSettings()
}
SearchBar {

View File

@ -12,6 +12,7 @@ RowLayout {
signal backClicked()
signal forwardClicked()
signal homeClicked()
signal settingsClicked()
spacing: Theme.spacing.small
@ -40,4 +41,9 @@ RowLayout {
}
Item { Layout.fillWidth: true }
IconButton {
source: Qt.resolvedUrl("../icons/settings.svg")
onClicked: root.settingsClicked()
}
}

View 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

View File

@ -11,70 +11,16 @@ Item {
// 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
// Indexer config + health.
// Health bar.
Card {
Layout.fillWidth: true
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.medium
LogosText {
text: "Indexer config"
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 38
radius: Theme.spacing.radiusMedium
color: Theme.palette.backgroundSecondary
border.width: 1
border.color: configField.activeFocus ? Theme.palette.borderInteractive
: Theme.palette.borderSecondary
TextField {
id: configField
anchors.fill: parent
anchors.leftMargin: Theme.spacing.medium
anchors.rightMargin: Theme.spacing.medium
verticalAlignment: TextInput.AlignVCenter
text: page.backend ? page.backend.indexerConfig : ""
placeholderText: "absolute path to indexer_config.json (blank = already running)"
color: Theme.palette.text
placeholderTextColor: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
background: Item {}
onAccepted: page.applyConfig()
}
}
Rectangle {
Layout.preferredHeight: 38
Layout.preferredWidth: 84
radius: Theme.spacing.radiusMedium
color: applyHover.hovered ? Qt.lighter(Theme.palette.primary, 1.1) : Theme.palette.primary
HoverHandler { id: applyHover }
TapHandler { onTapped: page.applyConfig() }
LogosText {
anchors.centerIn: parent
text: "Apply"
color: Theme.palette.background
font.pixelSize: Theme.typography.secondaryText
font.weight: Theme.typography.weightBold
}
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
@ -84,9 +30,7 @@ Item {
height: 9
radius: 4.5
Layout.alignment: Qt.AlignVCenter
color: page.backend && page.backend.connectionStatus === "Connected"
? Theme.palette.success
: Theme.palette.warning
color: page.connected ? Theme.palette.success : Theme.palette.warning
}
LogosText {
text: {
@ -97,6 +41,28 @@ Item {
}
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
}
}
}
}
@ -147,26 +113,43 @@ Item {
}
// Empty state.
LogosText {
ColumnLayout {
anchors.centerIn: parent
visible: blockList.count === 0
width: parent.width * 0.8
horizontalAlignment: Text.AlignHCenter
wrapMode: Text.WordWrap
text: page.backend && page.backend.connectionStatus === "Connected"
? "No blocks indexed yet."
: "Waiting for the indexer… set the config path above if it isn't running."
color: Theme.palette.textMuted
font.pixelSize: Theme.typography.secondaryText
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
}
}
}
}
}
function applyConfig() {
if (!page.backend)
return;
// Empty port backend keeps its default (8779).
logos.watch(page.backend.applyIndexerConfig(configField.text, ""),
function (ok) {}, function (err) {});
}
}

View File

@ -0,0 +1,191 @@
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;
portField.text = "8779";
}
}
// 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
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.medium
LogosText {
text: "RPC port"
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
Layout.alignment: Qt.AlignVCenter
}
Rectangle {
Layout.preferredWidth: 120
Layout.preferredHeight: 36
radius: Theme.spacing.radiusMedium
color: Theme.palette.backgroundSecondary
border.width: 1
border.color: portField.activeFocus ? Theme.palette.borderInteractive
: Theme.palette.borderSecondary
TextField {
id: portField
anchors.fill: parent
anchors.leftMargin: Theme.spacing.medium
anchors.rightMargin: Theme.spacing.medium
verticalAlignment: TextInput.AlignVCenter
text: "8779"
inputMethodHints: Qt.ImhDigitsOnly
color: Theme.palette.text
font.pixelSize: Theme.typography.secondaryText
background: Item {}
}
}
Item { Layout.fillWidth: true }
}
// 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);
logos.watch(page.backend.applyConfigJson(editor.text, portField.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);
});
}
}

View File

@ -20,7 +20,7 @@ test("lez_explorer_ui: loads the view", async (app) => {
test("lez_explorer_ui: renders the home page", async (app) => {
await app.waitFor(
async () => { await app.expectTexts(["Recent Blocks", "Indexer config"]); },
async () => { await app.expectTexts(["Recent Blocks", "Settings"]); },
{ timeout: 15000, interval: 500, description: "the home page to render" }
);
});