mirror of
https://github.com/logos-blockchain/lez-explorer-ui.git
synced 2026-07-30 03:13:25 +00:00
feat: copy buttons and add config editor
This commit is contained in:
parent
576881fb53
commit
42d6093010
@ -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
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
4
src/qml/icons/settings.svg
Normal file
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 |
@ -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) {});
|
||||
}
|
||||
}
|
||||
|
||||
191
src/qml/pages/SettingsPage.qml
Normal file
191
src/qml/pages/SettingsPage.qml
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
||||
@ -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" }
|
||||
);
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user