From 4ad392d705dfa1c760c0dd199b9194b5507caf10 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Lukas=20Br=C3=BCbach?= Date: Tue, 13 Oct 2026 20:29:49 +0200 Subject: [PATCH] [Client] Alt tab filters and unified user timeline TabAlts gains client-side match filters (Email / Client ID / IP toggles plus a hide-IPs-shared-by-more-than-N-users threshold for VPN noise) with a shown/hidden status count, and a Selected user panel that loads the row account's counts, admin notes, warning history, and ban history in one place. Detail requests are guarded against stale selections. --- .../src/interface/widgets/tabs/tab_alts.cpp | 326 ++++++++++++++++-- .../src/interface/widgets/tabs/tab_alts.h | 33 ++ 2 files changed, 331 insertions(+), 28 deletions(-) diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp index c8fa57343..a72d5885e 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.cpp +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.cpp @@ -4,21 +4,31 @@ #include "abstract_client.h" #include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h" -#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.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 @@ -38,6 +48,21 @@ constexpr int COL_ALTS_ACTIVE = 10; constexpr int COL_ALTS_COUNT = 11; constexpr int MAX_ALT_DEPTH = 3; +// Starting default only (adjustable 1-100000): shared VPN exits routinely +// cover dozens of users, so 10 hides the obvious VPN noise but keeps +// small shared networks visible. +constexpr int DEFAULT_VPN_THRESHOLD = 10; + +void configureAltTable(QTableWidget *table) +{ + table->setSelectionBehavior(QAbstractItemView::SelectRows); + table->setSelectionMode(QAbstractItemView::SingleSelection); + table->setEditTriggers(QAbstractItemView::NoEditTriggers); + table->verticalHeader()->setVisible(false); + table->setAlternatingRowColors(true); + table->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); + table->horizontalHeader()->setStretchLastSection(true); +} } // namespace TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) @@ -70,27 +95,80 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q topBar->addWidget(depthSpin); topBar->addWidget(investigateButton); + emailCheck = new QCheckBox; + emailCheck->setChecked(true); + connect(emailCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + clientIdCheck = new QCheckBox; + clientIdCheck->setChecked(true); + connect(clientIdCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + ipCheck = new QCheckBox; + ipCheck->setChecked(true); + connect(ipCheck, &QCheckBox::toggled, this, &TabAlts::onFilterChanged); + vpnLabel = new QLabel; + vpnSpin = new QSpinBox; + vpnSpin->setMinimum(1); + vpnSpin->setMaximum(100000); + vpnSpin->setValue(DEFAULT_VPN_THRESHOLD); + connect(vpnSpin, &QSpinBox::valueChanged, this, &TabAlts::onFilterChanged); + + auto *filterBar = new QHBoxLayout; + filterBar->addWidget(emailCheck); + filterBar->addWidget(clientIdCheck); + filterBar->addWidget(ipCheck); + filterBar->addWidget(vpnLabel); + filterBar->addWidget(vpnSpin); + filterBar->addStretch(); + altsTable = new QTableWidget(0, COL_ALTS_COUNT); - altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); - altsTable->setSelectionMode(QAbstractItemView::SingleSelection); - altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers); - altsTable->verticalHeader()->setVisible(false); - altsTable->setAlternatingRowColors(true); - altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents); - altsTable->horizontalHeader()->setStretchLastSection(true); + configureAltTable(altsTable); connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { auto *userItem = altsTable->item(row, COL_ALTS_USER); if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { investigate(userItem->text()); } }); + connect(altsTable, &QTableWidget::itemSelectionChanged, this, &TabAlts::onAltSelected); + + detailNameValue = new QLabel; + detailCountsValue = new QLabel; + detailNotesEdit = new QTextEdit; + detailNotesEdit->setReadOnly(true); + // Compact cap so the warn/ban tables stay visible without scrolling the tab. + detailNotesEdit->setMaximumHeight(60); + warnsTable = new QTableWidget(0, 3); + configureAltTable(warnsTable); + bansTable = new QTableWidget(0, 4); + configureAltTable(bansTable); + + auto *detailGrid = new QGridLayout; + detailNameLabel = new QLabel; + detailCountsLabel = new QLabel; + detailNotesLabel = new QLabel; + detailGrid->addWidget(detailNameLabel, 0, 0); + detailGrid->addWidget(detailNameValue, 0, 1); + detailGrid->addWidget(detailCountsLabel, 0, 2); + detailGrid->addWidget(detailCountsValue, 0, 3); + detailGrid->addWidget(detailNotesLabel, 1, 0); + detailGrid->addWidget(detailNotesEdit, 1, 1, 1, 3); + + auto *detailTables = new QHBoxLayout; + detailTables->addWidget(warnsTable); + detailTables->addWidget(bansTable); + + detailsGroup = new QGroupBox; + auto *detailsLayout = new QVBoxLayout(detailsGroup); + detailsLayout->addLayout(detailGrid); + detailsLayout->addLayout(detailTables); auto *mainLayout = new QVBoxLayout(centralWidget); mainLayout->addLayout(topBar); + mainLayout->addLayout(filterBar); mainLayout->addWidget(statusLabel); - mainLayout->addWidget(altsTable); + mainLayout->addWidget(altsTable, 3); + mainLayout->addWidget(detailsGroup, 2); retranslateUi(); + clearDetails(); investigate(initialUser); } @@ -106,9 +184,22 @@ void TabAlts::retranslateUi() statusLabel->setText(tr("Enter a user name to investigate.")); } + emailCheck->setText(tr("Email")); + clientIdCheck->setText(tr("Client ID")); + ipCheck->setText(tr("IP")); + vpnLabel->setText(tr("Hide IPs shared by more than")); + vpnSpin->setSuffix(tr(" users")); + altsTable->setHorizontalHeaderLabels({tr("User"), tr("Via"), tr("Depth"), tr("Shared"), tr("eMail"), tr("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"), tr("Active")}); + warnsTable->setHorizontalHeaderLabels({tr("Warning time"), tr("Moderator"), tr("Reason")}); + bansTable->setHorizontalHeaderLabels({tr("Ban time"), tr("Moderator"), tr("Length"), tr("Reason")}); + + detailsGroup->setTitle(tr("Selected user")); + detailNameLabel->setText(tr("Name:")); + detailCountsLabel->setText(tr("Counts:")); + detailNotesLabel->setText(tr("Admin notes:")); } QString TabAlts::formatEpoch(quint64 ts) const @@ -152,6 +243,9 @@ void TabAlts::investigateUser() return; } currentUser = userName; + lastAlts.clear(); + selectedUser.clear(); + clearDetails(); altsTable->setRowCount(0); statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); requestAlts(userName, depthSpin->value()); @@ -173,7 +267,7 @@ void TabAlts::requestAlts(const QString &userName, int depth) void TabAlts::altsResponse(const Response &response) { - altsTable->setRowCount(0); + lastAlts.clear(); if (response.response_code() != Response::RespOk) { statusLabel->setText(tr("Could not load alts for %1.").arg(currentUser)); return; @@ -186,31 +280,207 @@ void TabAlts::altsResponse(const Response &response) return; } - altsTable->setRowCount(resp.alts_size()); - int relatedCount = 0; for (int i = 0; i < resp.alts_size(); ++i) { - const ServerInfo_UserAlt &alt = resp.alts(i); - // The server appends the frontier user as a depth-0 seed row. - if (!alt.has_depth() || alt.depth() > 0) { - ++relatedCount; + lastAlts << resp.alts(i); + } + renderTable(); +} + +bool TabAlts::rowVisible(const ServerInfo_UserAlt &alt) const +{ + const QString name = QString::fromStdString(alt.user_name()); + if (name == currentUser) { + return true; + } + if (!alt.has_match_reason() || alt.match_reason().empty()) { + return true; + } + const QString reason = QString::fromStdString(alt.match_reason()); + if (reason == QStringLiteral("email")) { + return emailCheck->isChecked(); + } + if (reason.startsWith(QStringLiteral("clientid:"))) { + return clientIdCheck->isChecked(); + } + if (reason.startsWith(QStringLiteral("ip:"))) { + return ipCheck->isChecked() && + (!alt.has_shared_count() || alt.shared_count() <= static_cast(vpnSpin->value())); + } + return true; +} + +void TabAlts::renderTable() +{ + altsTable->setRowCount(0); + int hidden = 0; + int row = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!rowVisible(alt)) { + ++hidden; + continue; } - altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); - altsTable->setItem(i, COL_ALTS_VIA, + altsTable->insertRow(row); + altsTable->setItem(row, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); + altsTable->setItem(row, COL_ALTS_VIA, new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty() ? QString::fromStdString(alt.match_reason()) : QStringLiteral("-"))); - altsTable->setItem(i, COL_ALTS_DEPTH, + altsTable->setItem(row, COL_ALTS_DEPTH, new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); altsTable->setItem( - i, COL_ALTS_SHARED, + row, COL_ALTS_SHARED, new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); - altsTable->setItem(i, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); - altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); - altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); - altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); - altsTable->setItem(i, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); - altsTable->setItem(i, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); - altsTable->setItem(i, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + altsTable->setItem(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email()))); + altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); + altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); + altsTable->setItem(row, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); + altsTable->setItem(row, COL_ALTS_WARNS, new QTableWidgetItem(QString::number(alt.warn_count()))); + altsTable->setItem(row, COL_ALTS_BANS, new QTableWidgetItem(QString::number(alt.ban_count()))); + altsTable->setItem(row, COL_ALTS_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no"))); + ++row; + } + // The server appends the frontier user as a depth-0 seed row; it is the + // investigated user, not one of their related accounts. + int relatedCount = 0; + for (const ServerInfo_UserAlt &alt : lastAlts) { + if (!alt.has_depth() || alt.depth() > 0) { + ++relatedCount; + } + } + if (hidden == 0) { + statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); + } else { + statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.") + .arg(row) + .arg(hidden) + .arg(relatedCount) + .arg(currentUser)); + } +} + +void TabAlts::onFilterChanged() +{ + if (lastAlts.isEmpty()) { + return; + } + renderTable(); +} + +void TabAlts::onAltSelected() +{ + const auto selected = altsTable->selectedItems(); + if (selected.isEmpty()) { + selectedUser.clear(); + clearDetails(); + return; + } + auto *userItem = altsTable->item(selected.first()->row(), COL_ALTS_USER); + if (userItem == nullptr || userItem->text().isEmpty() || userItem->text() == selectedUser) { + return; + } + selectedUser = userItem->text(); + requestDetails(selectedUser); +} + +void TabAlts::requestDetails(const QString &userName) +{ + detailNameValue->setText(userName); + detailCountsValue->setText(tr("Loading...")); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); + + Command_ReportUserInfo infoCmd; + infoCmd.set_user_name(userName.toStdString()); + PendingCommand *infoPend = client->prepareModeratorCommand(infoCmd); + connect(infoPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + userDetailsResponse(response); + } + }); + client->sendCommand(infoPend); + + Command_GetWarnHistory warnCmd; + warnCmd.set_user_name(userName.toStdString()); + PendingCommand *warnPend = client->prepareModeratorCommand(warnCmd); + connect(warnPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + warnHistoryResponse(response); + } + }); + client->sendCommand(warnPend); + + Command_GetBanHistory banCmd; + banCmd.set_user_name(userName.toStdString()); + PendingCommand *banPend = client->prepareModeratorCommand(banCmd); + connect(banPend, &PendingCommand::finished, this, [this, userName](const Response &response) { + if (userName == selectedUser) { + banHistoryResponse(response); + } + }); + client->sendCommand(banPend); +} + +void TabAlts::clearDetails() +{ + detailNameValue->clear(); + detailCountsValue->clear(); + detailNotesEdit->clear(); + warnsTable->setRowCount(0); + bansTable->setRowCount(0); +} + +void TabAlts::userDetailsResponse(const Response &response) +{ + if (response.response_code() != Response::RespOk) { + detailCountsValue->setText(tr("Error loading user info.")); + return; + } + + const Response_ReportUserInfo &resp = response.GetExtension(Response_ReportUserInfo::ext); + detailCountsValue->setText(tr("Reports: %1 Bans: %2 Warnings: %3") + .arg(resp.total_reports()) + .arg(resp.total_bans()) + .arg(resp.total_warns())); + + if (resp.has_admin_notes() && !resp.admin_notes().empty()) { + detailNotesEdit->setPlainText(QString::fromStdString(resp.admin_notes())); + } else { + detailNotesEdit->setPlainText(tr("(none)")); + } +} + +void TabAlts::warnHistoryResponse(const Response &response) +{ + warnsTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_WarnHistory &resp = response.GetExtension(Response_WarnHistory::ext); + warnsTable->setRowCount(resp.warn_list_size()); + for (int i = 0; i < resp.warn_list_size(); ++i) { + const ServerInfo_Warning &warn = resp.warn_list(i); + warnsTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(warn.time_of()))); + warnsTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(warn.admin_name()))); + warnsTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(warn.reason()))); + } +} + +void TabAlts::banHistoryResponse(const Response &response) +{ + bansTable->setRowCount(0); + if (response.response_code() != Response::RespOk) { + return; + } + + const Response_BanHistory &resp = response.GetExtension(Response_BanHistory::ext); + bansTable->setRowCount(resp.ban_list_size()); + for (int i = 0; i < resp.ban_list_size(); ++i) { + const ServerInfo_Ban &ban = resp.ban_list(i); + bansTable->setItem(i, 0, new QTableWidgetItem(QString::fromStdString(ban.ban_time()))); + bansTable->setItem(i, 1, new QTableWidgetItem(QString::fromStdString(ban.admin_name()))); + bansTable->setItem(i, 2, new QTableWidgetItem(QString::fromStdString(ban.ban_length()))); + bansTable->setItem(i, 3, new QTableWidgetItem(QString::fromStdString(ban.ban_reason()))); } - statusLabel->setText(tr("%1 account(s) related to %2.").arg(relatedCount).arg(currentUser)); } diff --git a/cockatrice/src/interface/widgets/tabs/tab_alts.h b/cockatrice/src/interface/widgets/tabs/tab_alts.h index de5a11ab8..65833f90d 100644 --- a/cockatrice/src/interface/widgets/tabs/tab_alts.h +++ b/cockatrice/src/interface/widgets/tabs/tab_alts.h @@ -3,16 +3,20 @@ #include "tab.h" +#include #include #include +#include #include class AbstractClient; +class QCheckBox; class QLabel; class QLineEdit; class QPushButton; class QSpinBox; class QTableWidget; +class QTextEdit; class Response; class TabSupervisor; @@ -20,6 +24,7 @@ class TabSupervisor; * Staff alt-graph investigation tool. Shows accounts related to the * investigated user (shared email, client id, or recent IP address), * including transitive alts-of-alts, with per-row match evidence. + * Selecting a row loads that account's warnings, bans, and admin notes. */ class TabAlts : public Tab { @@ -37,15 +42,26 @@ private slots: void investigateUser(); void goBack(); void altsResponse(const Response &response); + void onFilterChanged(); + void onAltSelected(); + void userDetailsResponse(const Response &response); + void warnHistoryResponse(const Response &response); + void banHistoryResponse(const Response &response); private: void loadUser(const QString &userName); void requestAlts(const QString &userName, int depth); + void requestDetails(const QString &userName); + void clearDetails(); + void renderTable(); + [[nodiscard]] bool rowVisible(const ServerInfo_UserAlt &alt) const; [[nodiscard]] QString formatEpoch(quint64 ts) const; AbstractClient *client; QString currentUser; + QString selectedUser; QList backStack; + QList lastAlts; QLineEdit *searchEdit; QSpinBox *depthSpin; @@ -53,6 +69,23 @@ private: QPushButton *backButton; QLabel *statusLabel; QTableWidget *altsTable; + + QCheckBox *emailCheck; + QCheckBox *clientIdCheck; + QCheckBox *ipCheck; + QLabel *vpnLabel; + QSpinBox *vpnSpin; + + QLabel *detailNameValue; + QLabel *detailCountsValue; + QTextEdit *detailNotesEdit; + QTableWidget *warnsTable; + QTableWidget *bansTable; + + QGroupBox *detailsGroup; + QLabel *detailNameLabel; + QLabel *detailCountsLabel; + QLabel *detailNotesLabel; }; #endif // TAB_ALTS_H