[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.
This commit is contained in:
Lukas Brübach 2026-10-13 20:29:49 +02:00
parent 3823dfa087
commit e36b59c2f5
2 changed files with 325 additions and 24 deletions

View file

@ -3,21 +3,31 @@
#include "abstract_client.h" #include "abstract_client.h"
#include "libcockatrice/protocol/pb/response.pb.h" #include "libcockatrice/protocol/pb/response.pb.h"
#include "libcockatrice/protocol/pb/response_user_alts.pb.h" #include "libcockatrice/protocol/pb/response_user_alts.pb.h"
#include "libcockatrice/protocol/pb/serverinfo_user_alt.pb.h"
#include <QAbstractItemView> #include <QAbstractItemView>
#include <QCheckBox>
#include <QDateTime> #include <QDateTime>
#include <QGridLayout>
#include <QGroupBox>
#include <QHBoxLayout> #include <QHBoxLayout>
#include <QHeaderView> #include <QHeaderView>
#include <QLabel> #include <QLabel>
#include <QLineEdit> #include <QLineEdit>
#include <QList>
#include <QPushButton> #include <QPushButton>
#include <QSpinBox> #include <QSpinBox>
#include <QTableWidget> #include <QTableWidget>
#include <QTableWidgetItem> #include <QTableWidgetItem>
#include <QTextEdit>
#include <QVBoxLayout> #include <QVBoxLayout>
#include <QWidget> #include <QWidget>
#include <libcockatrice/protocol/pb/command_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/moderator_commands.pb.h> #include <libcockatrice/protocol/pb/moderator_commands.pb.h>
#include <libcockatrice/protocol/pb/response_ban_history.pb.h>
#include <libcockatrice/protocol/pb/response_report_user_info.pb.h>
#include <libcockatrice/protocol/pb/response_warn_history.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_ban.pb.h>
#include <libcockatrice/protocol/pb/serverinfo_warning.pb.h>
#include <libcockatrice/protocol/pending_command.h> #include <libcockatrice/protocol/pending_command.h>
#include <string> #include <string>
@ -37,6 +47,21 @@ constexpr int COL_ALTS_ACTIVE = 10;
constexpr int COL_ALTS_COUNT = 11; constexpr int COL_ALTS_COUNT = 11;
constexpr int MAX_ALT_DEPTH = 3; 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 } // namespace
TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser) TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const QString &initialUser)
@ -69,27 +94,80 @@ TabAlts::TabAlts(TabSupervisor *_tabSupervisor, AbstractClient *_client, const Q
topBar->addWidget(depthSpin); topBar->addWidget(depthSpin);
topBar->addWidget(investigateButton); 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 = new QTableWidget(0, COL_ALTS_COUNT);
altsTable->setSelectionBehavior(QAbstractItemView::SelectRows); configureAltTable(altsTable);
altsTable->setSelectionMode(QAbstractItemView::SingleSelection);
altsTable->setEditTriggers(QAbstractItemView::NoEditTriggers);
altsTable->verticalHeader()->setVisible(false);
altsTable->setAlternatingRowColors(true);
altsTable->horizontalHeader()->setSectionResizeMode(QHeaderView::ResizeToContents);
altsTable->horizontalHeader()->setStretchLastSection(true);
connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) { connect(altsTable, &QTableWidget::cellDoubleClicked, this, [this](int row, int) {
auto *userItem = altsTable->item(row, COL_ALTS_USER); auto *userItem = altsTable->item(row, COL_ALTS_USER);
if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) { if (userItem != nullptr && !userItem->text().isEmpty() && userItem->text() != currentUser) {
investigate(userItem->text()); 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); auto *mainLayout = new QVBoxLayout(centralWidget);
mainLayout->addLayout(topBar); mainLayout->addLayout(topBar);
mainLayout->addLayout(filterBar);
mainLayout->addWidget(statusLabel); mainLayout->addWidget(statusLabel);
mainLayout->addWidget(altsTable); mainLayout->addWidget(altsTable, 3);
mainLayout->addWidget(detailsGroup, 2);
retranslateUi(); retranslateUi();
clearDetails();
investigate(initialUser); investigate(initialUser);
} }
@ -105,9 +183,22 @@ void TabAlts::retranslateUi()
statusLabel->setText(tr("Enter a user name to investigate.")); 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"), 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("Client ID"), tr("Registered"), tr("Last login"), tr("Warns"), tr("Bans"),
tr("Active")}); 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 QString TabAlts::formatEpoch(quint64 ts) const
@ -154,6 +245,9 @@ void TabAlts::investigateUser()
return; return;
} }
currentUser = userName; currentUser = userName;
lastAlts.clear();
selectedUser.clear();
clearDetails();
altsTable->setRowCount(0); altsTable->setRowCount(0);
statusLabel->setText(tr("Loading alts for %1...").arg(currentUser)); statusLabel->setText(tr("Loading alts for %1...").arg(currentUser));
requestAlts(userName, depthSpin->value()); requestAlts(userName, depthSpin->value());
@ -171,33 +265,207 @@ void TabAlts::requestAlts(const QString &userName, int depth)
void TabAlts::altsResponse(const Response &response) void TabAlts::altsResponse(const Response &response)
{ {
altsTable->setRowCount(0); lastAlts.clear();
if (response.response_code() != Response::RespOk) { if (response.response_code() != Response::RespOk) {
renderTable();
statusLabel->setText(tr("User %1 not found.").arg(currentUser)); statusLabel->setText(tr("User %1 not found.").arg(currentUser));
return; return;
} }
const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext); const Response_UserAlts &resp = response.GetExtension(Response_UserAlts::ext);
altsTable->setRowCount(resp.alts_size());
for (int i = 0; i < resp.alts_size(); ++i) { for (int i = 0; i < resp.alts_size(); ++i) {
const ServerInfo_UserAlt &alt = resp.alts(i); lastAlts << resp.alts(i);
altsTable->setItem(i, COL_ALTS_USER, new QTableWidgetItem(QString::fromStdString(alt.user_name()))); }
altsTable->setItem(i, COL_ALTS_VIA, 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<unsigned>(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->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() new QTableWidgetItem(alt.has_match_reason() && !alt.match_reason().empty()
? QString::fromStdString(alt.match_reason()) ? QString::fromStdString(alt.match_reason())
: QStringLiteral("-"))); : QStringLiteral("-")));
altsTable->setItem(i, COL_ALTS_DEPTH, altsTable->setItem(row, COL_ALTS_DEPTH,
new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-"))); new QTableWidgetItem(alt.has_depth() ? QString::number(alt.depth()) : QStringLiteral("-")));
altsTable->setItem( altsTable->setItem(
i, COL_ALTS_SHARED, row, COL_ALTS_SHARED,
new QTableWidgetItem(alt.has_shared_count() ? QString::number(alt.shared_count()) : QStringLiteral("-"))); 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(row, COL_ALTS_EMAIL, new QTableWidgetItem(QString::fromStdString(alt.email())));
altsTable->setItem(i, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid()))); altsTable->setItem(row, COL_ALTS_CLIENTID, new QTableWidgetItem(QString::fromStdString(alt.clientid())));
altsTable->setItem(i, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time()))); altsTable->setItem(row, COL_ALTS_REGISTERED, new QTableWidgetItem(formatEpoch(alt.registration_time())));
altsTable->setItem(i, COL_ALTS_LAST_LOGIN, new QTableWidgetItem(formatEpoch(alt.last_login()))); altsTable->setItem(row, 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(row, 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(row, 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_ACTIVE, new QTableWidgetItem(alt.is_active() ? tr("yes") : tr("no")));
++row;
}
if (hidden == 0) {
statusLabel->setText(tr("%1 account(s) related to %2.").arg(lastAlts.size()).arg(currentUser));
} else {
statusLabel->setText(tr("%1 shown, %2 hidden by filters (%3 total) related to %4.")
.arg(row)
.arg(hidden)
.arg(lastAlts.size())
.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(resp.alts_size()).arg(currentUser));
} }

View file

@ -3,16 +3,20 @@
#include "tab.h" #include "tab.h"
#include <QList>
#include <QString> #include <QString>
#include <QtGlobal> #include <QtGlobal>
#include <libcockatrice/protocol/pb/serverinfo_user_alt.pb.h>
#include <qtmetamacros.h> #include <qtmetamacros.h>
class AbstractClient; class AbstractClient;
class QCheckBox;
class QLabel; class QLabel;
class QLineEdit; class QLineEdit;
class QPushButton; class QPushButton;
class QSpinBox; class QSpinBox;
class QTableWidget; class QTableWidget;
class QTextEdit;
class Response; class Response;
class TabSupervisor; class TabSupervisor;
@ -20,6 +24,7 @@ class TabSupervisor;
* Staff alt-graph investigation tool. Shows accounts related to the * Staff alt-graph investigation tool. Shows accounts related to the
* investigated user (shared email, client id, or recent IP address), * investigated user (shared email, client id, or recent IP address),
* including transitive alts-of-alts, with per-row match evidence. * 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 class TabAlts : public Tab
{ {
@ -37,15 +42,26 @@ private slots:
void investigateUser(); void investigateUser();
void goBack(); void goBack();
void altsResponse(const Response &response); 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: private:
void loadUser(const QString &userName); void loadUser(const QString &userName);
void requestAlts(const QString &userName, int depth); 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; [[nodiscard]] QString formatEpoch(quint64 ts) const;
AbstractClient *client; AbstractClient *client;
QString currentUser; QString currentUser;
QString selectedUser;
QList<QString> backStack; QList<QString> backStack;
QList<ServerInfo_UserAlt> lastAlts;
QLineEdit *searchEdit; QLineEdit *searchEdit;
QSpinBox *depthSpin; QSpinBox *depthSpin;
@ -53,6 +69,23 @@ private:
QPushButton *backButton; QPushButton *backButton;
QLabel *statusLabel; QLabel *statusLabel;
QTableWidget *altsTable; 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 #endif // TAB_ALTS_H