diff --git a/src/Makefile.qt.include b/src/Makefile.qt.include index 71cd30cc324..a60075b5a64 100644 --- a/src/Makefile.qt.include +++ b/src/Makefile.qt.include @@ -108,6 +108,8 @@ QT_MOC_CPP = \ qt/moc_coincontroldialog.cpp \ qt/moc_coincontroltreewidget.cpp \ qt/moc_csvmodelwriter.cpp \ + qt/moc_dashb0rd.cpp \ + qt/moc_dashb0rdpage.cpp \ qt/moc_editaddressdialog.cpp \ qt/moc_guiutil.cpp \ qt/moc_intro.cpp \ @@ -131,6 +133,7 @@ QT_MOC_CPP = \ qt/moc_sendcoinsdialog.cpp \ qt/moc_sendcoinsentry.cpp \ qt/moc_signverifymessagedialog.cpp \ + qt/moc_sparklinewidget.cpp \ qt/moc_splashscreen.cpp \ qt/moc_trafficgraphwidget.cpp \ qt/moc_transactiondesc.cpp \ @@ -175,6 +178,8 @@ BITCOIN_QT_H = \ qt/coincontroldialog.h \ qt/coincontroltreewidget.h \ qt/csvmodelwriter.h \ + qt/dashb0rd.h \ + qt/dashb0rdpage.h \ qt/editaddressdialog.h \ qt/guiconstants.h \ qt/guiutil.h \ @@ -201,6 +206,7 @@ BITCOIN_QT_H = \ qt/sendcoinsdialog.h \ qt/sendcoinsentry.h \ qt/signverifymessagedialog.h \ + qt/sparklinewidget.h \ qt/splashscreen.h \ qt/trafficgraphwidget.h \ qt/transactiondesc.h \ @@ -286,6 +292,8 @@ BITCOIN_QT_BASE_CPP = \ qt/bitcoinunits.cpp \ qt/clientmodel.cpp \ qt/csvmodelwriter.cpp \ + qt/dashb0rd.cpp \ + qt/dashb0rdpage.cpp \ qt/guiutil.cpp \ qt/intro.cpp \ qt/modaloverlay.cpp \ @@ -298,6 +306,7 @@ BITCOIN_QT_BASE_CPP = \ qt/qvalidatedlineedit.cpp \ qt/qvaluecombobox.cpp \ qt/rpcconsole.cpp \ + qt/sparklinewidget.cpp \ qt/splashscreen.cpp \ qt/trafficgraphwidget.cpp \ qt/utilitydialog.cpp \ diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 36f3948acaa..e9d49113d34 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -93,6 +93,7 @@ BitcoinGUI::BitcoinGUI(const PlatformStyle *_platformStyle, const NetworkStyle * appMenuBar(0), overviewAction(0), historyAction(0), + dashb0rdAction(0), quitAction(0), sendCoinsAction(0), sendCoinsMenuAction(0), @@ -327,6 +328,13 @@ void BitcoinGUI::createActions() historyAction->setShortcut(QKeySequence(Qt::ALT + Qt::Key_4)); tabGroup->addAction(historyAction); + dashb0rdAction = new QAction(platformStyle->SingleColorIcon(":/icons/about"), tr("&Dashb0rd"), this); + dashb0rdAction->setStatusTip(tr("View dashboard metrics")); + dashb0rdAction->setToolTip(dashb0rdAction->statusTip()); + dashb0rdAction->setCheckable(true); + dashb0rdAction->setShortcut(QKeySequence(Qt::ALT + Qt::Key_5)); + tabGroup->addAction(dashb0rdAction); + #ifdef ENABLE_WALLET // These showNormalIfMinimized are needed because Send Coins and Receive Coins // can be triggered from the tray menu, and need to show the GUI to be useful. @@ -342,6 +350,8 @@ void BitcoinGUI::createActions() connect(receiveCoinsMenuAction, SIGNAL(triggered()), this, SLOT(gotoReceiveCoinsPage())); connect(historyAction, SIGNAL(triggered()), this, SLOT(showNormalIfMinimized())); connect(historyAction, SIGNAL(triggered()), this, SLOT(gotoHistoryPage())); + connect(dashb0rdAction, SIGNAL(triggered()), this, SLOT(showNormalIfMinimized())); + connect(dashb0rdAction, SIGNAL(triggered()), this, SLOT(gotoDashb0rdPage())); #endif // ENABLE_WALLET quitAction = new QAction(platformStyle->TextColorIcon(":/icons/quit"), tr("E&xit"), this); @@ -484,6 +494,7 @@ void BitcoinGUI::createToolBars() toolbar->addAction(sendCoinsAction); toolbar->addAction(receiveCoinsAction); toolbar->addAction(historyAction); + toolbar->addAction(dashb0rdAction); overviewAction->setChecked(true); } } @@ -714,6 +725,12 @@ void BitcoinGUI::gotoHistoryPage() if (walletFrame) walletFrame->gotoHistoryPage(); } +void BitcoinGUI::gotoDashb0rdPage() +{ + dashb0rdAction->setChecked(true); + if (walletFrame) walletFrame->gotoDashb0rdPage(); +} + void BitcoinGUI::gotoReceiveCoinsPage() { receiveCoinsAction->setChecked(true); diff --git a/src/qt/bitcoingui.h b/src/qt/bitcoingui.h index 2a3c797a40f..8b9d04a6a08 100644 --- a/src/qt/bitcoingui.h +++ b/src/qt/bitcoingui.h @@ -95,6 +95,7 @@ class BitcoinGUI : public QMainWindow QMenuBar *appMenuBar; QAction *overviewAction; QAction *historyAction; + QAction *dashb0rdAction; QAction *quitAction; QAction *sendCoinsAction; QAction *sendCoinsMenuAction; @@ -200,6 +201,8 @@ private Q_SLOTS: void gotoOverviewPage(); /** Switch to history (transactions) page */ void gotoHistoryPage(); + /** Switch to dashboard page */ + void gotoDashb0rdPage(); /** Switch to receive coins page */ void gotoReceiveCoinsPage(); /** Switch to send coins page */ diff --git a/src/qt/dashb0rd.cpp b/src/qt/dashb0rd.cpp new file mode 100644 index 00000000000..92dc1ef9ad8 --- /dev/null +++ b/src/qt/dashb0rd.cpp @@ -0,0 +1,41 @@ +// Copyright (c) 2011-2016 The Bitcoin Core developers +// Copyright (c) 2021-2026 The Dogecoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include "dashb0rd.h" + +#include "dashb0rdpage.h" + +#include + +Dashb0rd::Dashb0rd(const PlatformStyle* platformStyle, QWidget* parent) + : QWidget(parent), + m_platformStyle(platformStyle), + m_page(nullptr) +{ + // Embed the dashboard page directly so this wrapper can forward model updates. + QVBoxLayout* root = new QVBoxLayout(this); + root->setContentsMargins(0, 0, 0, 0); + root->setSpacing(0); + + // Constructor order for Dashb0rdPage is (platformStyle, parent). + m_page = new Dashb0rdPage(m_platformStyle, this); + root->addWidget(m_page); +} + +Dashb0rd::~Dashb0rd() +{ +} + +void Dashb0rd::setClientModel(ClientModel* model) +{ + // Forward the shared client model to the underlying dashboard page. + if (m_page) m_page->setClientModel(model); +} + +void Dashb0rd::setWalletModel(WalletModel* model) +{ + // Forward the wallet model so page-level wallet features can use it. + if (m_page) m_page->setWalletModel(model); +} diff --git a/src/qt/dashb0rd.h b/src/qt/dashb0rd.h new file mode 100644 index 00000000000..4fe80c53804 --- /dev/null +++ b/src/qt/dashb0rd.h @@ -0,0 +1,32 @@ +// Copyright (c) 2011-2016 The Bitcoin Core developers +// Copyright (c) 2021-2026 The Dogecoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_DASHB0RD_H +#define BITCOIN_QT_DASHB0RD_H + +#include + +class ClientModel; +class WalletModel; +class PlatformStyle; +class Dashb0rdPage; + +class Dashb0rd : public QWidget +{ + Q_OBJECT + +public: + explicit Dashb0rd(const PlatformStyle* platformStyle, QWidget* parent = nullptr); + ~Dashb0rd(); + + void setClientModel(ClientModel* model); + void setWalletModel(WalletModel* model); + +private: + const PlatformStyle* m_platformStyle; + Dashb0rdPage* m_page; +}; + +#endif // BITCOIN_QT_DASHB0RD_H diff --git a/src/qt/dashb0rdpage.cpp b/src/qt/dashb0rdpage.cpp new file mode 100644 index 00000000000..cf435efb1dd --- /dev/null +++ b/src/qt/dashb0rdpage.cpp @@ -0,0 +1,1006 @@ +// Copyright (c) 2011-2016 The Bitcoin Core developers +// Copyright (c) 2021-2026 The Dogecoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#if defined(HAVE_CONFIG_H) +#include "config/bitcoin-config.h" +#endif + +#include "dashb0rdpage.h" + +#include "clientmodel.h" +#include "guiutil.h" +#include "platformstyle.h" +#include "sparklinewidget.h" + +#include "rpc/client.h" +#include "rpc/server.h" +#include "util.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace { +static const int kPollIntervalMs = 1000; +static const int kMaxSparkPoints = 120; +static const int kMetricGridColumns = 4; +// Slightly wider visual separation between metric tiles. +static const int kMetricGridSpacing = 20; +static const int kDefaultStatsWindowBlocks = 100; +static const char* kMetricMimeType = "application/x-dashb0rd-metric-index"; +static const char* kMetricDefinitionProperty = "metricDefinition"; +static const int kMetricBoxMinWidth = 280; +static const int kMetricBoxWidthChars = 38; +static const int kSparklineMinHeight = 40; + +static QLabel* MakeValueLabel() +{ + QLabel* l = new QLabel(QObject::tr("n/a")); + l->setAlignment(Qt::AlignCenter); + l->setTextInteractionFlags(Qt::TextSelectableByMouse); + QFont f = l->font(); + f.setPointSize(f.pointSize() + 2); + l->setFont(f); + return l; +} + +static int64_t GetInt64(const UniValue& obj, const char* key) +{ + // Missing/non-numeric fields are treated as 0 to keep UI refresh resilient. + const UniValue& v = find_value(obj, key); + return v.isNum() ? v.get_int64() : 0; +} + +static double GetDouble(const UniValue& obj, const char* key) +{ + // Missing/non-numeric fields are treated as 0.0 to avoid UI exceptions. + const UniValue& v = find_value(obj, key); + return v.isNum() ? v.get_real() : 0.0; +} + +static QString GetString(const UniValue& obj, const char* key) +{ + // Missing/non-string fields become empty text in the UI. + const UniValue& v = find_value(obj, key); + return v.isStr() ? QString::fromStdString(v.get_str()) : QString(); +} + +static QString TooltipValueKindForLabel(const QString& label) +{ + if (label == QObject::tr("Chain Tip Time")) return "epoch_time"; + if (label == QObject::tr("Bits (hex)")) return "bits_hex"; + if (label == QObject::tr("Mempool Bytes") || label == QObject::tr("Bytes")) return "bytes"; + if (label == QObject::tr("Volume (DOGE)") || label == QObject::tr("Median Fee/Block") || label == QObject::tr("Avg Fee/Block")) return "doge"; + if (label == QObject::tr("TPS")) return "tps"; + if (label == QObject::tr("Uptime")) return "duration_sec"; + if (label == QObject::tr("Difficulty")) return "difficulty"; + return "count"; +} + +static QString MetricDefinitionForLabel(const QString& label) +{ + if (label == QObject::tr("Block Height")) return QObject::tr("Current blockchain height."); + if (label == QObject::tr("Difficulty")) return QObject::tr("Network mining difficulty."); + if (label == QObject::tr("Chain Tip Time")) return QObject::tr("Timestamp of the most recent block (ISO-8601)."); + if (label == QObject::tr("Bits (hex)")) return QObject::tr("Compact difficulty target in hexadecimal format."); + if (label == QObject::tr("Mempool TX")) return QObject::tr("Number of transactions in the mempool."); + if (label == QObject::tr("Mempool Bytes")) return QObject::tr("Total mempool memory usage in bytes."); + if (label == QObject::tr("P2PKH Count")) return QObject::tr("Count of Pay-to-PubKey-Hash outputs in mempool."); + if (label == QObject::tr("P2SH Count")) return QObject::tr("Count of Pay-to-Script-Hash outputs in mempool."); + if (label == QObject::tr("Multisig Count")) return QObject::tr("Count of multisig outputs in mempool."); + if (label == QObject::tr("OP_RETURN Count")) return QObject::tr("Count of OP_RETURN outputs in mempool."); + if (label == QObject::tr("Nonstandard Count")) return QObject::tr("Count of nonstandard outputs in mempool."); + if (label == QObject::tr("Total Outputs")) return QObject::tr("Total outputs across all mempool transactions."); + if (label == QObject::tr("Transactions")) return QObject::tr("Total transactions across analyzed blocks."); + if (label == QObject::tr("TPS")) return QObject::tr("Estimated transactions per second over analyzed blocks."); + if (label == QObject::tr("Volume (DOGE)")) return QObject::tr("Sum of output values in analyzed blocks."); + if (label == QObject::tr("Outputs")) return QObject::tr("Total transaction outputs in analyzed blocks."); + if (label == QObject::tr("Bytes")) return QObject::tr("Total serialized block bytes in analyzed window."); + if (label == QObject::tr("Median Fee/Block")) return QObject::tr("Median miner fee per block in analyzed window."); + if (label == QObject::tr("Avg Fee/Block")) return QObject::tr("Average miner fee per block in analyzed window."); + if (label == QObject::tr("Uptime")) return QObject::tr("Node uptime in seconds since startup."); + return QString(); +} + +static int MetricBoxMaxWidthPx(const QWidget* widget) +{ + if (!widget) return kMetricBoxMinWidth; + const int scaledWidth = widget->fontMetrics().averageCharWidth() * kMetricBoxWidthChars; + return std::max(kMetricBoxMinWidth, scaledWidth); +} + +static QString FormatValueForKind(const QString& kind, double value) +{ + if (kind == "count") return QString::number(static_cast(value)); + if (kind == "bytes") return QString("%1 B").arg(QString::number(static_cast(value))); + if (kind == "doge") return QString("%1 DOGE").arg(QString::number(value, 'f', 8)); + if (kind == "tps") return QString("%1 tx/s").arg(QString::number(value, 'f', 3)); + if (kind == "epoch_time") { + const qint64 epoch = value < 0 ? 0 : static_cast(value); + return QDateTime::fromTime_t(static_cast(epoch)).toString(Qt::ISODate); + } + if (kind == "bits_hex") return QString("0x%1").arg(static_cast(value), 0, 16); + if (kind == "duration_sec") return QString("%1 s").arg(QString::number(static_cast(value))); + if (kind == "difficulty") return QString::number(value, 'f', 2); + return QString::number(value, 'g', 12); +} + +static QDateTime DateTimeFromEpochCompat(qint64 secs) +{ + if (secs < 0) secs = 0; + if (secs > std::numeric_limits::max()) secs = std::numeric_limits::max(); + return QDateTime::fromTime_t(static_cast(secs)); +} + +static bool DecodeContextToUniValue(const QString& txid, const QString& blockHash, UniValue& out, QString& errorMessage) +{ + try { + JSONRPCRequest req; + req.fHelp = false; + req.params = UniValue(UniValue::VARR); + + if (!txid.isEmpty() && !blockHash.isEmpty()) { + req.strMethod = "getblock"; + req.params.push_back(UniValue(blockHash.toStdString())); + req.params.push_back(UniValue(2)); + const UniValue blockResult = tableRPC.execute(req); + const UniValue& txList = find_value(blockResult, "tx"); + if (!txList.isNull() && txList.isArray()) { + const std::vector& txValues = txList.getValues(); + const std::string wantedTxid = txid.toStdString(); + for (const UniValue& txObj : txValues) { + if (!txObj.isObject()) continue; + const UniValue& txidValue = find_value(txObj, "txid"); + if (txidValue.isStr() && txidValue.get_str() == wantedTxid) { + out = txObj; + return true; + } + } + } + errorMessage = QObject::tr("Transaction %1 not found in block %2.").arg(txid).arg(blockHash); + return false; + } else if (!txid.isEmpty()) { + req.strMethod = "getrawtransaction"; + req.params.push_back(UniValue(txid.toStdString())); + req.params.push_back(UniValue(true)); + } else if (!blockHash.isEmpty()) { + req.strMethod = "getblock"; + req.params.push_back(UniValue(blockHash.toStdString())); + req.params.push_back(UniValue(true)); + } else { + errorMessage = QObject::tr("No transaction or block context available for this point."); + return false; + } + out = tableRPC.execute(req); + return true; + } catch (const std::exception& e) { + errorMessage = QObject::tr("Unable to decode context: %1").arg(QString::fromStdString(e.what())); + } catch (...) { + errorMessage = QObject::tr("Unable to decode context."); + } + return false; +} + +static void AddUniValueNode(QTreeWidgetItem* parent, const QString& key, const UniValue& value) +{ + QTreeWidgetItem* item = new QTreeWidgetItem(parent); + item->setText(0, key); + if (value.isObject()) { + item->setText(1, "{...}"); + const std::vector& keys = value.getKeys(); + const std::vector& values = value.getValues(); + for (size_t i = 0; i < keys.size() && i < values.size(); ++i) { + AddUniValueNode(item, QString::fromStdString(keys[i]), values[i]); + } + return; + } + if (value.isArray()) { + item->setText(1, QString("[%1]").arg(value.size())); + const std::vector& values = value.getValues(); + for (size_t i = 0; i < values.size(); ++i) { + AddUniValueNode(item, QString("[%1]").arg(i), values[i]); + } + return; + } + item->setText(1, QString::fromStdString(value.write())); +} + +static void PopulateDecodedTree(QTreeWidget* tree, const bool decodedOk, const UniValue& decoded, const QString& decodeError) +{ + tree->clear(); + if (decodedOk) { + if (decoded.isObject()) { + const std::vector& keys = decoded.getKeys(); + const std::vector& values = decoded.getValues(); + for (size_t i = 0; i < keys.size() && i < values.size(); ++i) { + AddUniValueNode(tree->invisibleRootItem(), QString::fromStdString(keys[i]), values[i]); + } + } else { + AddUniValueNode(tree->invisibleRootItem(), QObject::tr("context"), decoded); + } + } else { + QTreeWidgetItem* err = new QTreeWidgetItem(tree->invisibleRootItem()); + err->setText(0, QObject::tr("error")); + err->setText(1, decodeError); + } + tree->expandToDepth(1); +} + +static QString UnquoteJsonString(const QString& valueIn) +{ + QString value = valueIn.trimmed(); + if (value.size() >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.mid(1, value.size() - 2); + } + return value; +} + +static QTreeWidgetItem* FindChildByKey(QTreeWidgetItem* parent, const QString& key) +{ + if (!parent) return nullptr; + for (int i = 0; i < parent->childCount(); ++i) { + QTreeWidgetItem* child = parent->child(i); + if (child && child->text(0) == key) return child; + } + return nullptr; +} + +static bool HighlightScriptAsmForType(QTreeWidget* tree, const QString& scriptType) +{ + if (!tree || scriptType.isEmpty()) return false; + + QList nodeStack; + for (int i = 0; i < tree->topLevelItemCount(); ++i) { + nodeStack.push_back(tree->topLevelItem(i)); + } + + while (!nodeStack.isEmpty()) { + QTreeWidgetItem* scriptPubKeyNode = nodeStack.takeLast(); + if (!scriptPubKeyNode) continue; + for (int i = 0; i < scriptPubKeyNode->childCount(); ++i) { + nodeStack.push_back(scriptPubKeyNode->child(i)); + } + if (scriptPubKeyNode->text(0) != "scriptPubKey") continue; + + QTreeWidgetItem* typeNode = FindChildByKey(scriptPubKeyNode, "type"); + if (!typeNode) continue; + const QString typeValue = UnquoteJsonString(typeNode->text(1)); + if (typeValue != scriptType) continue; + + QTreeWidgetItem* asmNode = FindChildByKey(scriptPubKeyNode, "asm"); + if (!asmNode) continue; + + for (QTreeWidgetItem* p = asmNode; p; p = p->parent()) { + p->setExpanded(true); + } + tree->setCurrentItem(asmNode); + asmNode->setSelected(true); + tree->scrollToItem(asmNode); + return true; + } + return false; +} + +static bool IsLikelyTxid(QString value) +{ + value = value.trimmed(); + if (value.size() >= 2 && value.startsWith('"') && value.endsWith('"')) { + value = value.mid(1, value.size() - 2); + } + if (value.size() != 64) return false; + for (int i = 0; i < value.size(); ++i) { + if (!std::isxdigit(static_cast(value.at(i).toLatin1()))) return false; + } + return true; +} + +} // namespace + +Dashb0rdPage::Dashb0rdPage(const PlatformStyle* platformStyle, QWidget* parent) + : QWidget(parent) + , m_clientModel(nullptr) + , m_walletModel(nullptr) + , m_platformStyle(platformStyle) + , m_pollTimer(new QTimer(this)) + , m_lastUpdated(nullptr) + , m_metricsContainer(nullptr) + , m_metricGrid(nullptr) + , m_dragSourceBox(nullptr) + , m_prevMempoolTxCount(-1) + , m_statsWindowBlocks(kDefaultStatsWindowBlocks) + , m_statsWindowSpinBox(nullptr) + , m_chainTipHeightValue(nullptr) + , m_chainTipDifficultyValue(nullptr) + , m_chainTipTimeValue(nullptr) + , m_chainTipBitsValue(nullptr) + , m_chainTipHeightSpark(nullptr) + , m_chainTipDifficultySpark(nullptr) + , m_chainTipTimeSpark(nullptr) + , m_chainTipBitsSpark(nullptr) + , m_mempoolTxCountValue(nullptr) + , m_mempoolTotalBytesValue(nullptr) + , m_mempoolP2pkhValue(nullptr) + , m_mempoolP2shValue(nullptr) + , m_mempoolMultisigValue(nullptr) + , m_mempoolOpReturnValue(nullptr) + , m_mempoolNonstandardValue(nullptr) + , m_mempoolOutputCountValue(nullptr) + , m_mempoolTxCountSpark(nullptr) + , m_mempoolTotalBytesSpark(nullptr) + , m_mempoolP2pkhSpark(nullptr) + , m_mempoolP2shSpark(nullptr) + , m_mempoolMultisigSpark(nullptr) + , m_mempoolOpReturnSpark(nullptr) + , m_mempoolNonstandardSpark(nullptr) + , m_mempoolOutputCountSpark(nullptr) + , m_statsTransactionsValue(nullptr) + , m_statsTpsValue(nullptr) + , m_statsVolumeValue(nullptr) + , m_statsOutputsValue(nullptr) + , m_statsBytesValue(nullptr) + , m_statsMedianFeeValue(nullptr) + , m_statsAvgFeeValue(nullptr) + , m_statsTransactionsSpark(nullptr) + , m_statsTpsSpark(nullptr) + , m_statsVolumeSpark(nullptr) + , m_statsOutputsSpark(nullptr) + , m_statsBytesSpark(nullptr) + , m_statsMedianFeeSpark(nullptr) + , m_statsAvgFeeSpark(nullptr) + , m_uptimeValue(nullptr) + , m_uptimeSpark(nullptr) +{ + QScrollArea* scrollArea = new QScrollArea(this); + scrollArea->setWidgetResizable(true); + scrollArea->setFrameShape(QFrame::NoFrame); + + QWidget* scrollContent = new QWidget(); + QVBoxLayout* outer = new QVBoxLayout(scrollContent); + outer->setContentsMargins(18, 14, 18, 14); + outer->setSpacing(12); + + QLabel* title = new QLabel(tr("Dashb0rd - All Metrics")); + QFont tf = title->font(); + tf.setPointSize(tf.pointSize() + 8); + tf.setBold(true); + title->setFont(tf); + outer->addWidget(title); + + QHBoxLayout* windowLayout = new QHBoxLayout(); + QLabel* windowLabel = new QLabel(tr("Rolling Window Blocks:"), this); + m_statsWindowSpinBox = new QSpinBox(this); + m_statsWindowSpinBox->setRange(1, 5000); + m_statsWindowSpinBox->setValue(m_statsWindowBlocks); + windowLayout->addWidget(windowLabel); + windowLayout->addWidget(m_statsWindowSpinBox); + windowLayout->addStretch(); + outer->addLayout(windowLayout); + + m_lastUpdated = new QLabel(tr("Last updated: n/a")); + m_lastUpdated->setTextInteractionFlags(Qt::TextSelectableByMouse); + outer->addWidget(m_lastUpdated); + + m_metricsContainer = scrollContent; + m_metricsContainer->setAcceptDrops(true); + m_metricsContainer->installEventFilter(this); + + m_metricGrid = new QGridLayout(); + m_metricGrid->setHorizontalSpacing(kMetricGridSpacing); + m_metricGrid->setVerticalSpacing(kMetricGridSpacing); + m_metricGrid->setAlignment(Qt::AlignTop | Qt::AlignLeft); + + int row = 0; + int col = 0; + + auto addMetric = [&](const QString& label, QLabel*& value, SparklineWidget*& spark) { + QWidget* box = createMetricBox(label, value, spark); + box->setProperty("metricLabel", label); + box->setAcceptDrops(true); + box->installEventFilter(this); + box->setCursor(Qt::OpenHandCursor); + spark->setProperty("tooltipValueKind", TooltipValueKindForLabel(label)); + spark->setHoverTextProvider([this, spark](int index, double sampleValue) { + return formatSparklineHoverText(spark, index, sampleValue); + }); + spark->setDoubleClickHandler([this, spark](int index, double sampleValue) { + showSparklineDetailsDialog(spark, index, sampleValue); + }); + m_metricBoxes.push_back(box); + m_metricGrid->addWidget(box, row, col, Qt::AlignLeft); + if (++col >= kMetricGridColumns) { + col = 0; + ++row; + } + }; + + addMetric(tr("Block Height"), m_chainTipHeightValue, m_chainTipHeightSpark); + addMetric(tr("Difficulty"), m_chainTipDifficultyValue, m_chainTipDifficultySpark); + addMetric(tr("Chain Tip Time"), m_chainTipTimeValue, m_chainTipTimeSpark); + addMetric(tr("Bits (hex)"), m_chainTipBitsValue, m_chainTipBitsSpark); + + addMetric(tr("Mempool TX"), m_mempoolTxCountValue, m_mempoolTxCountSpark); + addMetric(tr("Mempool Bytes"), m_mempoolTotalBytesValue, m_mempoolTotalBytesSpark); + addMetric(tr("P2PKH Count"), m_mempoolP2pkhValue, m_mempoolP2pkhSpark); + addMetric(tr("P2SH Count"), m_mempoolP2shValue, m_mempoolP2shSpark); + addMetric(tr("Multisig Count"), m_mempoolMultisigValue, m_mempoolMultisigSpark); + addMetric(tr("OP_RETURN Count"), m_mempoolOpReturnValue, m_mempoolOpReturnSpark); + addMetric(tr("Nonstandard Count"), m_mempoolNonstandardValue, m_mempoolNonstandardSpark); + addMetric(tr("Total Outputs"), m_mempoolOutputCountValue, m_mempoolOutputCountSpark); + + addMetric(tr("Transactions"), m_statsTransactionsValue, m_statsTransactionsSpark); + addMetric(tr("TPS"), m_statsTpsValue, m_statsTpsSpark); + addMetric(tr("Volume (DOGE)"), m_statsVolumeValue, m_statsVolumeSpark); + addMetric(tr("Outputs"), m_statsOutputsValue, m_statsOutputsSpark); + addMetric(tr("Bytes"), m_statsBytesValue, m_statsBytesSpark); + addMetric(tr("Median Fee/Block"), m_statsMedianFeeValue, m_statsMedianFeeSpark); + addMetric(tr("Avg Fee/Block"), m_statsAvgFeeValue, m_statsAvgFeeSpark); + + addMetric(tr("Uptime"), m_uptimeValue, m_uptimeSpark); + + outer->addLayout(m_metricGrid, 1); + relayoutMetricBoxes(); + + scrollArea->setWidget(scrollContent); + + QVBoxLayout* mainLayout = new QVBoxLayout(this); + mainLayout->setContentsMargins(0, 0, 0, 0); + mainLayout->addWidget(scrollArea); + + connect(m_pollTimer, SIGNAL(timeout()), this, SLOT(pollStats())); + connect(m_statsWindowSpinBox, SIGNAL(valueChanged(int)), this, SLOT(setStatsWindow(int))); + m_pollTimer->setInterval(kPollIntervalMs); + m_pollTimer->start(); + + pollStats(); +} + +Dashb0rdPage::~Dashb0rdPage() = default; + +void Dashb0rdPage::setClientModel(ClientModel* model) +{ + m_clientModel = model; + pollStats(); +} + +void Dashb0rdPage::setWalletModel(WalletModel* model) +{ + m_walletModel = model; + (void)m_walletModel; + pollStats(); +} + +void Dashb0rdPage::setStatsWindow(int blocks) +{ + m_statsWindowBlocks = std::max(1, blocks); + pollStats(); +} + +QWidget* Dashb0rdPage::createMetricBox(const QString& label, QLabel*& valueLabel, SparklineWidget*& spark) +{ + QFrame* box = new QFrame(this); + box->setFrameStyle(QFrame::StyledPanel | QFrame::Raised); + const int boxWidth = MetricBoxMaxWidthPx(this); + box->setMinimumWidth(boxWidth); + box->setMaximumWidth(boxWidth); + box->setSizePolicy(QSizePolicy::Fixed, QSizePolicy::Expanding); + + QPalette pal = box->palette(); + pal.setColor(QPalette::Window, palette().color(QPalette::AlternateBase)); + box->setAutoFillBackground(true); + box->setPalette(pal); + + QVBoxLayout* layout = new QVBoxLayout(box); + layout->setContentsMargins(8, 8, 8, 8); + layout->setSpacing(6); + + QLabel* title = new QLabel(label, box); + QFont titleFont = title->font(); + titleFont.setBold(true); + title->setFont(titleFont); + title->setAlignment(Qt::AlignCenter); + title->setToolTip(MetricDefinitionForLabel(label)); + title->setProperty(kMetricDefinitionProperty, MetricDefinitionForLabel(label)); + title->setMouseTracking(true); + title->setAttribute(Qt::WA_Hover, true); + title->installEventFilter(this); + + valueLabel = MakeValueLabel(); + spark = new SparklineWidget(box); + spark->setMinimumHeight(kSparklineMinHeight); + + layout->addWidget(title); + layout->addWidget(valueLabel); + layout->addWidget(spark); + layout->setStretch(2, 1); + + return box; +} + +void Dashb0rdPage::relayoutMetricBoxes() +{ + // Rebuild grid positions from the current ordering/visibility state. + while (QLayoutItem* item = m_metricGrid->takeAt(0)) { + delete item; + } + + int visibleCount = 0; + for (QWidget* box : m_metricBoxes) { + if (box && !box->isHidden()) { + ++visibleCount; + } + } + int availableWidth = 0; + if (m_metricsContainer) { + QWidget* parentWidget = m_metricsContainer->parentWidget(); + // Prefer parent width (scroll viewport/container), fallback to content width. + availableWidth = parentWidget ? parentWidget->width() : m_metricsContainer->width(); + if (m_metricsContainer->layout()) { + const QMargins margins = m_metricsContainer->layout()->contentsMargins(); + availableWidth -= (margins.left() + margins.right()); + } + } + if (availableWidth <= 0) { + availableWidth = this->width(); + } + int metricBoxWidth = MetricBoxMaxWidthPx(m_metricsContainer); + if (!m_metricBoxes.isEmpty() && m_metricBoxes[0]) { + metricBoxWidth = std::max(kMetricBoxMinWidth, m_metricBoxes[0]->maximumWidth()); + } + int dynamicColumns = 1; + if (availableWidth > 0) { + int columnsByWidth = (availableWidth + kMetricGridSpacing) / (metricBoxWidth + kMetricGridSpacing); + dynamicColumns = std::max(1, columnsByWidth); + } + const int columns = std::max(1, std::min(dynamicColumns, visibleCount)); + + int visibleIndex = 0; + for (QWidget* box : m_metricBoxes) { + if (!box || box->isHidden()) { + continue; + } + const int row = visibleIndex / columns; + const int col = visibleIndex % columns; + m_metricGrid->addWidget(box, row, col, Qt::AlignLeft); + ++visibleIndex; + } +} + +void Dashb0rdPage::resizeEvent(QResizeEvent* event) +{ + QWidget::resizeEvent(event); + relayoutMetricBoxes(); +} + +void Dashb0rdPage::showEvent(QShowEvent* event) +{ + QWidget::showEvent(event); + if (m_pollTimer) { + m_pollTimer->start(); + } + relayoutMetricBoxes(); + pollStats(); +} + +void Dashb0rdPage::hideEvent(QHideEvent* event) +{ + QWidget::hideEvent(event); + if (m_pollTimer && m_pollTimer->isActive()) { + m_pollTimer->stop(); + } +} + +bool Dashb0rdPage::eventFilter(QObject* watched, QEvent* event) +{ + QWidget* watchedWidget = qobject_cast(watched); + const bool isMetricBox = watchedWidget && m_metricBoxes.contains(watchedWidget); + const bool isMetricsContainer = (watched == m_metricsContainer); + const bool isMetricTitle = watchedWidget && watchedWidget->property(kMetricDefinitionProperty).isValid(); + + if (isMetricTitle && event->type() == QEvent::ToolTip) { + QHelpEvent* helpEvent = static_cast(event); + QToolTip::showText(helpEvent->globalPos(), watchedWidget->property(kMetricDefinitionProperty).toString(), watchedWidget); + return true; + } + + if ((isMetricBox || isMetricsContainer) && event->type() == QEvent::MouseButtonPress) { + QMouseEvent* mouseEvent = static_cast(event); + if (mouseEvent->button() == Qt::LeftButton) { + if (isMetricBox) { + // Record drag origin for drag threshold + source index lookup. + m_dragStartPos = mouseEvent->pos(); + m_dragSourceBox = watchedWidget; + } + } else if (mouseEvent->button() == Qt::RightButton) { + // Right-click opens metric visibility toggles. + QMenu menu(this); + for (int i = 0; i < m_metricBoxes.size(); ++i) { + QWidget* box = m_metricBoxes[i]; + QAction* action = menu.addAction(box->property("metricLabel").toString()); + action->setCheckable(true); + action->setChecked(!box->isHidden()); + action->setData(i); + } + const QAction* selectedAction = menu.exec(mouseEvent->globalPos()); + if (selectedAction) { + const int boxIndex = selectedAction->data().toInt(); + if (boxIndex >= 0 && boxIndex < m_metricBoxes.size()) { + QWidget* box = m_metricBoxes[boxIndex]; + box->setHidden(!box->isHidden()); + } + relayoutMetricBoxes(); + } + return true; + } + } + + if (isMetricBox && event->type() == QEvent::MouseMove) { + QMouseEvent* mouseEvent = static_cast(event); + if (!(mouseEvent->buttons() & Qt::LeftButton) || m_dragSourceBox != watchedWidget) { + return QWidget::eventFilter(watched, event); + } + // Ignore tiny mouse movement so normal clicks do not trigger drag mode. + if ((mouseEvent->pos() - m_dragStartPos).manhattanLength() < QApplication::startDragDistance()) { + return QWidget::eventFilter(watched, event); + } + + const int sourceIndex = m_metricBoxes.indexOf(m_dragSourceBox); + if (sourceIndex < 0) { + return QWidget::eventFilter(watched, event); + } + + QDrag* drag = new QDrag(watchedWidget); + QMimeData* mimeData = new QMimeData(); + mimeData->setData(kMetricMimeType, QByteArray::number(sourceIndex)); + drag->setMimeData(mimeData); + + // Show a translucent preview of the metric tile while dragging. + QPixmap dragPixmap = watchedWidget->grab(); + if (!dragPixmap.isNull()) { + QPixmap ghost(dragPixmap.size()); + ghost.fill(Qt::transparent); + QPainter painter(&ghost); + painter.setOpacity(0.65); + painter.drawPixmap(0, 0, dragPixmap); + painter.end(); + drag->setPixmap(ghost); + drag->setHotSpot(mouseEvent->pos()); + } + + drag->exec(Qt::MoveAction); + return true; + } + + if ((isMetricBox || isMetricsContainer) && event->type() == QEvent::DragEnter) { + QDragEnterEvent* dragEvent = static_cast(event); + if (dragEvent->mimeData()->hasFormat(kMetricMimeType)) { + dragEvent->acceptProposedAction(); + return true; + } + } + + if ((isMetricBox || isMetricsContainer) && event->type() == QEvent::Drop) { + QDropEvent* dropEvent = static_cast(event); + if (!dropEvent->mimeData()->hasFormat(kMetricMimeType)) { + return QWidget::eventFilter(watched, event); + } + + const int sourceIndex = QString::fromLatin1(dropEvent->mimeData()->data(kMetricMimeType)).toInt(); + if (sourceIndex < 0 || sourceIndex >= m_metricBoxes.size()) { + return QWidget::eventFilter(watched, event); + } + + QWidget* targetBox = isMetricBox ? watchedWidget : nullptr; + if (!targetBox && isMetricsContainer) { + targetBox = m_metricsContainer->childAt(dropEvent->pos()); + while (targetBox && !m_metricBoxes.contains(targetBox)) { + targetBox = targetBox->parentWidget(); + } + } + + int targetIndex = targetBox ? m_metricBoxes.indexOf(targetBox) : (m_metricBoxes.size() - 1); + if (targetIndex < 0) { + targetIndex = m_metricBoxes.size() - 1; + } + + if (sourceIndex != targetIndex) { + // Reorder metric list and reflow visible tiles back into grid form. + QWidget* box = m_metricBoxes.takeAt(sourceIndex); + m_metricBoxes.insert(targetIndex, box); + relayoutMetricBoxes(); + } + + dropEvent->setDropAction(Qt::MoveAction); + dropEvent->accept(); + return true; + } + + return QWidget::eventFilter(watched, event); +} + +void Dashb0rdPage::pushSample(QVector& series, SparklineWidget* spark, double value, const QString& txid, const QString& blockHash) +{ + series.push_back(value); + if (series.size() > kMaxSparkPoints) { + const int extra = series.size() - kMaxSparkPoints; + series.erase(series.begin(), series.begin() + extra); + } + if (spark) { + PointContext pointContext; + pointContext.timestamp = static_cast(QDateTime::currentDateTime().toTime_t()); + pointContext.txid = txid; + pointContext.blockHash = blockHash; + QVector& contexts = m_pointContexts[spark]; + contexts.push_back(pointContext); + if (contexts.size() > kMaxSparkPoints) { + const int extra = contexts.size() - kMaxSparkPoints; + contexts.erase(contexts.begin(), contexts.begin() + extra); + } + spark->setData(series); + } +} + +QString Dashb0rdPage::formatSparklineHoverText(SparklineWidget* spark, int index, double value) const +{ + if (!spark || !m_pointContexts.contains(spark)) { + return QString(); + } + const QVector& contexts = m_pointContexts[spark]; + if (index < 0 || index >= contexts.size()) { + return QString(); + } + const PointContext& ctx = contexts[index]; + const QString tsStr = DateTimeFromEpochCompat(ctx.timestamp).toString(Qt::ISODate); + const QString valueStr = FormatValueForKind(spark->property("tooltipValueKind").toString(), value); + if (!ctx.txid.isEmpty()) { + return tr("Time: %1\nValue: %2\nTxID: %3").arg(tsStr).arg(valueStr).arg(ctx.txid); + } + return tr("Time: %1\nValue: %2\nBlock: %3").arg(tsStr).arg(valueStr).arg(!ctx.blockHash.isEmpty() ? ctx.blockHash : tr("n/a")); +} + +void Dashb0rdPage::showSparklineDetailsDialog(SparklineWidget* spark, int index, double value) +{ + if (!spark || !m_pointContexts.contains(spark)) { + return; + } + const QVector& contexts = m_pointContexts[spark]; + if (index < 0 || index >= contexts.size()) { + return; + } + const PointContext& ctx = contexts[index]; + const QString tsStr = DateTimeFromEpochCompat(ctx.timestamp).toString(Qt::ISODate); + const QString valueStr = FormatValueForKind(spark->property("tooltipValueKind").toString(), value); + + UniValue decoded; + QString decodeError; + const bool decodedOk = DecodeContextToUniValue(ctx.txid, ctx.blockHash, decoded, decodeError); + + QDialog details(this); + details.setWindowTitle(tr("Metric Point Details")); + QVBoxLayout* detailsLayout = new QVBoxLayout(&details); + detailsLayout->addWidget(new QLabel(tr("Time: %1").arg(tsStr), &details)); + detailsLayout->addWidget(new QLabel(tr("Value: %1").arg(valueStr), &details)); + if (!ctx.txid.isEmpty()) { + detailsLayout->addWidget(new QLabel(tr("TxID: %1").arg(ctx.txid), &details)); + } else if (!ctx.blockHash.isEmpty()) { + detailsLayout->addWidget(new QLabel(tr("Block: %1").arg(ctx.blockHash), &details)); + } + + QTreeWidget* tree = new QTreeWidget(&details); + tree->setColumnCount(2); + tree->setHeaderLabels(QStringList() << tr("Field") << tr("Value")); + tree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + tree->header()->setSectionResizeMode(1, QHeaderView::Stretch); + PopulateDecodedTree(tree, decodedOk, decoded, decodeError); + HighlightScriptAsmForType(tree, scriptTypeFilterForSpark(spark)); + + QObject::connect(tree, &QTreeWidget::itemDoubleClicked, &details, [this, ctx](QTreeWidgetItem* item, int /*column*/) { + if (!item) return; + QString txid = item->text(1).trimmed(); + if (!IsLikelyTxid(txid)) return; + if (txid.size() >= 2 && txid.startsWith('"') && txid.endsWith('"')) { + txid = txid.mid(1, txid.size() - 2); + } + + UniValue nestedDecoded; + QString nestedError; + const bool nestedOk = DecodeContextToUniValue(txid, ctx.blockHash, nestedDecoded, nestedError); + + QDialog nested(this); + nested.setWindowTitle(tr("Decoded Transaction")); + QVBoxLayout* nestedLayout = new QVBoxLayout(&nested); + nestedLayout->addWidget(new QLabel(tr("TxID: %1").arg(txid), &nested)); + QTreeWidget* nestedTree = new QTreeWidget(&nested); + nestedTree->setColumnCount(2); + nestedTree->setHeaderLabels(QStringList() << tr("Field") << tr("Value")); + nestedTree->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents); + nestedTree->header()->setSectionResizeMode(1, QHeaderView::Stretch); + PopulateDecodedTree(nestedTree, nestedOk, nestedDecoded, nestedError); + nestedLayout->addWidget(nestedTree); + QDialogButtonBox* closeNested = new QDialogButtonBox(QDialogButtonBox::Close, &nested); + QObject::connect(closeNested, &QDialogButtonBox::rejected, &nested, &QDialog::reject); + nestedLayout->addWidget(closeNested); + nested.resize(760, 500); + nested.exec(); + }); + + detailsLayout->addWidget(tree); + QDialogButtonBox* closeBox = new QDialogButtonBox(QDialogButtonBox::Close, &details); + QObject::connect(closeBox, &QDialogButtonBox::rejected, &details, &QDialog::reject); + detailsLayout->addWidget(closeBox); + details.resize(760, 500); + details.exec(); +} + +QString Dashb0rdPage::scriptTypeFilterForSpark(SparklineWidget* spark) const +{ + if (spark == m_mempoolP2pkhSpark) return "pubkeyhash"; + if (spark == m_mempoolP2shSpark) return "scripthash"; + if (spark == m_mempoolMultisigSpark) return "multisig"; + if (spark == m_mempoolOpReturnSpark) return "nulldata"; + if (spark == m_mempoolNonstandardSpark) return "nonstandard"; + return QString(); +} + +void Dashb0rdPage::pollStats() +{ + if (!isVisible()) { + return; + } + + const QDateTime now = QDateTime::currentDateTime(); + m_lastUpdated->setText(tr("Last updated: %1").arg(now.toString(Qt::ISODate))); + + if (!m_clientModel) { + return; + } + + try { + // Pull all dashboard values in one core RPC call. + JSONRPCRequest req; + req.strMethod = "getdashboardmetrics"; + req.params = UniValue(UniValue::VARR); + req.params.push_back(m_statsWindowBlocks); + const UniValue result = tableRPC.execute(req); + const QString chainBlockHash = GetString(result, "chain_tip_blockhash"); + + const int64_t chainTipHeight = GetInt64(result, "chain_tip_height"); + m_chainTipHeightValue->setText(QString::number(chainTipHeight)); + pushSample(m_chainTipHeightSeries, m_chainTipHeightSpark, static_cast(chainTipHeight), QString(), chainBlockHash); + + const double chainTipDifficulty = GetDouble(result, "chain_tip_difficulty"); + m_chainTipDifficultyValue->setText(QString::number(chainTipDifficulty, 'f', 2)); + pushSample(m_chainTipDifficultySeries, m_chainTipDifficultySpark, chainTipDifficulty, QString(), chainBlockHash); + + const QString chainTipTime = GetString(result, "chain_tip_time"); + m_chainTipTimeValue->setText(chainTipTime); + const qint64 chainTipTimeEpoch = static_cast(QDateTime::fromString(chainTipTime, Qt::ISODate).toTime_t()); + pushSample(m_chainTipTimeSeries, m_chainTipTimeSpark, static_cast(chainTipTimeEpoch), QString(), chainBlockHash); + + const QString chainTipBits = GetString(result, "chain_tip_bits_hex"); + m_chainTipBitsValue->setText(chainTipBits); + bool bitsOk = false; + const quint64 bitsValue = chainTipBits.toULongLong(&bitsOk, 0); + pushSample(m_chainTipBitsSeries, m_chainTipBitsSpark, bitsOk ? static_cast(bitsValue) : 0.0, QString(), chainBlockHash); + + const int64_t mempoolTxCount = GetInt64(result, "mempool_tx_count"); + const QString mempoolTxid = GetString(result, "mempool_latest_txid"); + if (m_prevMempoolTxCount >= 0) { + // Show per-poll direction so users can quickly see churn (+/-). + const int64_t mempoolDelta = mempoolTxCount - m_prevMempoolTxCount; + QString deltaText = QString::number(mempoolDelta); + if (mempoolDelta > 0) { + deltaText.prepend("+"); + } + m_mempoolTxCountValue->setText(QString("%1 (%2)").arg(mempoolTxCount).arg(deltaText)); + } else { + m_mempoolTxCountValue->setText(QString::number(mempoolTxCount)); + } + m_prevMempoolTxCount = mempoolTxCount; + pushSample(m_mempoolTxCountSeries, m_mempoolTxCountSpark, static_cast(mempoolTxCount), mempoolTxid); + + const int64_t mempoolTotalBytes = GetInt64(result, "mempool_total_bytes"); + m_mempoolTotalBytesValue->setText(GUIUtil::formatBytes(mempoolTotalBytes)); + pushSample(m_mempoolTotalBytesSeries, m_mempoolTotalBytesSpark, static_cast(mempoolTotalBytes), mempoolTxid); + + const int64_t mempoolP2pkhCount = GetInt64(result, "mempool_p2pkh_count"); + m_mempoolP2pkhValue->setText(QString::number(mempoolP2pkhCount)); + pushSample(m_mempoolP2pkhSeries, m_mempoolP2pkhSpark, static_cast(mempoolP2pkhCount), mempoolTxid); + + const int64_t mempoolP2shCount = GetInt64(result, "mempool_p2sh_count"); + m_mempoolP2shValue->setText(QString::number(mempoolP2shCount)); + pushSample(m_mempoolP2shSeries, m_mempoolP2shSpark, static_cast(mempoolP2shCount), mempoolTxid); + + const int64_t mempoolMultisigCount = GetInt64(result, "mempool_multisig_count"); + m_mempoolMultisigValue->setText(QString::number(mempoolMultisigCount)); + pushSample(m_mempoolMultisigSeries, m_mempoolMultisigSpark, static_cast(mempoolMultisigCount), mempoolTxid); + + const int64_t mempoolOpReturnCount = GetInt64(result, "mempool_op_return_count"); + m_mempoolOpReturnValue->setText(QString::number(mempoolOpReturnCount)); + pushSample(m_mempoolOpReturnSeries, m_mempoolOpReturnSpark, static_cast(mempoolOpReturnCount), mempoolTxid); + + const int64_t mempoolNonstandardCount = GetInt64(result, "mempool_nonstandard_count"); + m_mempoolNonstandardValue->setText(QString::number(mempoolNonstandardCount)); + pushSample(m_mempoolNonstandardSeries, m_mempoolNonstandardSpark, static_cast(mempoolNonstandardCount), mempoolTxid); + + const int64_t mempoolOutputCount = GetInt64(result, "mempool_output_count"); + m_mempoolOutputCountValue->setText(QString::number(mempoolOutputCount)); + pushSample(m_mempoolOutputCountSeries, m_mempoolOutputCountSpark, static_cast(mempoolOutputCount), mempoolTxid); + + const int64_t statsTransactions = GetInt64(result, "stats_transactions"); + const QString statsBlockHash = GetString(result, "stats_reference_blockhash"); + m_statsTransactionsValue->setText(QString::number(statsTransactions)); + pushSample(m_statsTransactionsSeries, m_statsTransactionsSpark, static_cast(statsTransactions), QString(), statsBlockHash); + + const double statsTps = GetDouble(result, "stats_tps"); + m_statsTpsValue->setText(QString::number(statsTps, 'f', 3)); + pushSample(m_statsTpsSeries, m_statsTpsSpark, statsTps, QString(), statsBlockHash); + + const double statsVolume = GetDouble(result, "stats_volume"); + m_statsVolumeValue->setText(QString::number(statsVolume, 'f', 2)); + pushSample(m_statsVolumeSeries, m_statsVolumeSpark, statsVolume, QString(), statsBlockHash); + + const int64_t statsOutputs = GetInt64(result, "stats_outputs"); + m_statsOutputsValue->setText(QString::number(statsOutputs)); + pushSample(m_statsOutputsSeries, m_statsOutputsSpark, static_cast(statsOutputs), QString(), statsBlockHash); + + const int64_t statsBytes = GetInt64(result, "stats_bytes"); + // Show formatted and exact byte totals to make small changes obvious. + m_statsBytesValue->setText(QString("%1 (%2 B)").arg(GUIUtil::formatBytes(statsBytes)).arg(QString::number(statsBytes))); + pushSample(m_statsBytesSeries, m_statsBytesSpark, static_cast(statsBytes), QString(), statsBlockHash); + + const double statsMedianFeePerBlock = GetDouble(result, "stats_median_fee_per_block"); + m_statsMedianFeeValue->setText(QString::number(statsMedianFeePerBlock, 'f', 8)); + pushSample(m_statsMedianFeeSeries, m_statsMedianFeeSpark, statsMedianFeePerBlock, QString(), statsBlockHash); + + const double statsAvgFeePerBlock = GetDouble(result, "stats_avg_fee_per_block"); + m_statsAvgFeeValue->setText(QString::number(statsAvgFeePerBlock, 'f', 8)); + pushSample(m_statsAvgFeeSeries, m_statsAvgFeeSpark, statsAvgFeePerBlock, QString(), statsBlockHash); + + const int64_t uptimeSec = GetInt64(result, "uptime_sec"); + if (uptimeSec > std::numeric_limits::max()) { + m_uptimeValue->setText(QString::number(uptimeSec) + tr(" s")); + } else { + m_uptimeValue->setText(GUIUtil::formatDurationStr(static_cast(uptimeSec))); + } + pushSample(m_uptimeSeries, m_uptimeSpark, static_cast(uptimeSec)); + } catch (const UniValue& objError) { + LogPrintf("Dashboard RPC error: %s\n", objError.write().c_str()); + } catch (const std::exception& e) { + LogPrintf("Dashboard metrics error: %s\n", e.what()); + } +} diff --git a/src/qt/dashb0rdpage.h b/src/qt/dashb0rdpage.h new file mode 100644 index 00000000000..f4bb07c8fa9 --- /dev/null +++ b/src/qt/dashb0rdpage.h @@ -0,0 +1,150 @@ +// Copyright (c) 2011-2016 The Bitcoin Core developers +// Copyright (c) 2021-2026 The Dogecoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_DASHB0RDPAGE_H +#define BITCOIN_QT_DASHB0RDPAGE_H + +#include + +#include +#include +#include +#include +#include + +class ClientModel; +class PlatformStyle; +class QGridLayout; +class QLabel; +class QResizeEvent; +class QShowEvent; +class QHideEvent; +class QSpinBox; +class QTimer; +class SparklineWidget; +class WalletModel; + +class Dashb0rdPage : public QWidget +{ + Q_OBJECT + +public: + explicit Dashb0rdPage(const PlatformStyle* platformStyle, QWidget* parent = nullptr); + ~Dashb0rdPage() override; + + void setClientModel(ClientModel* model); + void setWalletModel(WalletModel* model); + +protected: + bool eventFilter(QObject* watched, QEvent* event) override; + void resizeEvent(QResizeEvent* event) override; + void showEvent(QShowEvent* event) override; + void hideEvent(QHideEvent* event) override; + +private Q_SLOTS: + void pollStats(); + void setStatsWindow(int blocks); + +private: + void pushSample(QVector& series, SparklineWidget* spark, double value, const QString& txid = QString(), const QString& blockHash = QString()); + QWidget* createMetricBox(const QString& label, QLabel*& valueLabel, SparklineWidget*& spark); + void relayoutMetricBoxes(); + QString formatSparklineHoverText(SparklineWidget* spark, int index, double value) const; + void showSparklineDetailsDialog(SparklineWidget* spark, int index, double value); + QString scriptTypeFilterForSpark(SparklineWidget* spark) const; + + ClientModel* m_clientModel; + WalletModel* m_walletModel; + const PlatformStyle* m_platformStyle; + + QTimer* m_pollTimer; + QLabel* m_lastUpdated; + QWidget* m_metricsContainer; + QGridLayout* m_metricGrid; + QVector m_metricBoxes; + QPoint m_dragStartPos; + QWidget* m_dragSourceBox; + int64_t m_prevMempoolTxCount; + int m_statsWindowBlocks; + QSpinBox* m_statsWindowSpinBox; + + // Chain Tip Metrics + QLabel* m_chainTipHeightValue; + QLabel* m_chainTipDifficultyValue; + QLabel* m_chainTipTimeValue; + QLabel* m_chainTipBitsValue; + SparklineWidget* m_chainTipHeightSpark; + SparklineWidget* m_chainTipDifficultySpark; + SparklineWidget* m_chainTipTimeSpark; + SparklineWidget* m_chainTipBitsSpark; + QVector m_chainTipHeightSeries; + QVector m_chainTipDifficultySeries; + QVector m_chainTipTimeSeries; + QVector m_chainTipBitsSeries; + + // Mempool Metrics + QLabel* m_mempoolTxCountValue; + QLabel* m_mempoolTotalBytesValue; + QLabel* m_mempoolP2pkhValue; + QLabel* m_mempoolP2shValue; + QLabel* m_mempoolMultisigValue; + QLabel* m_mempoolOpReturnValue; + QLabel* m_mempoolNonstandardValue; + QLabel* m_mempoolOutputCountValue; + SparklineWidget* m_mempoolTxCountSpark; + SparklineWidget* m_mempoolTotalBytesSpark; + SparklineWidget* m_mempoolP2pkhSpark; + SparklineWidget* m_mempoolP2shSpark; + SparklineWidget* m_mempoolMultisigSpark; + SparklineWidget* m_mempoolOpReturnSpark; + SparklineWidget* m_mempoolNonstandardSpark; + SparklineWidget* m_mempoolOutputCountSpark; + QVector m_mempoolTxCountSeries; + QVector m_mempoolTotalBytesSeries; + QVector m_mempoolP2pkhSeries; + QVector m_mempoolP2shSeries; + QVector m_mempoolMultisigSeries; + QVector m_mempoolOpReturnSeries; + QVector m_mempoolNonstandardSeries; + QVector m_mempoolOutputCountSeries; + + // Rolling Stats Metrics + QLabel* m_statsTransactionsValue; + QLabel* m_statsTpsValue; + QLabel* m_statsVolumeValue; + QLabel* m_statsOutputsValue; + QLabel* m_statsBytesValue; + QLabel* m_statsMedianFeeValue; + QLabel* m_statsAvgFeeValue; + SparklineWidget* m_statsTransactionsSpark; + SparklineWidget* m_statsTpsSpark; + SparklineWidget* m_statsVolumeSpark; + SparklineWidget* m_statsOutputsSpark; + SparklineWidget* m_statsBytesSpark; + SparklineWidget* m_statsMedianFeeSpark; + SparklineWidget* m_statsAvgFeeSpark; + QVector m_statsTransactionsSeries; + QVector m_statsTpsSeries; + QVector m_statsVolumeSeries; + QVector m_statsOutputsSeries; + QVector m_statsBytesSeries; + QVector m_statsMedianFeeSeries; + QVector m_statsAvgFeeSeries; + + // Uptime + QLabel* m_uptimeValue; + SparklineWidget* m_uptimeSpark; + QVector m_uptimeSeries; + + struct PointContext + { + qint64 timestamp; + QString txid; + QString blockHash; + }; + QHash > m_pointContexts; +}; + +#endif // BITCOIN_QT_DASHB0RDPAGE_H diff --git a/src/qt/sparklinewidget.cpp b/src/qt/sparklinewidget.cpp new file mode 100644 index 00000000000..847f924496c --- /dev/null +++ b/src/qt/sparklinewidget.cpp @@ -0,0 +1,158 @@ +// Copyright (c) 2011-2016 The Bitcoin Core developers +// Copyright (c) 2021-2026 The Dogecoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#include "sparklinewidget.h" + +#include +#include +#include +#include +#include +#include + +#include + +SparklineWidget::SparklineWidget(QWidget* parent) + : QWidget(parent) +{ + setSizePolicy(QSizePolicy::Expanding, QSizePolicy::Expanding); + setMinimumHeight(34); + setMouseTracking(true); +} + +SparklineWidget::~SparklineWidget() = default; + +void SparklineWidget::setData(const QVector& data) +{ + m_data = data; + update(); +} + +void SparklineWidget::setHoverTextProvider(const std::function& provider) +{ + m_hoverTextProvider = provider; +} + +void SparklineWidget::setDoubleClickHandler(const std::function& handler) +{ + m_doubleClickHandler = handler; +} + +void SparklineWidget::clear() +{ + m_data.clear(); + update(); +} + +void SparklineWidget::paintEvent(QPaintEvent* /*event*/) +{ + QPainter p(this); + p.setRenderHint(QPainter::Antialiasing, true); + + // Background (use widget palette, do not hardcode colors) + p.fillRect(rect(), palette().brush(QPalette::Base)); + + if (m_data.isEmpty() || width() <= 2 || height() <= 2) { + return; + } + + // Compute min/max for normalization + double minv = m_data[0]; + double maxv = m_data[0]; + for (double v : m_data) { + if (v < minv) minv = v; + if (v > maxv) maxv = v; + } + const double range = (maxv - minv); + + const int w = width(); + const int h = height(); + + // Padding + const int pad = 2; + const QRectF r(pad, pad, w - 2.0 * pad, h - 2.0 * pad); + + // Build polyline points + const int n = m_data.size(); + QPolygonF poly; + poly.reserve(n); + + for (int i = 0; i < n; ++i) { + const double x = r.left() + (n == 1 ? 0.0 : (r.width() * i / double(n - 1))); + + double norm = 0.5; + if (range > 0.0) { + norm = (m_data[i] - minv) / range; // 0..1 + } + // invert Y so higher values go up + const double y = r.bottom() - (r.height() * norm); + poly << QPointF(x, y); + } + + // Draw line with accent color for better visibility on dashboard + const QColor lineColor = palette().color(QPalette::Highlight); + QPen pen(lineColor); + pen.setWidthF(1.2); + p.setPen(pen); + p.drawPolyline(poly); + + // Optional: a subtle baseline midline when flat (range == 0) + if (range == 0.0) { + QPen mid(palette().color(QPalette::Mid)); + mid.setWidthF(1.0); + p.setPen(mid); + p.drawLine(QPointF(r.left(), r.center().y()), QPointF(r.right(), r.center().y())); + } +} + +int SparklineWidget::sampleIndexForPos(const QPoint& pos) const +{ + static const int kMinSampleWidth = 4; + static const int kSamplePad = 2; + const int count = m_data.size(); + if (count <= 1 || width() <= kMinSampleWidth) { + return 0; + } + const double left = kSamplePad; + const double right = width() - kSamplePad; + const double clampedX = std::max(left, std::min(pos.x(), right)); + const double ratio = (clampedX - left) / std::max(1.0, right - left); + int index = qRound(ratio * (count - 1)); + index = std::max(0, std::min(index, count - 1)); + return index; +} + +void SparklineWidget::mouseMoveEvent(QMouseEvent* event) +{ + if (m_data.isEmpty()) { + QWidget::mouseMoveEvent(event); + return; + } + + if (m_hoverTextProvider) { + const int index = sampleIndexForPos(event->pos()); + const QString tooltip = m_hoverTextProvider(index, m_data[index]); + if (!tooltip.isEmpty()) { + QToolTip::showText(event->globalPos(), tooltip, this); + } + } + + QWidget::mouseMoveEvent(event); +} + +void SparklineWidget::mouseDoubleClickEvent(QMouseEvent* event) +{ + if (!m_data.isEmpty() && m_doubleClickHandler) { + const int index = sampleIndexForPos(event->pos()); + m_doubleClickHandler(index, m_data[index]); + } + QWidget::mouseDoubleClickEvent(event); +} + +void SparklineWidget::leaveEvent(QEvent* event) +{ + QToolTip::hideText(); + QWidget::leaveEvent(event); +} diff --git a/src/qt/sparklinewidget.h b/src/qt/sparklinewidget.h new file mode 100644 index 00000000000..4e60ee7d34b --- /dev/null +++ b/src/qt/sparklinewidget.h @@ -0,0 +1,44 @@ +// Copyright (c) 2011-2016 The Bitcoin Core developers +// Copyright (c) 2021-2026 The Dogecoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +#ifndef BITCOIN_QT_SPARKLINEWIDGET_H +#define BITCOIN_QT_SPARKLINEWIDGET_H + +#include +#include + +#include +#include +#include + +class QEvent; +class QMouseEvent; + +class SparklineWidget : public QWidget +{ +public: + explicit SparklineWidget(QWidget* parent = nullptr); + ~SparklineWidget() override; + + void setData(const QVector& data); + void setHoverTextProvider(const std::function& provider); + void setDoubleClickHandler(const std::function& handler); + void clear(); + +protected: + void paintEvent(QPaintEvent* event) override; + void mouseMoveEvent(QMouseEvent* event) override; + void mouseDoubleClickEvent(QMouseEvent* event) override; + void leaveEvent(QEvent* event) override; + +private: + int sampleIndexForPos(const QPoint& pos) const; + + QVector m_data; + std::function m_hoverTextProvider; + std::function m_doubleClickHandler; +}; + +#endif // BITCOIN_QT_SPARKLINEWIDGET_H diff --git a/src/qt/walletframe.cpp b/src/qt/walletframe.cpp index 4430183ed3a..f5a00be5053 100644 --- a/src/qt/walletframe.cpp +++ b/src/qt/walletframe.cpp @@ -152,6 +152,13 @@ void WalletFrame::gotoVerifyMessageTab(QString addr) walletView->gotoVerifyMessageTab(addr); } +void WalletFrame::gotoDashb0rdPage() +{ + QMap::const_iterator i; + for (i = mapWalletViews.constBegin(); i != mapWalletViews.constEnd(); ++i) + i.value()->gotoDashb0rdPage(); +} + void WalletFrame::encryptWallet(bool status) { WalletView *walletView = currentWalletView(); diff --git a/src/qt/walletframe.h b/src/qt/walletframe.h index 94f30330df0..57eb4e33b57 100644 --- a/src/qt/walletframe.h +++ b/src/qt/walletframe.h @@ -71,6 +71,8 @@ public Q_SLOTS: void gotoReceiveCoinsPage(); /** Switch to send coins page */ void gotoSendCoinsPage(QString addr = ""); + /** Switch to dashboard page */ + void gotoDashb0rdPage(); /** Show Sign/Verify Message dialog and switch to sign message tab */ void gotoSignMessageTab(QString addr = ""); diff --git a/src/qt/walletview.cpp b/src/qt/walletview.cpp index 7d0cf1ada32..6a299e436ac 100644 --- a/src/qt/walletview.cpp +++ b/src/qt/walletview.cpp @@ -9,6 +9,7 @@ #include "askpassphrasedialog.h" #include "bitcoingui.h" #include "clientmodel.h" +#include "dashb0rd.h" #include "guiutil.h" #include "importkeysdialog.h" #include "optionsmodel.h" @@ -58,6 +59,7 @@ WalletView::WalletView(const PlatformStyle *_platformStyle, QWidget *parent): receiveCoinsPage = new ReceiveCoinsDialog(platformStyle); sendCoinsPage = new SendCoinsDialog(platformStyle); + dashb0rdPage = new Dashb0rd(platformStyle, this); usedSendingAddressesPage = new AddressBookPage(platformStyle, AddressBookPage::ForEditing, AddressBookPage::SendingTab, this); usedReceivingAddressesPage = new AddressBookPage(platformStyle, AddressBookPage::ForEditing, AddressBookPage::ReceivingTab, this); @@ -66,6 +68,7 @@ WalletView::WalletView(const PlatformStyle *_platformStyle, QWidget *parent): addWidget(transactionsPage); addWidget(receiveCoinsPage); addWidget(sendCoinsPage); + addWidget(dashb0rdPage); importKeysDialog = new ImportKeysDialog(platformStyle); @@ -116,6 +119,7 @@ void WalletView::setClientModel(ClientModel *_clientModel) overviewPage->setClientModel(_clientModel); sendCoinsPage->setClientModel(_clientModel); + dashb0rdPage->setClientModel(_clientModel); } void WalletView::setWalletModel(WalletModel *_walletModel) @@ -127,6 +131,7 @@ void WalletView::setWalletModel(WalletModel *_walletModel) overviewPage->setWalletModel(_walletModel); receiveCoinsPage->setModel(_walletModel); sendCoinsPage->setModel(_walletModel); + dashb0rdPage->setWalletModel(_walletModel); usedReceivingAddressesPage->setModel(_walletModel->getAddressTableModel()); usedSendingAddressesPage->setModel(_walletModel->getAddressTableModel()); @@ -197,6 +202,11 @@ void WalletView::gotoSendCoinsPage(QString addr) sendCoinsPage->setAddress(addr); } +void WalletView::gotoDashb0rdPage() +{ + setCurrentWidget(dashb0rdPage); +} + void WalletView::gotoSignMessageTab(QString addr) { // calls show() in showTab_SM() diff --git a/src/qt/walletview.h b/src/qt/walletview.h index 94f7e0db1ad..6f8d8084055 100644 --- a/src/qt/walletview.h +++ b/src/qt/walletview.h @@ -12,6 +12,7 @@ class BitcoinGUI; class ClientModel; +class Dashb0rd; class OverviewPage; class PlatformStyle; class ReceiveCoinsDialog; @@ -64,6 +65,7 @@ class WalletView : public QStackedWidget QWidget *transactionsPage; ReceiveCoinsDialog *receiveCoinsPage; SendCoinsDialog *sendCoinsPage; + Dashb0rd *dashb0rdPage; AddressBookPage *usedSendingAddressesPage; AddressBookPage *usedReceivingAddressesPage; ImportKeysDialog *importKeysDialog; @@ -84,6 +86,8 @@ public Q_SLOTS: void gotoReceiveCoinsPage(); /** Switch to send coins page */ void gotoSendCoinsPage(QString addr = ""); + /** Switch to dashboard page */ + void gotoDashb0rdPage(); /** Show Sign/Verify Message dialog and switch to sign message tab */ void gotoSignMessageTab(QString addr = ""); diff --git a/src/rpc/blockchain.cpp b/src/rpc/blockchain.cpp index 5d97aad8606..c783bc78371 100644 --- a/src/rpc/blockchain.cpp +++ b/src/rpc/blockchain.cpp @@ -24,9 +24,14 @@ #include "undo.h" #include "util.h" #include "utilstrencodings.h" +#include "utiltime.h" #include "hash.h" +#include "script/standard.h" #include +#include +#include +#include #include @@ -1265,6 +1270,245 @@ UniValue getblockchaininfo(const JSONRPCRequest& request) return obj; } +UniValue getdashboardmetrics(const JSONRPCRequest& request) +{ + if (request.fHelp || request.params.size() > 1) + throw runtime_error( + "getdashboardmetrics ( window_blocks )\n" + "Returns metrics formatted for libdogecoin dashboard integration.\n" + "\nArguments:\n" + "1. window_blocks (numeric, optional, default=100) Number of recent blocks for rolling stats (1-5000)\n" + "\nResult:\n" + "{\n" + " \"chain_tip_height\": x, (numeric) current chain height\n" + " \"chain_tip_difficulty\": x, (numeric) current difficulty\n" + " \"chain_tip_time\": \"xxxx\", (string) chain tip time in ISO-8601 format\n" + " \"chain_tip_bits_hex\": \"0xxxxx\", (string) compact difficulty bits in hex\n" + " \"mempool_tx_count\": x, (numeric) count of transactions in mempool\n" + " \"mempool_total_bytes\": x, (numeric) total mempool size in bytes\n" + " \"mempool_p2pkh_count\": x, (numeric) P2PKH outputs in mempool\n" + " \"mempool_p2sh_count\": x, (numeric) P2SH outputs in mempool\n" + " \"mempool_multisig_count\": x, (numeric) multisig outputs in mempool\n" + " \"mempool_op_return_count\": x, (numeric) OP_RETURN outputs in mempool\n" + " \"mempool_nonstandard_count\": x, (numeric) nonstandard outputs in mempool\n" + " \"mempool_output_count\": x, (numeric) total outputs in mempool\n" + " \"stats_blocks\": x, (numeric) blocks analyzed in rolling window\n" + " \"stats_transactions\": x, (numeric) total transactions in rolling window\n" + " \"stats_tps\": x, (numeric) estimated transactions per second\n" + " \"stats_volume\": x, (numeric) sum of output values in DOGE\n" + " \"stats_outputs\": x, (numeric) total outputs in rolling window\n" + " \"stats_bytes\": x, (numeric) total block bytes in rolling window\n" + " \"stats_median_fee_per_block\": x, (numeric) median fee per block in DOGE\n" + " \"stats_avg_fee_per_block\": x, (numeric) average fee per block in DOGE\n" + " \"uptime_sec\": x (numeric) node uptime in seconds\n" + "}\n" + "\nExamples:\n" + + HelpExampleCli("getdashboardmetrics", "") + + HelpExampleCli("getdashboardmetrics", "250") + + HelpExampleRpc("getdashboardmetrics", "250") + ); + + int stats_window = 100; + if (request.params.size() == 1) { + stats_window = request.params[0].get_int(); + if (stats_window < 1 || stats_window > 5000) { + throw JSONRPCError(RPC_INVALID_PARAMETER, "window_blocks must be between 1 and 5000"); + } + } + + LOCK(cs_main); + + UniValue result(UniValue::VOBJ); + + // Chain tip metrics + CBlockIndex* tip = chainActive.Tip(); + if (!tip) + throw JSONRPCError(RPC_INTERNAL_ERROR, "Chain tip not available"); + + result.pushKV("chain_tip_height", (int64_t)chainActive.Height()); + result.pushKV("chain_tip_difficulty", GetDifficulty()); + result.pushKV("chain_tip_time", DateTimeStrFormat("%Y-%m-%dT%H:%M:%S", tip->GetBlockTime())); + result.pushKV("chain_tip_blockhash", tip->GetBlockHash().GetHex()); + std::string chain_tip_coinbase_txid; + { + CBlock tip_block; + if (ReadBlockFromDisk(tip_block, tip, Params().GetConsensus(tip->nHeight)) && !tip_block.vtx.empty()) { + chain_tip_coinbase_txid = tip_block.vtx[0]->GetHash().GetHex(); + } + } + result.pushKV("chain_tip_coinbase_txid", chain_tip_coinbase_txid); + + std::ostringstream bitsHex; + bitsHex << "0x" << std::hex << tip->nBits; + result.pushKV("chain_tip_bits_hex", bitsHex.str()); + + // Mempool metrics + { + LOCK(mempool.cs); + + result.pushKV("mempool_tx_count", (int64_t)mempool.size()); + result.pushKV("mempool_total_bytes", (int64_t)mempool.DynamicMemoryUsage()); + + // Count output types in mempool + int64_t p2pkh_count = 0; + int64_t p2sh_count = 0; + int64_t multisig_count = 0; + int64_t op_return_count = 0; + int64_t nonstandard_count = 0; + int64_t total_vouts = 0; + int64_t latest_mempool_time = 0; + std::string latest_mempool_txid; + + for (const CTxMemPoolEntry& e : mempool.mapTx) { + const CTransaction& tx = e.GetTx(); + if (e.GetTime() > latest_mempool_time) { + latest_mempool_time = e.GetTime(); + latest_mempool_txid = tx.GetHash().GetHex(); + } + for (const CTxOut& txout : tx.vout) { + total_vouts++; + + txnouttype type; + std::vector> vSolutions; + if (Solver(txout.scriptPubKey, type, vSolutions)) { + switch (type) { + case TX_PUBKEYHASH: + p2pkh_count++; + break; + case TX_SCRIPTHASH: + p2sh_count++; + break; + case TX_MULTISIG: + multisig_count++; + break; + case TX_NULL_DATA: + op_return_count++; + break; + case TX_NONSTANDARD: + nonstandard_count++; + break; + default: + // Other types (TX_PUBKEY, TX_WITNESS_*) are not counted separately + break; + } + } else { + nonstandard_count++; + } + } + } + + result.pushKV("mempool_p2pkh_count", (int64_t)p2pkh_count); + result.pushKV("mempool_p2sh_count", (int64_t)p2sh_count); + result.pushKV("mempool_multisig_count", (int64_t)multisig_count); + result.pushKV("mempool_op_return_count", (int64_t)op_return_count); + result.pushKV("mempool_nonstandard_count", (int64_t)nonstandard_count); + result.pushKV("mempool_output_count", (int64_t)total_vouts); + result.pushKV("mempool_latest_txid", latest_mempool_txid); + } + + // Rolling statistics (last N blocks) + const int STATS_WINDOW = stats_window; + int blocks_analyzed = 0; + int64_t total_transactions = 0; + int64_t total_outputs = 0; + int64_t total_bytes = 0; + CAmount total_volume = 0; + std::vector fees_per_block; + int64_t total_time_span = 0; + + CBlockIndex* pindex = tip; + CBlockIndex* pindexStart = pindex; + + for (int i = 0; i < STATS_WINDOW && pindex; i++) { + CBlock block; + if (ReadBlockFromDisk(block, pindex, Params().GetConsensus(pindex->nHeight))) { + blocks_analyzed++; + total_transactions += block.vtx.size(); + total_bytes += ::GetSerializeSize(block, SER_NETWORK, PROTOCOL_VERSION); + + // Calculate block fee from coinbase + CAmount block_fee = 0; + if (!block.vtx.empty() && block.vtx[0]->IsCoinBase()) { + CAmount coinbase_out = 0; + for (const CTxOut& txout : block.vtx[0]->vout) { + coinbase_out += txout.nValue; + } + // Block reward at this height + uint256 prevHash = pindex->pprev ? pindex->pprev->GetBlockHash() : uint256(); + CAmount block_subsidy = GetDogecoinBlockSubsidy(pindex->nHeight, Params().GetConsensus(pindex->nHeight), prevHash); + // Fee is coinbase output minus subsidy + if (coinbase_out > block_subsidy) { + block_fee = coinbase_out - block_subsidy; + } + } + fees_per_block.push_back(block_fee); + + // Count outputs and volume + for (const auto& tx : block.vtx) { + for (const CTxOut& txout : tx->vout) { + total_outputs++; + total_volume += txout.nValue; + } + } + + pindexStart = pindex; + } + + pindex = pindex->pprev; + } + + if (blocks_analyzed > 0) { + if (pindexStart && tip) { + total_time_span = tip->GetBlockTime() - pindexStart->GetBlockTime(); + } + } + + result.pushKV("stats_blocks", (int64_t)blocks_analyzed); + result.pushKV("stats_transactions", (int64_t)total_transactions); + + double tps = 0.0; + if (total_time_span > 0) { + tps = (double)total_transactions / (double)total_time_span; + } + result.pushKV("stats_tps", tps); + + result.pushKV("stats_volume", ValueFromAmount(total_volume)); + result.pushKV("stats_outputs", (int64_t)total_outputs); + result.pushKV("stats_bytes", (int64_t)total_bytes); + + // Calculate median and average fees + UniValue median_fee = 0.0; + UniValue avg_fee = 0.0; + + if (!fees_per_block.empty()) { + std::vector sorted_fees = fees_per_block; + std::sort(sorted_fees.begin(), sorted_fees.end()); + + size_t mid = sorted_fees.size() / 2; + if (sorted_fees.size() % 2 == 0) { + median_fee = ValueFromAmount((sorted_fees[mid - 1] + sorted_fees[mid]) / 2); + } else { + median_fee = ValueFromAmount(sorted_fees[mid]); + } + + CAmount total_fees = 0; + for (CAmount fee : fees_per_block) { + total_fees += fee; + } + avg_fee = ValueFromAmount(total_fees / (CAmount)fees_per_block.size()); + } + + result.pushKV("stats_median_fee_per_block", median_fee); + result.pushKV("stats_avg_fee_per_block", avg_fee); + result.pushKV("stats_reference_txid", chain_tip_coinbase_txid); + result.pushKV("stats_reference_blockhash", tip->GetBlockHash().GetHex()); + + // Uptime + result.pushKV("uptime_sec", (int64_t)(GetTime() - GetStartupTime())); + + return result; +} + /** Comparison function for sorting the getchaintips heads. */ struct CompareBlocksByHeight { @@ -1858,6 +2102,7 @@ static const CRPCCommand commands[] = { // category name actor (function) okSafe argNames // --------------------- ------------------------ ----------------------- ------ ---------- { "blockchain", "getblockchaininfo", &getblockchaininfo, true, {} }, + { "blockchain", "getdashboardmetrics", &getdashboardmetrics, true, {"window_blocks"} }, { "blockchain", "getblockstats", &getblockstats, true, {"hash", "stats"} }, { "blockchain", "getbestblockhash", &getbestblockhash, true, {} }, { "blockchain", "getblockcount", &getblockcount, true, {} }, diff --git a/src/rpc/client.cpp b/src/rpc/client.cpp index feca3d8fd65..0211b3ac23a 100644 --- a/src/rpc/client.cpp +++ b/src/rpc/client.cpp @@ -113,6 +113,7 @@ static const CRPCConvertParam vRPCConvertParams[] = { "importmulti", 1, "options" }, { "verifychain", 0, "checklevel" }, { "verifychain", 1, "nblocks" }, + { "getdashboardmetrics", 0, "window_blocks" }, { "getblockstats", 1, "stats" }, { "pruneblockchain", 0, "height" }, { "keypoolrefill", 0, "newsize" },