Commit 4a9d697d authored by Eugene Popov's avatar Eugene Popov 🇺🇦 Committed by Arjen Hiemstra
Browse files

[SystemMonitor] Port detailed memory information to QtWidgets

Currently, the detailed memory information is displayed as a web page,
and this has several limitations:

* it's not possible to filter data
* it's not possible to sort data by a specific column
* it's not possible to move columns
* it's quite slow and looks outdated

Porting it to QtWidgets removes these limitations and adds new features:

* general page: shows general information about process
* memory maps page: shows memory consumption for each of the
  process's mappings
* open files: shows files opened by the process
parent 2575a366
Pipeline #174667 passed with stage
in 2 minutes and 36 seconds
......@@ -12,6 +12,10 @@ set(processui_LIB_SRCS
ReniceDlg.cpp
KTextEditVT.cpp
scripting.cpp
processdetails/GeneralTab.cpp
processdetails/MemoryMapsTab.cpp
processdetails/OpenFilesTab.cpp
processdetails/ProcessDetailsDialog.cpp
)
ecm_qt_declare_logging_category(processui_LIB_SRCS HEADER processui_debug.h IDENTIFIER LIBKSYSGUARD_PROCESSUI CATEGORY_NAME org.kde.libksysguard.processui
......
......@@ -54,6 +54,7 @@
#include <klocalizedstring.h>
#include <kmessagebox.h>
#include "processdetails/ProcessDetailsDialog.h"
#include "ReniceDlg.h"
#include "process_attribute.h"
#include "process_controller.h"
......@@ -207,6 +208,7 @@ struct KSysGuardProcessListPrivate {
selectTracer = new QAction(i18n("Jump to Process Debugging This One"), q);
window = new QAction(i18n("Show Application Window"), q);
processDetails = new QAction(i18nc("@action:inmenu", "Detailed Information..."), q);
resume = new QAction(QIcon::fromTheme(QStringLiteral("media-playback-start")), i18n("Resume Stopped Process"), q);
terminate = new QAction(i18np("End Process", "End Processes", 1), q);
terminate->setIcon(QIcon::fromTheme(QStringLiteral("process-stop")));
......@@ -285,6 +287,7 @@ struct KSysGuardProcessListPrivate {
QAction *selectTracer;
QAction *jumpToSearchFilter;
QAction *window;
QAction *processDetails;
QAction *resume;
QAction *sigStop;
QAction *sigCont;
......@@ -296,6 +299,8 @@ struct KSysGuardProcessListPrivate {
QAction *sigUsr2;
QMenu *mToolsMenu;
QPointer<ProcessDetailsDialog> processDetailsDialog;
};
KSysGuardProcessList::KSysGuardProcessList(QWidget *parent, const QString &hostName)
......@@ -390,8 +395,9 @@ KSysGuardProcessList::KSysGuardProcessList(QWidget *parent, const QString &hostN
// Add all the actions to the main widget, and get all the actions to call actionTriggered when clicked
QList<QAction *> actions;
actions << d->renice << d->kill << d->terminate << d->selectParent << d->selectTracer << d->window << d->jumpToSearchFilter;
actions << d->resume << d->sigStop << d->sigCont << d->sigHup << d->sigInt << d->sigTerm << d->sigKill << d->sigUsr1 << d->sigUsr2;
actions << d->renice << d->kill << d->terminate << d->selectParent << d->selectTracer << d->window;
actions << d->processDetails << d->jumpToSearchFilter << d->resume << d->sigStop << d->sigCont;
actions << d->sigHup << d->sigInt << d->sigTerm << d->sigKill << d->sigUsr1 << d->sigUsr2;
foreach (QAction *action, actions) {
addAction(action);
......@@ -607,6 +613,13 @@ void KSysGuardProcessList::showProcessContextMenu(const QPoint &point)
d->mProcessContextMenu->addAction(d->window);
}
if (numProcesses == 1) {
const QFileInfo procDirFileInfo(QStringLiteral("/proc/%1").arg(process->pid()));
if (procDirFileInfo.exists() && procDirFileInfo.isReadable() && procDirFileInfo.isDir()) {
d->mProcessContextMenu->addAction(d->processDetails);
}
}
if (numProcesses == 1 && process->status() == KSysGuard::Process::Stopped) {
// If the process is stopped, offer to resume it
d->mProcessContextMenu->addAction(d->resume);
......@@ -670,6 +683,19 @@ void KSysGuardProcessList::actionTriggered(QObject *object)
KWindowSystem::activateWindow(wid);
}
}
} else if (result == d->processDetails) {
const QModelIndexList selectedIndexes = d->mUi->treeView->selectionModel()->selectedRows();
if (selectedIndexes.size() != 1) {
return;
}
const QModelIndex mappedIndex = d->mFilterModel.mapToSource(selectedIndexes.first());
Q_ASSERT(mappedIndex.isValid());
if (!d->processDetailsDialog) {
d->processDetailsDialog = new ProcessDetailsDialog(this);
}
d->processDetailsDialog->setModelIndex(mappedIndex);
d->processDetailsDialog->show();
d->processDetailsDialog->raise();
} else if (result == d->jumpToSearchFilter) {
d->mUi->txtFilter->setFocus();
} else {
......
/*
* KSysGuard, the KDE System Guard
*
* SPDX-FileCopyrightText: 2022 Eugene Popov <popov895@ukr.net>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "GeneralTab.h"
#include <QGraphicsOpacityEffect>
#include <QHeaderView>
#include <QLabel>
#include <QLayout>
#include <QTreeWidget>
#include <KLocalizedString>
GeneralTab::GeneralTab(QWidget *parent)
: QWidget(parent)
{
m_dataTreeWidget = new QTreeWidget;
m_dataTreeWidget->setAlternatingRowColors(true);
m_dataTreeWidget->setColumnCount(2);
m_dataTreeWidget->setHeaderHidden(true);
m_dataTreeWidget->setRootIsDecorated(false);
m_dataTreeWidget->header()->setSectionResizeMode(0, QHeaderView::ResizeToContents);
m_dataTreeWidget->header()->setStretchLastSection(true);
QVBoxLayout *rootLayout = new QVBoxLayout;
rootLayout->addWidget(m_dataTreeWidget);
setLayout(rootLayout);
m_placeholderLabel = new QLabel;
m_placeholderLabel->setAlignment(Qt::AlignCenter);
m_placeholderLabel->setMargin(20);
m_placeholderLabel->setTextInteractionFlags(Qt::NoTextInteraction);
m_placeholderLabel->setWordWrap(true);
m_placeholderLabel->setText(i18nc("@info:status", "No data to display"));
// To match the size of a level 2 Heading/KTitleWidget
QFont placeholderFont = m_placeholderLabel->font();
placeholderFont.setPointSize(qRound(placeholderFont.pointSize() * 1.3));
m_placeholderLabel->setFont(placeholderFont);
// Match opacity of QML placeholder label component
QGraphicsOpacityEffect *opacityEffect = new QGraphicsOpacityEffect(m_placeholderLabel);
opacityEffect->setOpacity(0.5);
m_placeholderLabel->setGraphicsEffect(opacityEffect);
QVBoxLayout *placeholderLayout = new QVBoxLayout;
placeholderLayout->addWidget(m_placeholderLabel);
m_dataTreeWidget->setLayout(placeholderLayout);
}
void GeneralTab::setData(const QVariantMap &data)
{
m_dataTreeWidget->clear();
QList<QTreeWidgetItem*> items;
for (QVariantMap::const_iterator i = data.cbegin(); i != data.cend(); ++i) {
items << new QTreeWidgetItem({ i.key(), i.value().toString() });
}
if (items.isEmpty()) {
m_placeholderLabel->show();
} else {
m_placeholderLabel->hide();
m_dataTreeWidget->addTopLevelItems(items);
}
}
/*
* KSysGuard, the KDE System Guard
*
* SPDX-FileCopyrightText: 2022 Eugene Popov <popov895@ukr.net>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#ifndef _GeneralTab_h_
#define _GeneralTab_h_
#include <QWidget>
class QLabel;
class QTreeWidget;
class GeneralTab : public QWidget
{
Q_OBJECT
public:
explicit GeneralTab(QWidget *parent = nullptr);
void setData(const QVariantMap &data);
private:
QTreeWidget *m_dataTreeWidget = nullptr;
QLabel *m_placeholderLabel = nullptr;
};
#endif // _GeneralTab_h_
/*
* KSysGuard, the KDE System Guard
*
* SPDX-FileCopyrightText: 2022 Eugene Popov <popov895@ukr.net>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "MemoryMapsTab.h"
#include <QFile>
#include <QGraphicsOpacityEffect>
#include <QHeaderView>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QSortFilterProxyModel>
#include <QTextStream>
#include <QTimer>
#include <QTreeView>
#include <KLocalizedString>
#include <KMessageWidget>
class MemoryMapsModel : public QAbstractTableModel
{
public:
using QAbstractTableModel::QAbstractTableModel;
using QAbstractTableModel::setData;
// we know for sure that the next columns are in these positions
enum KnownColumn
{
Column_Filename,
Column_Start,
Column_End,
Column_Permissions,
Column_Offset,
Column_Inode,
KnownColumnCount
};
using DataItem = QVector<QVariant>;
using Data = struct
{
QVector<QString> columns;
QVector<DataItem> rows;
};
void setData(Data &&data)
{
beginResetModel();
m_data = std::forward<Data>(data);
endResetModel();
}
QVariant data(const QModelIndex &index, int role) const override
{
if (index.isValid() && (role == Qt::DisplayRole || role == Qt::EditRole)) {
const int row = index.row();
if (row >= 0 && row < m_data.rows.count()) {
const DataItem &dataItem = m_data.rows.at(row);
const int column = index.column();
if (column >= 0 && column < dataItem.count()) {
const QVariant data = dataItem.at(column);
Q_ASSERT(data.isValid());
if (role == Qt::DisplayRole && data.type() != QVariant::String) {
if (column == Column_Start || column == Column_End || column == Column_Offset) {
return QString::number(data.toULongLong(), 16); // show in hex
}
if (column > Column_Inode) {
return i18nc("kilobytes", "%1 kB", data.toString());
}
}
return data;
}
}
}
return QVariant();
}
QVariant headerData(int section, Qt::Orientation orientation, int role) const override
{
if (orientation == Qt::Horizontal && role == Qt::DisplayRole) {
if (section >= 0 && section < m_data.columns.count()) {
return m_data.columns.at(section);
}
}
return QVariant();
}
int columnCount(const QModelIndex &parent) const override
{
Q_UNUSED(parent);
return m_data.columns.count();
}
int rowCount(const QModelIndex &parent) const override
{
Q_UNUSED(parent);
return m_data.rows.count();
}
private:
Data m_data;
};
MemoryMapsTab::MemoryMapsTab(QWidget *parent)
: QWidget(parent)
{
m_errorWidget = new KMessageWidget;
m_errorWidget->setCloseButtonVisible(false);
m_errorWidget->setMessageType(KMessageWidget::Error);
m_errorWidget->setWordWrap(true);
m_errorWidget->hide();
QPushButton *refreshButton = new QPushButton(i18nc("@action:button", "Refresh"));
m_searchEdit = new QLineEdit;
m_searchEdit->setPlaceholderText(i18n("Quick search"));
m_dataModel = new MemoryMapsModel(this);
m_proxyModel = new QSortFilterProxyModel(this);
m_proxyModel->setFilterCaseSensitivity(Qt::CaseInsensitive);
m_proxyModel->setFilterKeyColumn(-1);
m_proxyModel->setSortRole(Qt::EditRole);
m_proxyModel->setSourceModel(m_dataModel);
QTreeView *dataTreeView = new QTreeView;
dataTreeView->setAlternatingRowColors(true);
dataTreeView->setRootIsDecorated(false);
dataTreeView->setSortingEnabled(true);
dataTreeView->sortByColumn(MemoryMapsModel::Column_Start, Qt::AscendingOrder);
dataTreeView->setModel(m_proxyModel);
dataTreeView->header()->setStretchLastSection(true);
QGridLayout *rootLayout = new QGridLayout;
rootLayout->addWidget(m_errorWidget, 0, 0, 1, 2);
rootLayout->addWidget(refreshButton, 1, 0);
rootLayout->addWidget(m_searchEdit, 1, 1);
rootLayout->addWidget(dataTreeView, 2, 0, 1, 2);
setLayout(rootLayout);
m_placeholderLabel = new QLabel;
m_placeholderLabel->setAlignment(Qt::AlignCenter);
m_placeholderLabel->setMargin(20);
m_placeholderLabel->setTextInteractionFlags(Qt::NoTextInteraction);
m_placeholderLabel->setWordWrap(true);
// To match the size of a level 2 Heading/KTitleWidget
QFont placeholderFont = m_placeholderLabel->font();
placeholderFont.setPointSize(qRound(placeholderFont.pointSize() * 1.3));
m_placeholderLabel->setFont(placeholderFont);
// Match opacity of QML placeholder label component
QGraphicsOpacityEffect *opacityEffect = new QGraphicsOpacityEffect(m_placeholderLabel);
opacityEffect->setOpacity(0.5);
m_placeholderLabel->setGraphicsEffect(opacityEffect);
QVBoxLayout *placeholderLayout = new QVBoxLayout;
placeholderLayout->addWidget(m_placeholderLabel);
dataTreeView->setLayout(placeholderLayout);
// use some delay while searching as you type, because an immediate
// search in large data can slow down the UI
QTimer *applySearchTimer = new QTimer(this);
applySearchTimer->setInterval(350);
applySearchTimer->setSingleShot(true);
connect(refreshButton, &QPushButton::clicked, this, &MemoryMapsTab::refresh);
connect(m_searchEdit, &QLineEdit::textChanged, applySearchTimer, qOverload<>(&QTimer::start));
connect(m_searchEdit, &QLineEdit::editingFinished, applySearchTimer, &QTimer::stop);
connect(m_searchEdit, &QLineEdit::editingFinished, this, &MemoryMapsTab::onSearchEditEditingFinished);
connect(m_proxyModel, &QSortFilterProxyModel::modelReset, this, &MemoryMapsTab::onProxyModelChanged);
connect(m_proxyModel, &QSortFilterProxyModel::rowsInserted, this, &MemoryMapsTab::onProxyModelChanged);
connect(m_proxyModel, &QSortFilterProxyModel::rowsRemoved, this, &MemoryMapsTab::onProxyModelChanged);
connect(applySearchTimer, &QTimer::timeout, this, &MemoryMapsTab::onSearchEditEditingFinished);
}
void MemoryMapsTab::setProcessId(long processId)
{
if (m_processId != processId) {
m_processId = processId;
refresh();
}
}
void MemoryMapsTab::refresh()
{
MemoryMapsModel::Data data;
if (m_processId <= 0) {
m_errorWidget->animatedHide();
} else {
const QString filePath = QStringLiteral("/proc/%1/smaps").arg(m_processId);
QFile file(filePath);
if (!file.open(QFile::ReadOnly)) {
m_errorWidget->setText(QStringLiteral("%1: %2").arg(filePath).arg(file.errorString()));
m_errorWidget->animatedShow();
} else {
m_errorWidget->animatedHide();
MemoryMapsModel::DataItem dataItem;
QTextStream smapsTextStream(file.readAll());
QString line;
QRegularExpressionMatch match;
while (smapsTextStream.readLineInto(&line)) {
// e.g. "Size: 80 kB"
static QRegularExpression regexLineKb(QStringLiteral("^([^ ]+): +(\\d+) kB$"));
match = regexLineKb.match(line);
if (match.hasMatch()) {
if (data.rows.isEmpty()) {
// add the parsed column while we are parsing the first item
data.columns << match.captured(1);
}
dataItem << match.captured(2).toUInt();
continue;
}
// e.g. "VmFlags: rd ex mr mw me sd"
static QRegularExpression regexLine(QStringLiteral("^([^ ]+): +(.+)$"));
match = regexLine.match(line);
if (match.hasMatch()) {
if (data.rows.isEmpty()) {
// add the parsed column while we are parsing the first item
data.columns << match.captured(1);
}
dataItem << match.captured(2);
continue;
}
// e.g. "7f935d6a5000-7f935d6a6000 rw-p 00040000 08:02 7457 /usr/lib64/libxcb.so.1.1.0"
static QRegularExpression regexHeader(QStringLiteral("^([0-9A-Fa-f]+)-([0-9A-Fa-f]+) +([^ ]*) +([0-9A-Fa-f]+) "
"+([0-9A-Fa-f]+:[0-9A-Fa-f]+) +(\\d+) +(.*)$"));
match = regexHeader.match(line);
if (match.hasMatch()) {
// we have reached the next header so we need to store the parsed smapsDataItem
if (!dataItem.isEmpty()) {
data.rows << std::move(dataItem);
}
if (data.rows.isEmpty()) {
// add known columns while we are parsing the first item
data.columns << i18nc("@title:column", "Filename");
data.columns << i18nc("@title:column Start of the address space", "Start");
data.columns << i18nc("@title:column End of the address space", "End");
data.columns << i18nc("@title:column", "Permissions");
data.columns << i18nc("@title:column Offset into the file", "Offset");
data.columns << i18nc("@title:column", "Inode");
}
dataItem << match.captured(7); // filename
dataItem << match.captured(1).toULongLong(nullptr, 16); // start
dataItem << match.captured(2).toULongLong(nullptr, 16); // end
dataItem << match.captured(3); // permissions
dataItem << match.captured(4).toULongLong(nullptr, 16); // offset
dataItem << match.captured(6).toUInt(); // inode
}
}
if (!dataItem.isEmpty()) {
data.rows << std::move(dataItem);
}
}
}
m_dataModel->setData(std::move(data));
}
void MemoryMapsTab::onProxyModelChanged()
{
if (m_proxyModel->rowCount() > 0) {
m_placeholderLabel->hide();
} else {
if (m_proxyModel->filterRegularExpression().pattern().isEmpty()) {
m_placeholderLabel->setText(i18nc("@info:status", "No data to display"));
} else {
m_placeholderLabel->setText(i18nc("@info:status", "No data matching the filter"));
}
m_placeholderLabel->show();
}
}
void MemoryMapsTab::onSearchEditEditingFinished()
{
m_proxyModel->setFilterFixedString(m_searchEdit->text());
}
/*
* KSysGuard, the KDE System Guard
*
* SPDX-FileCopyrightText: 2022 Eugene Popov <popov895@ukr.net>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#ifndef _MemoryMapsTab_h_
#define _MemoryMapsTab_h_
#include <QWidget>
class QLabel;
class QLineEdit;
class QSortFilterProxyModel;
class KMessageWidget;
class MemoryMapsModel;
class MemoryMapsTab : public QWidget
{
Q_OBJECT
public:
explicit MemoryMapsTab(QWidget *parent = nullptr);
void setProcessId(long processId);
private Q_SLOTS:
void refresh();
void onProxyModelChanged();
void onSearchEditEditingFinished();
private:
long m_processId = 0;
MemoryMapsModel *m_dataModel = nullptr;
QSortFilterProxyModel *m_proxyModel = nullptr;
KMessageWidget *m_errorWidget = nullptr;
QLineEdit *m_searchEdit = nullptr;
QLabel *m_placeholderLabel = nullptr;
};
#endif // _MemoryMapsTab_h_
/*
* KSysGuard, the KDE System Guard
*
* SPDX-FileCopyrightText: 2022 Eugene Popov <popov895@ukr.net>
*
* SPDX-License-Identifier: LGPL-2.0-or-later
*/
#include "OpenFilesTab.h"
#include <QDir>
#include <QGraphicsOpacityEffect>
#include <QHeaderView>
#include <QLabel>
#include <QLayout>
#include <QLineEdit>
#include <QPushButton>
#include <QSortFilterProxyModel>
#include <QTimer>
#include <QTreeView>
#include <KLocalizedString>
#include <KMessageWidget>
#include <unistd.h>
#include <sys/stat.h>
QString fileTypeFromPath(const QString &path)
{
struct stat statbuf;
if (stat(qPrintable(path), &statbuf) == 0) {
if (S_ISREG(statbuf.st_mode) || S_ISLNK(statbuf.st_mode)) {
return i18nc("Device type", "File");
}
if (S_ISCHR(statbuf.st_mode)) {
return i18nc("Device type", "Character device");
}
if (S_ISBLK(statbuf.st_mode)) {
return i18nc("Device type", "Block device");
}
if (S_ISFIFO(statbuf.st_mode)) {
return i18nc("Device type", "Pipe");
}
if (S_ISSOCK(statbuf.st_mode)) {
return i18nc("Device type", "Socket");
}
}
return QString();
}
QString symLinkTargetFromPath(const QString &path)
{
struct stat statbuf;
if (lstat(qPrintable(path), &statbuf) == 0) {
QVarLengthArray<char, 256> symLinkTarget(statbuf.st_size + 1);
ssize_t count;
if ((count = readlink(qPrintable(path), symLinkTarget.data(), symLinkTarget.size() - 1)) > 0) {
symLinkTarget[count] = '\0';
return symLinkTarget.constData();
}
}