Merge e6b5d5affdc762d89676f35bcca383cec5edbc48 into 920d9b282140ed4be4472c018ed770b11daa9566

This commit is contained in:
Daniel Sanchez 2026-07-07 10:25:52 +00:00 committed by GitHub
commit c0d5bc3d33
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 610 additions and 3 deletions

View File

@ -112,6 +112,7 @@ void BlockModel::appendRaw(const QString& timestamp, const QString& rawJson)
const QJsonObject header = block.value(QStringLiteral("header")).toObject();
e.version = header.value(QStringLiteral("version")).toString();
e.blockId = header.value(QStringLiteral("id")).toString();
e.parentBlock = header.value(QStringLiteral("parent_block")).toString();
const QJsonValue slotV = header.value(QStringLiteral("slot"));
@ -156,6 +157,41 @@ void BlockModel::appendRaw(const QString& timestamp, const QString& rawJson)
emit countChanged();
}
QVariantMap BlockModel::findTransaction(const QString& txId) const
{
// Normalise for comparison: trim, drop an optional 0x prefix, lowercase.
auto normalize = [](const QString& s) {
QString t = s.trimmed();
if (t.startsWith(QStringLiteral("0x"), Qt::CaseInsensitive))
t = t.mid(2);
return t.toLower();
};
const QString needle = normalize(txId);
if (needle.isEmpty())
return QVariantMap{{"found", false}};
for (const Entry& e : m_entries) {
for (const QString& txJson : e.transactions) {
QJsonParseError err{};
const QJsonDocument doc = QJsonDocument::fromJson(txJson.toUtf8(), &err);
if (err.error != QJsonParseError::NoError || !doc.isObject())
continue;
const QString id = doc.object().value(QStringLiteral("id")).toString();
if (!id.isEmpty() && normalize(id) == needle) {
return QVariantMap{
{"found", true},
{"value", txJson},
{"blockId", e.blockId},
{"slot", e.slot},
{"timestamp", e.timestamp},
};
}
}
}
return QVariantMap{{"found", false}};
}
void BlockModel::clear()
{
if (m_entries.isEmpty())

View File

@ -3,6 +3,7 @@
#include <QAbstractListModel>
#include <QString>
#include <QStringList>
#include <QVariantMap>
#include <QVector>
// Structured model of recent blockchain blocks, fed by the backend's `newBlock`
@ -43,6 +44,15 @@ public:
Q_INVOKABLE void appendRaw(const QString& timestamp, const QString& rawJson);
Q_INVOKABLE void clear();
// Search the retained blocks for a transaction whose `id` matches `txId`
// (case-insensitive, `0x`-tolerant). The blocks fed by the node's newBlock
// subscription carry a per-transaction `id`; the node cannot look a mined
// transaction up by hash (its tx-by-hash store is mempool-only and pruned
// shortly after inclusion), so this in-memory scan is how a tx copied from
// the blocks view is resolved. Returns { found, value, blockId, slot,
// timestamp }; `value` is the prettified transaction JSON when found.
QVariantMap findTransaction(const QString& txId) const;
signals:
void countChanged();
@ -58,6 +68,7 @@ private:
QString proof;
QString voucherCm;
QString signature;
QString blockId;
int txCount = 0;
QStringList transactions;
QString rawJson;

View File

@ -219,6 +219,43 @@ QVariantMap BlockchainBackend::getCryptarchiaInfo()
BLOCKCHAIN_MODULE_NAME, QStringLiteral("get_cryptarchia_info"))));
}
QVariantMap BlockchainBackend::getBlock(QString headerIdHex)
{
if (!m_blockchainClient)
return result::toVariantMap(result::err(QStringLiteral("Module not initialized.")));
return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod(
BLOCKCHAIN_MODULE_NAME, QStringLiteral("get_block"), headerIdHex.trimmed())));
}
QVariantMap BlockchainBackend::getTransaction(QString txHashHex)
{
if (!m_blockchainClient)
return result::toVariantMap(result::err(QStringLiteral("Module not initialized.")));
return result::toVariantMap(result::toLogosResult(m_blockchainClient->invokeRemoteMethod(
BLOCKCHAIN_MODULE_NAME, QStringLiteral("get_transaction"), txHashHex.trimmed())));
}
QVariantMap BlockchainBackend::findTransactionInBlocks(QString txHashHex)
{
// Local, in-memory resolution against the blocks currently held by the
// model. The node's get_transaction only serves mempool (pending / very
// recently mined) transactions, so a tx copied from the blocks view — which
// is already mined — is looked up here instead. Returns the same shape as
// the remote calls: { success, value, ... } with block context on success.
const QVariantMap hit = m_blockModel->findTransaction(txHashHex);
QVariantMap out;
out.insert("success", hit.value("found").toBool());
out.insert("value", hit.value("value"));
out.insert("blockId", hit.value("blockId"));
out.insert("slot", hit.value("slot"));
out.insert("timestamp", hit.value("timestamp"));
if (!out.value("success").toBool())
out.insert("error", QStringLiteral("Not in loaded blocks."));
return out;
}
QVariantMap BlockchainBackend::getPeerId()
{
if (!m_blockchainClient)

View File

@ -46,6 +46,9 @@ public slots:
QVariantMap transferFunds(QString fromKeyHex, QString toKeyHex, QString amountStr) override;
QVariantMap claimLeaderRewards() override;
QVariantMap getCryptarchiaInfo() override;
QVariantMap getBlock(QString headerIdHex) override;
QVariantMap getTransaction(QString txHashHex) override;
QVariantMap findTransactionInBlocks(QString txHashHex) override;
QVariantMap getPeerId() override;
QVariantMap getClaimableVouchers() override;
QVariantMap generateConfig(QString outputPath, QStringList initialPeers, int netPort,

View File

@ -16,6 +16,9 @@ class BlockchainBackend
SLOT(QVariantMap transferFunds(QString fromKeyHex, QString toKeyHex, QString amountStr))
SLOT(QVariantMap claimLeaderRewards())
SLOT(QVariantMap getCryptarchiaInfo())
SLOT(QVariantMap getBlock(QString headerIdHex))
SLOT(QVariantMap getTransaction(QString txHashHex))
SLOT(QVariantMap findTransactionInBlocks(QString txHashHex))
SLOT(QVariantMap getPeerId())
SLOT(QVariantMap getClaimableVouchers())
SLOT(QVariantMap generateConfig(QString outputPath, QStringList initialPeers, int netPort, int blendPort, QString httpAddr, QString externalAddress, bool noPublicIpCheck, int deploymentMode, QString deploymentConfigPath, QString statePath))

View File

@ -333,6 +333,10 @@ Rectangle {
text: qsTr("Operations")
enabled: opPage.nodeRunning
}
LogosTabButton {
text: qsTr("Explorer")
enabled: opPage.nodeRunning
}
}
StackLayout {
@ -581,6 +585,62 @@ Rectangle {
}
}
}
// ---- Tab 2: Explorer (block / transaction lookup) ----
ExplorerView {
id: explorerView
nodeRunning: opPage.nodeRunning
// Auto-detect the id kind. The node can't fetch a mined
// transaction by hash (its tx store is mempool-only, pruned
// ~10 min after inclusion), so resolve a tx in this order:
// 1. loaded blocks the blocks view already holds each
// tx and its id, so a copied tx id resolves locally;
// 2. get_block the id is a block header id;
// 3. get_transaction a still-pending mempool tx.
onSearchRequested: function(id) {
if (!root.backend) return
// Every backend call is remoted through QtRO, so each
// must be resolved via logos.watch (even the local scan,
// whose search runs synchronously on the source side).
// Step 1: scan the loaded blocks for the tx by its id.
logos.watch(
root.backend.findTransactionInBlocks(id),
function(local) {
if (local.success) {
explorerView.setTransactionResult(id, local.value, local.slot, local.blockId)
return
}
// Step 2: block by header id.
logos.watch(
root.backend.getBlock(id),
function(blockResult) {
if (blockResult.success) {
explorerView.setBlockResult(id, blockResult.value)
return
}
// Step 3: pending transaction via the node.
logos.watch(
root.backend.getTransaction(id),
function(txResult) {
if (txResult.success)
explorerView.setTransactionResult(id, txResult.value)
else
explorerView.setNotFound(id)
},
function(error) { explorerView.setError(id, _d.errorText(error)) }
)
},
function(error) { explorerView.setError(id, _d.errorText(error)) }
)
},
function(error) { explorerView.setError(id, _d.errorText(error)) }
)
}
onCopyToClipboard: (text) => root.copyText(text)
}
}
// Sidebar nav entry used by the Operations tab. `pinnable` adds a

View File

@ -5,7 +5,9 @@ import Logos.Theme
import Logos.Controls
// A boxed, monospace, wrapping JSON/text block. Used for prettified payloads
// and raw fallbacks.
// and raw fallbacks. Rendered with a read-only TextEdit so the content can be
// selected with the mouse and copied (Cmd/Ctrl+C), in addition to the dedicated
// copy buttons elsewhere.
Rectangle {
id: root
@ -18,15 +20,23 @@ Rectangle {
border.color: Theme.palette.border
border.width: 1
LogosText {
TextEdit {
id: jsonText
anchors.left: parent.left
anchors.right: parent.right
anchors.top: parent.top
anchors.margins: Theme.spacing.small
text: root.json
readOnly: true
selectByMouse: true
persistentSelection: true
// Treat the payload as literal text; never interpret it as rich text.
textFormat: TextEdit.PlainText
color: Theme.palette.text
selectionColor: Theme.palette.overlayOrange
selectedTextColor: Theme.palette.text
font.pixelSize: Theme.typography.secondaryText
font.family: "monospace"
wrapMode: Text.WrapAnywhere
wrapMode: TextEdit.WrapAnywhere
}
}

View File

@ -0,0 +1,446 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Layouts
import Logos.Theme
import Logos.Controls
import "../controls"
// Block/transaction explorer. The user pastes a block header id or a
// transaction hash; the lookup is auto-detected (block first, then tx
// both are hex hashes and can't be told apart by shape) and the matching
// entity is rendered with every field copyable.
//
// Orchestration lives in BlockchainView: `searchRequested` triggers a
// get_block; on a miss it falls back to get_transaction. Results flow back
// through setBlockResult / setTransactionResult / setNotFound / setError.
ColumnLayout {
id: root
signal searchRequested(string id)
signal copyToClipboard(string text)
property bool nodeRunning: false
// ---- Result state (driven by BlockchainView) ----
// kind: "" (none) | "block" | "transaction" | "notfound" | "error"
property string kind: ""
property string queriedId: ""
property string rawJson: ""
property string errorText: ""
property bool busy: false
// Block context for a transaction resolved from a loaded block (empty when
// the tx came from the node's mempool lookup).
property string txSlot: ""
property string txBlockId: ""
// Parsed block fields (populated when kind === "block").
property var block: null
function _reset() {
root.kind = ""
root.rawJson = ""
root.errorText = ""
root.block = null
root.txSlot = ""
root.txBlockId = ""
}
// Called by BlockchainView when a get_block succeeds. `value` is the JSON
// string returned by the module (tolerant of a few nesting shapes, mirroring
// BlockModel::appendRaw).
function setBlockResult(id, value) {
root.busy = false
root._reset()
root.queriedId = id
root.kind = "block"
root.rawJson = value
root.block = parseBlock(value)
}
// `slot` / `blockId` are optional supplied when the tx was resolved from
// a loaded block, omitted for a pending mempool tx.
function setTransactionResult(id, value, slot, blockId) {
root.busy = false
root._reset()
root.queriedId = id
root.kind = "transaction"
root.rawJson = value
root.txSlot = slot !== undefined && slot !== null ? String(slot) : ""
root.txBlockId = blockId !== undefined && blockId !== null ? String(blockId) : ""
}
function setNotFound(id) {
root.busy = false
root._reset()
root.queriedId = id
root.kind = "notfound"
}
function setError(id, message) {
root.busy = false
root._reset()
root.queriedId = id
root.kind = "error"
root.errorText = message
}
// Tolerant block parser mirroring BlockModel::appendRaw:
// { "block": "<stringified block>" } | { "block": {...} } | { "header": ... }
// Returns a normalised object { header, slot, version, parentBlock, blockRoot,
// signature, proof, entropy, leaderKey, voucherCm, transactions:[jsonStr] } or null.
function parseBlock(s) {
var obj = null
try { obj = JSON.parse(s) } catch (e) { return null }
if (!obj || typeof obj !== "object") return null
var b = null
if (obj.block !== undefined) {
if (typeof obj.block === "string") {
try { b = JSON.parse(obj.block) } catch (e) { b = null }
} else if (typeof obj.block === "object") {
b = obj.block
}
} else if (obj.header !== undefined) {
b = obj
}
if (!b || typeof b !== "object") return null
var header = b.header || {}
var pol = header.proof_of_leadership || {}
var txs = []
var arr = b.transactions || []
for (var i = 0; i < arr.length; ++i) {
try { txs.push(JSON.stringify(arr[i])) }
catch (e) { txs.push(String(arr[i])) }
}
return {
slot: header.slot !== undefined ? String(header.slot) : "",
version: header.version !== undefined ? String(header.version) : "",
parentBlock: header.parent_block || "",
blockRoot: header.block_root || "",
signature: b.signature || "",
proof: pol.proof || "",
entropy: pol.entropy_contribution || "",
leaderKey: pol.leader_key || "",
voucherCm: pol.voucher_cm || "",
transactions: txs
}
}
function pretty(s) {
try { return JSON.stringify(JSON.parse(s), null, 2) } catch (e) { return s }
}
function doSearch() {
var id = idField.text.trim()
if (id.length === 0) return
root.busy = true
root.searchRequested(id)
}
spacing: Theme.spacing.large
// ---- Search bar ----
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: searchCol.implicitHeight + 2 * Theme.spacing.large
color: Theme.palette.backgroundTertiary
radius: Theme.spacing.radiusLarge
border.color: Theme.palette.border
border.width: 1
ColumnLayout {
id: searchCol
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacing.large
spacing: Theme.spacing.small
RowLayout {
Layout.fillWidth: true
LogosText {
text: qsTr("Explorer")
font.pixelSize: Theme.typography.secondaryText
font.bold: true
}
Item { Layout.fillWidth: true }
InfoButton {
Layout.alignment: Qt.AlignVCenter
text: qsTr("Paste a block header id or a transaction hash, then press Search. The lookup is auto-detected: it tries a block first, then a transaction.")
}
}
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosTextField {
id: idField
Layout.fillWidth: true
Layout.preferredHeight: 30
placeholderText: qsTr("Block id or transaction hash (hex)")
enabled: root.nodeRunning && !root.busy
// LogosTextField wraps a TextInput (no `accepted` signal of
// its own); connect to the inner input to search on Enter.
Connections {
target: idField.textInput
function onAccepted() { root.doSearch() }
}
}
LogosButton {
Layout.preferredWidth: 90
Layout.preferredHeight: 30
text: root.busy ? qsTr("…") : qsTr("Search")
enabled: root.nodeRunning && !root.busy && idField.text.trim().length > 0
onClicked: root.doSearch()
}
}
LogosText {
Layout.fillWidth: true
visible: !root.nodeRunning
text: qsTr("Start the node to look up blocks and transactions.")
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
wrapMode: Text.WordWrap
}
}
}
// ---- Status line (not found / error) ----
LogosText {
Layout.fillWidth: true
visible: root.kind === "notfound" || root.kind === "error"
text: root.kind === "notfound"
? qsTr("Nothing found for “%1”.\nBlocks are looked up by header id. Transactions resolve from the blocks currently loaded above, or from the node's mempool while still pending — mined transactions can't be fetched by hash, so open their block instead.").arg(root.queriedId)
: root.errorText
color: root.kind === "notfound" ? Theme.palette.textSecondary : Theme.palette.error
font.pixelSize: Theme.typography.secondaryText
wrapMode: Text.WordWrap
}
// ---- Result area ----
ScrollView {
id: resultScroll
Layout.fillWidth: true
Layout.fillHeight: true
visible: root.kind === "block" || root.kind === "transaction"
clip: true
ColumnLayout {
width: resultScroll.availableWidth
spacing: Theme.spacing.large
// ---- Block result ----
Rectangle {
Layout.fillWidth: true
visible: root.kind === "block"
Layout.preferredHeight: blockCol.implicitHeight + 2 * Theme.spacing.large
color: Theme.palette.backgroundTertiary
radius: Theme.spacing.radiusLarge
border.color: Theme.palette.border
border.width: 1
ColumnLayout {
id: blockCol
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacing.large
spacing: Theme.spacing.small
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("Block")
font.pixelSize: Theme.typography.secondaryText
font.bold: true
}
// slot / version badges
LogosText {
visible: root.block && root.block.slot.length > 0
text: qsTr("slot %1").arg(root.block ? root.block.slot : "")
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
Rectangle {
visible: root.block && root.block.version.length > 0
radius: Theme.spacing.radiusSmall
color: Theme.palette.backgroundSecondary
border.color: Theme.palette.border
border.width: 1
implicitWidth: verText.implicitWidth + 2 * Theme.spacing.small
implicitHeight: verText.implicitHeight + Theme.spacing.tiny
LogosText {
id: verText
anchors.centerIn: parent
text: root.block ? root.block.version : ""
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
}
Item { Layout.fillWidth: true }
LogosCopyButton {
ToolTip.visible: hovered
ToolTip.text: qsTr("Copy raw block JSON")
onCopyText: root.copyToClipboard(root.pretty(root.rawJson))
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.palette.borderSecondary
}
HashRow {
label: qsTr("Block id"); value: root.queriedId
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
label: qsTr("Parent block"); value: root.block ? root.block.parentBlock : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
label: qsTr("Block root"); value: root.block ? root.block.blockRoot : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
label: qsTr("Signature"); value: root.block ? root.block.signature : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
LogosText {
text: qsTr("Proof of leadership")
font.pixelSize: Theme.typography.secondaryText
font.bold: true
Layout.topMargin: Theme.spacing.small
}
HashRow {
label: qsTr("Leader key"); value: root.block ? root.block.leaderKey : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
label: qsTr("Entropy"); value: root.block ? root.block.entropy : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
label: qsTr("Proof"); value: root.block ? root.block.proof : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
label: qsTr("Voucher cm"); value: root.block ? root.block.voucherCm : ""
onCopyRequested: (t) => root.copyToClipboard(t)
}
LogosText {
text: qsTr("Transactions (%1)").arg(root.block ? root.block.transactions.length : 0)
font.pixelSize: Theme.typography.secondaryText
font.bold: true
Layout.topMargin: Theme.spacing.small
}
ColumnLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
Repeater {
model: root.block ? root.block.transactions : []
delegate: TransactionDelegate {
required property int index
required property string modelData
Layout.fillWidth: true
idx: index
json: modelData
onCopyToClipboard: (t) => root.copyToClipboard(t)
}
}
LogosText {
visible: root.block && root.block.transactions.length === 0
text: qsTr("No transactions in this block.")
color: Theme.palette.textSecondary
font.pixelSize: Theme.typography.secondaryText
}
}
}
}
// ---- Transaction result ----
Rectangle {
Layout.fillWidth: true
visible: root.kind === "transaction"
Layout.preferredHeight: txCol.implicitHeight + 2 * Theme.spacing.large
color: Theme.palette.backgroundTertiary
radius: Theme.spacing.radiusLarge
border.color: Theme.palette.border
border.width: 1
ColumnLayout {
id: txCol
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.spacing.large
spacing: Theme.spacing.small
RowLayout {
Layout.fillWidth: true
spacing: Theme.spacing.small
LogosText {
text: qsTr("Transaction")
font.pixelSize: Theme.typography.secondaryText
font.bold: true
}
LogosText {
visible: root.txSlot.length > 0
text: qsTr("in block · slot %1").arg(root.txSlot)
font.pixelSize: Theme.typography.secondaryText
color: Theme.palette.textSecondary
}
Item { Layout.fillWidth: true }
LogosCopyButton {
ToolTip.visible: hovered
ToolTip.text: qsTr("Copy raw transaction JSON")
onCopyText: root.copyToClipboard(root.pretty(root.rawJson))
}
}
Rectangle {
Layout.fillWidth: true
Layout.preferredHeight: 1
color: Theme.palette.borderSecondary
}
HashRow {
label: qsTr("Transaction id"); value: root.queriedId
onCopyRequested: (t) => root.copyToClipboard(t)
}
HashRow {
visible: root.txBlockId.length > 0
label: qsTr("Block id"); value: root.txBlockId
onCopyRequested: (t) => root.copyToClipboard(t)
}
TransactionDelegate {
Layout.fillWidth: true
idx: 0
json: root.rawJson
open: true
onCopyToClipboard: (t) => root.copyToClipboard(t)
}
}
}
}
}
Item {
Layout.fillHeight: true
visible: root.kind === "" || root.kind === "notfound" || root.kind === "error"
}
}

View File

@ -6,6 +6,7 @@ NodeInfoView 1.0 NodeInfoView.qml
AccountsView 1.0 AccountsView.qml
TransferView 1.0 TransferView.qml
LeaderRewardsView 1.0 LeaderRewardsView.qml
ExplorerView 1.0 ExplorerView.qml
GenerateConfigView 1.0 GenerateConfigView.qml
ConfigChoiceView 1.0 ConfigChoiceView.qml
SetConfigPathView 1.0 SetConfigPathView.qml