mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-11-05 01:23:24 -08:00
[Logs] Browse room history without filters with paginated infinite scroll (#7531)
* [Logs] Browse room history without filters with paginated infinite scroll * [Logs] Continue pagination when switching log tabs --------- Co-authored-by: Lukas Brübach <Bruebach.Lukas@bdosecurity.de>
This commit is contained in:
parent
3cf8bdb00e
commit
94e6281d87
7 changed files with 321 additions and 91 deletions
|
|
@ -13,6 +13,7 @@
|
||||||
#include <QMessageBox>
|
#include <QMessageBox>
|
||||||
#include <QPushButton>
|
#include <QPushButton>
|
||||||
#include <QRadioButton>
|
#include <QRadioButton>
|
||||||
|
#include <QScrollBar>
|
||||||
#include <QSharedPointer>
|
#include <QSharedPointer>
|
||||||
#include <QSizePolicy>
|
#include <QSizePolicy>
|
||||||
#include <QSpinBox>
|
#include <QSpinBox>
|
||||||
|
|
@ -21,6 +22,7 @@
|
||||||
#include <QTableWidgetItem>
|
#include <QTableWidgetItem>
|
||||||
#include <QVBoxLayout>
|
#include <QVBoxLayout>
|
||||||
#include <QWidget>
|
#include <QWidget>
|
||||||
|
#include <QtGlobal>
|
||||||
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
#include <libcockatrice/network/client/abstract/abstract_client.h>
|
||||||
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
||||||
#include <libcockatrice/protocol/pb/response_viewlog_history.pb.h>
|
#include <libcockatrice/protocol/pb/response_viewlog_history.pb.h>
|
||||||
|
|
@ -31,6 +33,17 @@
|
||||||
|
|
||||||
class TabSupervisor;
|
class TabSupervisor;
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
/** @brief Identifies the newest row of a page well enough to recognise a repeated page. */
|
||||||
|
QString pageStartKey(const Response_ViewLogHistory &response)
|
||||||
|
{
|
||||||
|
const ServerInfo_ChatMessage &first = response.log_message(0);
|
||||||
|
return QString::fromStdString(first.time()) + QChar(0x1f) + QString::fromStdString(first.sender_name()) +
|
||||||
|
QChar(0x1f) + QString::fromStdString(first.message());
|
||||||
|
}
|
||||||
|
} // namespace
|
||||||
|
|
||||||
TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands)
|
TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _canUseDeveloperCommands)
|
||||||
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
|
: Tab(_tabSupervisor), client(_client), canUseDeveloperCommands(_canUseDeveloperCommands)
|
||||||
{
|
{
|
||||||
|
|
@ -52,12 +65,20 @@ TabLog::TabLog(TabSupervisor *_tabSupervisor, AbstractClient *_client, bool _can
|
||||||
chatTable->setHorizontalHeaderLabels(
|
chatTable->setHorizontalHeaderLabels(
|
||||||
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";"));
|
QString(tr("Time;SenderName;SenderIP;Message;TargetID;TargetName")).split(";"));
|
||||||
|
|
||||||
QTabWidget *tabManager = new QTabWidget();
|
tabManager = new QTabWidget();
|
||||||
tabManager->addTab(roomTable, tr("Room Logs"));
|
tabManager->addTab(roomTable, tr("Room Logs"));
|
||||||
tabManager->addTab(gameTable, tr("Game Logs"));
|
tabManager->addTab(gameTable, tr("Game Logs"));
|
||||||
tabManager->addTab(chatTable, tr("Chat Logs"));
|
tabManager->addTab(chatTable, tr("Chat Logs"));
|
||||||
setCentralWidget(tabManager);
|
setCentralWidget(tabManager);
|
||||||
|
|
||||||
|
connect(roomTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
|
||||||
|
connect(gameTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
|
||||||
|
connect(chatTable->verticalScrollBar(), &QScrollBar::valueChanged, this, &TabLog::checkFetchMore);
|
||||||
|
// A tab that gains only a few rows has nothing to scroll, so its scrollbar never fires:
|
||||||
|
// switching to it would leave its next page unfetched until the moderator scrolled
|
||||||
|
// another tab. Re-check on every tab switch so the visible table can continue the browse.
|
||||||
|
connect(tabManager, &QTabWidget::currentChanged, this, &TabLog::checkFetchMore);
|
||||||
|
|
||||||
createDock();
|
createDock();
|
||||||
restartLayout();
|
restartLayout();
|
||||||
clearClicked();
|
clearClicked();
|
||||||
|
|
@ -74,12 +95,9 @@ void TabLog::retranslateUi()
|
||||||
|
|
||||||
void TabLog::getClicked()
|
void TabLog::getClicked()
|
||||||
{
|
{
|
||||||
if (findUsername->text().isEmpty() && findIPAddress->text().isEmpty() && findGameName->text().isEmpty() &&
|
// Empty filters are a valid browse: room/game history going back from
|
||||||
findGameID->text().isEmpty() && findMessage->text().isEmpty()) {
|
// today, paginated. Only require a date range + location, both of which
|
||||||
QMessageBox::critical(this, tr("Error"), tr("You must select at least one filter."));
|
// get sensible defaults below so a bare "Get" just works.
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) {
|
if (!lastHour->isChecked() && !today->isChecked() && !pastDays->isChecked()) {
|
||||||
pastDays->setChecked(true);
|
pastDays->setChecked(true);
|
||||||
pastXDays->setValue(20);
|
pastXDays->setValue(20);
|
||||||
|
|
@ -99,39 +117,71 @@ void TabLog::getClicked()
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maximumResults->value() == 0) {
|
if (maximumResults->value() == 0) {
|
||||||
maximumResults->setValue(1000);
|
maximumResults->setValue(200);
|
||||||
}
|
}
|
||||||
|
pageSize = qBound(1, maximumResults->value(), 1000);
|
||||||
|
|
||||||
int dateRange = 0;
|
// Build the command once, here, and let every page copy it: rebuilding it from the
|
||||||
if (lastHour->isChecked()) {
|
// widgets per page would run page 2+ against filters edited after Get was clicked,
|
||||||
dateRange = 1;
|
// appending rows selected by a different query under the first page's results.
|
||||||
}
|
currentQuery.Clear();
|
||||||
|
currentQuery.set_user_name(findUsername->text().toStdString());
|
||||||
if (today->isChecked()) {
|
currentQuery.set_ip_address(findIPAddress->text().toStdString());
|
||||||
dateRange = 24;
|
currentQuery.set_game_name(findGameName->text().toStdString());
|
||||||
}
|
currentQuery.set_game_id(findGameID->text().toStdString());
|
||||||
|
currentQuery.set_message(findMessage->text().toStdString());
|
||||||
if (pastDays->isChecked()) {
|
|
||||||
dateRange = pastXDays->value() * 24;
|
|
||||||
}
|
|
||||||
|
|
||||||
Command_ViewLogHistory cmd;
|
|
||||||
cmd.set_user_name(findUsername->text().toStdString());
|
|
||||||
cmd.set_ip_address(findIPAddress->text().toStdString());
|
|
||||||
cmd.set_game_name(findGameName->text().toStdString());
|
|
||||||
cmd.set_game_id(findGameID->text().toStdString());
|
|
||||||
cmd.set_message(findMessage->text().toStdString());
|
|
||||||
if (mainRoom->isChecked()) {
|
if (mainRoom->isChecked()) {
|
||||||
cmd.add_log_location("room");
|
currentQuery.add_log_location("room");
|
||||||
};
|
}
|
||||||
if (gameRoom->isChecked()) {
|
if (gameRoom->isChecked()) {
|
||||||
cmd.add_log_location("game");
|
currentQuery.add_log_location("game");
|
||||||
};
|
}
|
||||||
if (privateChat->isChecked()) {
|
if (privateChat->isChecked()) {
|
||||||
cmd.add_log_location("chat");
|
currentQuery.add_log_location("chat");
|
||||||
};
|
}
|
||||||
cmd.set_date_range(dateRange);
|
currentQuery.set_date_range(dateRangeForSelection());
|
||||||
cmd.set_maximum_results(maximumResults->value());
|
currentQuery.set_maximum_results(pageSize);
|
||||||
|
hasCurrentQuery = true;
|
||||||
|
snapshotBefore.clear();
|
||||||
|
lastPageStartKey.clear();
|
||||||
|
|
||||||
|
// Fresh search: reset pagination state and clear previous pages.
|
||||||
|
currentOffset = 0;
|
||||||
|
roomCounter = 0;
|
||||||
|
gameCounter = 0;
|
||||||
|
chatCounter = 0;
|
||||||
|
roomTable->setRowCount(0);
|
||||||
|
gameTable->setRowCount(0);
|
||||||
|
chatTable->setRowCount(0);
|
||||||
|
|
||||||
|
fetchPage(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
int TabLog::dateRangeForSelection() const
|
||||||
|
{
|
||||||
|
if (lastHour->isChecked()) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (today->isChecked()) {
|
||||||
|
return 24;
|
||||||
|
}
|
||||||
|
if (pastDays->isChecked()) {
|
||||||
|
return pastXDays->value() * 24;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
void TabLog::fetchPage(int offset)
|
||||||
|
{
|
||||||
|
if (isFetching || !hasCurrentQuery) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Command_ViewLogHistory cmd = currentQuery;
|
||||||
|
cmd.set_offset(offset);
|
||||||
|
if (!snapshotBefore.isEmpty()) {
|
||||||
|
cmd.set_before_time(snapshotBefore.toStdString());
|
||||||
|
}
|
||||||
|
|
||||||
PendingCommand *pend;
|
PendingCommand *pend;
|
||||||
if (canUseDeveloperCommands) {
|
if (canUseDeveloperCommands) {
|
||||||
|
|
@ -141,10 +191,42 @@ void TabLog::getClicked()
|
||||||
pend = client->prepareModeratorCommand(cmd);
|
pend = client->prepareModeratorCommand(cmd);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
isFetching = true;
|
||||||
|
getButton->setEnabled(false);
|
||||||
|
|
||||||
connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse);
|
connect(pend, &PendingCommand::finished, this, &TabLog::viewLogHistory_processResponse);
|
||||||
client->sendCommand(pend);
|
client->sendCommand(pend);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabLog::checkFetchMore()
|
||||||
|
{
|
||||||
|
if (isFetching || !hasMoreResults) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only the table the moderator is looking at may pull the next page. Checking all
|
||||||
|
// three would make an empty or hidden table nearBottom by definition (maximum() and
|
||||||
|
// value() are both zero), so a room-only browse chained page after page without any
|
||||||
|
// scrolling - pulling the whole range with a growing OFFSET scan per page, and
|
||||||
|
// looping forever against a server that ignores `offset`.
|
||||||
|
auto *table = qobject_cast<QTableWidget *>(tabManager->currentWidget());
|
||||||
|
if (table == nullptr || table->rowCount() == 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ScrollPerItem (the default) counts rows, not pixels: `value` is the top visible row
|
||||||
|
// and `maximum` the last one that can be scrolled to. Prefetch half a page ahead so
|
||||||
|
// scrolling never visibly stalls, without the fixed 300-row threshold firing the next
|
||||||
|
// page the moment a page loads.
|
||||||
|
const QScrollBar *bar = table->verticalScrollBar();
|
||||||
|
const int prefetchRows = qMax(1, pageSize / 2);
|
||||||
|
if (bar->maximum() - bar->value() > prefetchRows) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPage(currentOffset);
|
||||||
|
}
|
||||||
|
|
||||||
void TabLog::clearClicked()
|
void TabLog::clearClicked()
|
||||||
{
|
{
|
||||||
findUsername->clear();
|
findUsername->clear();
|
||||||
|
|
@ -166,6 +248,10 @@ void TabLog::clearClicked()
|
||||||
pastDays->setAutoExclusive(true);
|
pastDays->setAutoExclusive(true);
|
||||||
today->setAutoExclusive(true);
|
today->setAutoExclusive(true);
|
||||||
lastHour->setAutoExclusive(true);
|
lastHour->setAutoExclusive(true);
|
||||||
|
currentOffset = 0;
|
||||||
|
hasMoreResults = false;
|
||||||
|
hasCurrentQuery = false;
|
||||||
|
snapshotBefore.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabLog::createDock()
|
void TabLog::createDock()
|
||||||
|
|
@ -209,14 +295,15 @@ void TabLog::createDock()
|
||||||
pastXDays = new QSpinBox;
|
pastXDays = new QSpinBox;
|
||||||
pastXDays->setMaximum(20);
|
pastXDays->setMaximum(20);
|
||||||
|
|
||||||
labelMaximum = new QLabel(tr("Maximum Results: "));
|
labelMaximum = new QLabel(tr("Results per page: "));
|
||||||
maximumResults = new QSpinBox;
|
maximumResults = new QSpinBox;
|
||||||
maximumResults->setMaximum(1000);
|
maximumResults->setMaximum(1000);
|
||||||
|
maximumResults->setToolTip(tr("Page size for smooth scrolling: more rows load automatically as you scroll."));
|
||||||
|
|
||||||
labelDescription = new QLabel(tr(
|
labelDescription = new QLabel(tr("Leave filters empty to browse recent room and game messages.\n"
|
||||||
"At least one filter is required.\nThe more information you put in, the more specific your results will be."));
|
"More rows load automatically as you scroll."));
|
||||||
|
|
||||||
getButton = new QPushButton(tr("Get User Logs"));
|
getButton = new QPushButton(tr("Get Logs"));
|
||||||
getButton->setAutoDefault(true);
|
getButton->setAutoDefault(true);
|
||||||
connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked);
|
connect(getButton, &QPushButton::clicked, this, &TabLog::getClicked);
|
||||||
|
|
||||||
|
|
@ -262,7 +349,7 @@ void TabLog::createDock()
|
||||||
maxResultsGrid->addWidget(labelMaximum, 0, 0);
|
maxResultsGrid->addWidget(labelMaximum, 0, 0);
|
||||||
maxResultsGrid->addWidget(maximumResults, 0, 1);
|
maxResultsGrid->addWidget(maximumResults, 0, 1);
|
||||||
|
|
||||||
maxResultsGroupBox = new QGroupBox(tr("Maximum Results"));
|
maxResultsGroupBox = new QGroupBox(tr("Results per page"));
|
||||||
maxResultsGroupBox->setLayout(maxResultsGrid);
|
maxResultsGroupBox->setLayout(maxResultsGrid);
|
||||||
|
|
||||||
descriptionGrid = new QGridLayout;
|
descriptionGrid = new QGridLayout;
|
||||||
|
|
@ -295,84 +382,134 @@ void TabLog::createDock()
|
||||||
searchDock->setWidget(searchDockContents);
|
searchDock->setWidget(searchDockContents);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void TabLog::appendChatMessage(QTableWidget *table,
|
||||||
|
int &counter,
|
||||||
|
const QString &time,
|
||||||
|
const QString &senderName,
|
||||||
|
const QString &senderIp,
|
||||||
|
const QString &messageText,
|
||||||
|
const QString &targetId,
|
||||||
|
const QString &targetName,
|
||||||
|
const QString &channel)
|
||||||
|
{
|
||||||
|
table->insertRow(counter);
|
||||||
|
table->setItem(counter, 0, new QTableWidgetItem(time));
|
||||||
|
table->setItem(counter, 1, new QTableWidgetItem(senderName));
|
||||||
|
table->setItem(counter, 2, new QTableWidgetItem(senderIp));
|
||||||
|
table->setItem(counter, 3, new QTableWidgetItem(messageText));
|
||||||
|
table->setItem(counter, 4, new QTableWidgetItem(targetId));
|
||||||
|
table->setItem(counter, 5, new QTableWidgetItem(targetName));
|
||||||
|
if (table->columnCount() > 6) {
|
||||||
|
table->setItem(counter, 6, new QTableWidgetItem(channel));
|
||||||
|
}
|
||||||
|
++counter;
|
||||||
|
}
|
||||||
|
|
||||||
void TabLog::viewLogHistory_processResponse(const Response &resp)
|
void TabLog::viewLogHistory_processResponse(const Response &resp)
|
||||||
{
|
{
|
||||||
const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext);
|
const Response_ViewLogHistory &response = resp.GetExtension(Response_ViewLogHistory::ext);
|
||||||
|
const bool isFirstPage = (currentOffset == 0);
|
||||||
|
isFetching = false;
|
||||||
|
getButton->setEnabled(true);
|
||||||
|
|
||||||
if (resp.response_code() != Response::RespOk) {
|
if (resp.response_code() != Response::RespOk) {
|
||||||
QMessageBox::critical(static_cast<QWidget *>(parent()), tr("Message History"),
|
QMessageBox::critical(static_cast<QWidget *>(parent()), tr("Message History"),
|
||||||
tr("Failed to collect message history information."));
|
tr("Failed to collect message history information."));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (response.log_message_size() == 0) {
|
if (isFirstPage && response.log_message_size() == 0) {
|
||||||
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Message History"),
|
QMessageBox::information(static_cast<QWidget *>(parent()), tr("Message History"),
|
||||||
tr("There are no messages for the selected filters."));
|
tr("There are no messages for the selected filters."));
|
||||||
|
hasMoreResults = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
int roomCounter = 0, gameCounter = 0, chatCounter = 0;
|
const QString thisPageStart = response.log_message_size() > 0 ? pageStartKey(response) : QString();
|
||||||
roomTable->setRowCount(roomCounter);
|
|
||||||
gameTable->setRowCount(gameCounter);
|
if (isFirstPage) {
|
||||||
chatTable->setRowCount(chatCounter);
|
// Rows come newest first, so the first one fixes the snapshot every later page is
|
||||||
|
// bounded by. Without it, each message logged between two page fetches pushes the
|
||||||
|
// window down by one and repeats a row the moderator already has.
|
||||||
|
snapshotBefore = QString::fromStdString(response.log_message(0).time());
|
||||||
|
} else if (!response.has_has_more() && !thisPageStart.isEmpty() && thisPageStart == lastPageStartKey) {
|
||||||
|
// A server that predates `offset` ignores it and returns the same page again. It
|
||||||
|
// also predates `has_more`, so the full-page heuristic below can never prove
|
||||||
|
// exhaustion and every scroll would append the same rows forever. The identical
|
||||||
|
// first row is the proof; stop instead of looping.
|
||||||
|
hasMoreResults = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
lastPageStartKey = thisPageStart;
|
||||||
|
|
||||||
for (int i = 0; i < response.log_message_size(); ++i) {
|
for (int i = 0; i < response.log_message_size(); ++i) {
|
||||||
ServerInfo_ChatMessage message = response.log_message(i);
|
ServerInfo_ChatMessage message = response.log_message(i);
|
||||||
const QString targetType = QString::fromStdString(message.target_type());
|
const QString targetType = QString::fromStdString(message.target_type());
|
||||||
if (targetType == "room" || targetType == "room_channel") {
|
if (targetType == "room" || targetType == "room_channel") {
|
||||||
roomTable->insertRow(roomCounter);
|
appendChatMessage(roomTable, roomCounter, QString::fromStdString(message.time()),
|
||||||
roomTable->setItem(roomCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
QString::fromStdString(message.sender_name()),
|
||||||
roomTable->setItem(roomCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
|
||||||
roomTable->setItem(roomCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
|
QString::fromStdString(message.target_id()),
|
||||||
roomTable->setItem(roomCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
|
QString::fromStdString(message.target_name()), QString::fromStdString(message.channel()));
|
||||||
roomTable->setItem(roomCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
|
|
||||||
roomTable->setItem(roomCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
|
|
||||||
roomTable->setItem(roomCounter, 6, new QTableWidgetItem(QString::fromStdString(message.channel())));
|
|
||||||
++roomCounter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetType == "game") {
|
if (targetType == "game") {
|
||||||
gameTable->insertRow(gameCounter);
|
appendChatMessage(gameTable, gameCounter, QString::fromStdString(message.time()),
|
||||||
gameTable->setItem(gameCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
QString::fromStdString(message.sender_name()),
|
||||||
gameTable->setItem(gameCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
|
||||||
gameTable->setItem(gameCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
|
QString::fromStdString(message.target_id()),
|
||||||
gameTable->setItem(gameCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
|
QString::fromStdString(message.target_name()));
|
||||||
gameTable->setItem(gameCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
|
|
||||||
gameTable->setItem(gameCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
|
|
||||||
++gameCounter;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (targetType == "chat") {
|
if (targetType == "chat") {
|
||||||
chatTable->insertRow(chatCounter);
|
appendChatMessage(chatTable, chatCounter, QString::fromStdString(message.time()),
|
||||||
chatTable->setItem(chatCounter, 0, new QTableWidgetItem(QString::fromStdString(message.time())));
|
QString::fromStdString(message.sender_name()),
|
||||||
chatTable->setItem(chatCounter, 1, new QTableWidgetItem(QString::fromStdString(message.sender_name())));
|
QString::fromStdString(message.sender_ip()), QString::fromStdString(message.message()),
|
||||||
chatTable->setItem(chatCounter, 2, new QTableWidgetItem(QString::fromStdString(message.sender_ip())));
|
QString::fromStdString(message.target_id()),
|
||||||
chatTable->setItem(chatCounter, 3, new QTableWidgetItem(QString::fromStdString(message.message())));
|
QString::fromStdString(message.target_name()));
|
||||||
chatTable->setItem(chatCounter, 4, new QTableWidgetItem(QString::fromStdString(message.target_id())));
|
|
||||||
chatTable->setItem(chatCounter, 5, new QTableWidgetItem(QString::fromStdString(message.target_name())));
|
|
||||||
++chatCounter;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
currentOffset += response.log_message_size();
|
||||||
|
if (response.has_has_more()) {
|
||||||
|
hasMoreResults = response.has_more();
|
||||||
|
} else {
|
||||||
|
// Legacy server without has_more: a full page implies more may exist.
|
||||||
|
hasMoreResults = (response.log_message_size() >= pageSize);
|
||||||
|
}
|
||||||
|
|
||||||
if (roomCounter) {
|
if (roomCounter) {
|
||||||
roomTable->show();
|
roomTable->show();
|
||||||
|
if (isFirstPage) {
|
||||||
roomTable->resizeColumnsToContents();
|
roomTable->resizeColumnsToContents();
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
roomTable->hide();
|
roomTable->hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (gameCounter) {
|
if (gameCounter) {
|
||||||
|
if (isFirstPage) {
|
||||||
gameTable->resizeColumnsToContents();
|
gameTable->resizeColumnsToContents();
|
||||||
|
}
|
||||||
gameTable->show();
|
gameTable->show();
|
||||||
} else {
|
} else {
|
||||||
gameTable->hide();
|
gameTable->hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (chatCounter) {
|
if (chatCounter) {
|
||||||
|
if (isFirstPage) {
|
||||||
chatTable->resizeColumnsToContents();
|
chatTable->resizeColumnsToContents();
|
||||||
|
}
|
||||||
chatTable->show();
|
chatTable->show();
|
||||||
} else {
|
} else {
|
||||||
chatTable->hide();
|
chatTable->hide();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The new rows may already fill less than a viewport (or the user is
|
||||||
|
// still parked at the bottom), so chain the next page without waiting
|
||||||
|
// for another scroll event. The prefetch stays invisible: rows simply
|
||||||
|
// keep appearing as you scroll.
|
||||||
|
checkFetchMore();
|
||||||
}
|
}
|
||||||
|
|
||||||
void TabLog::restartLayout()
|
void TabLog::restartLayout()
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
#include "tab.h"
|
#include "tab.h"
|
||||||
|
|
||||||
#include <QString>
|
#include <QString>
|
||||||
|
#include <libcockatrice/protocol/pb/moderator_commands.pb.h>
|
||||||
#include <qtmetamacros.h>
|
#include <qtmetamacros.h>
|
||||||
|
|
||||||
class AbstractClient;
|
class AbstractClient;
|
||||||
|
|
@ -25,6 +26,7 @@ class QWidget;
|
||||||
class QGridLayout;
|
class QGridLayout;
|
||||||
class QVBoxLayout;
|
class QVBoxLayout;
|
||||||
class QTableWidget;
|
class QTableWidget;
|
||||||
|
class QTabWidget;
|
||||||
class Response;
|
class Response;
|
||||||
class TabSupervisor;
|
class TabSupervisor;
|
||||||
|
|
||||||
|
|
@ -48,8 +50,37 @@ private:
|
||||||
*buttonGroupBox;
|
*buttonGroupBox;
|
||||||
QVBoxLayout *mainLayout;
|
QVBoxLayout *mainLayout;
|
||||||
QTableWidget *roomTable, *gameTable, *chatTable;
|
QTableWidget *roomTable, *gameTable, *chatTable;
|
||||||
|
QTabWidget *tabManager = nullptr;
|
||||||
|
|
||||||
|
//! The filters of the search in flight, captured when Get was clicked so that
|
||||||
|
//! later pages cannot pick up edits the moderator made after starting it.
|
||||||
|
Command_ViewLogHistory currentQuery;
|
||||||
|
bool hasCurrentQuery = false;
|
||||||
|
//! Newest `log_time` of the first page, sent back as the upper bound of every
|
||||||
|
//! later page so messages logged in the meantime do not shift the window.
|
||||||
|
QString snapshotBefore;
|
||||||
|
//! Identity of the previous page's first row, used to spot a server that ignores
|
||||||
|
//! `offset` and hands back the same page forever.
|
||||||
|
QString lastPageStartKey;
|
||||||
|
int currentOffset = 0;
|
||||||
|
int pageSize = 200;
|
||||||
|
int roomCounter = 0, gameCounter = 0, chatCounter = 0;
|
||||||
|
bool isFetching = false;
|
||||||
|
bool hasMoreResults = false;
|
||||||
|
|
||||||
void createDock();
|
void createDock();
|
||||||
|
[[nodiscard]] int dateRangeForSelection() const;
|
||||||
|
void fetchPage(int offset);
|
||||||
|
void appendChatMessage(QTableWidget *table,
|
||||||
|
int &counter,
|
||||||
|
const QString &time,
|
||||||
|
const QString &senderName,
|
||||||
|
const QString &senderIp,
|
||||||
|
const QString &messageText,
|
||||||
|
const QString &targetId,
|
||||||
|
const QString &targetName,
|
||||||
|
const QString &channel = QString());
|
||||||
|
void checkFetchMore();
|
||||||
signals:
|
signals:
|
||||||
|
|
||||||
private slots:
|
private slots:
|
||||||
|
|
|
||||||
|
|
@ -94,7 +94,9 @@ message Command_ViewLogHistory {
|
||||||
optional string message = 5; // raw message that was sent
|
optional string message = 5; // raw message that was sent
|
||||||
repeated string log_location = 6; // destination of message (ex: main room, game room, private chat)
|
repeated string log_location = 6; // destination of message (ex: main room, game room, private chat)
|
||||||
required uint32 date_range = 7; // the length of time (in minutes) to look back for
|
required uint32 date_range = 7; // the length of time (in minutes) to look back for
|
||||||
optional uint32 maximum_results = 8; // the maximum number of query results
|
optional uint32 maximum_results = 8; // the maximum number of query results (page size when paginating)
|
||||||
|
optional uint32 offset = 9 [default = 0]; // rows to skip for pagination, ORDER BY log_time DESC
|
||||||
|
optional string before_time = 10; // page those rows against this `log_time` snapshot (newest first page row)
|
||||||
}
|
}
|
||||||
|
|
||||||
message Command_GrantReplayAccess {
|
message Command_GrantReplayAccess {
|
||||||
|
|
|
||||||
|
|
@ -7,4 +7,5 @@ message Response_ViewLogHistory {
|
||||||
optional Response_ViewLogHistory ext = 1015;
|
optional Response_ViewLogHistory ext = 1015;
|
||||||
}
|
}
|
||||||
repeated ServerInfo_ChatMessage log_message = 1;
|
repeated ServerInfo_ChatMessage log_message = 1;
|
||||||
|
optional bool has_more = 2 [default = false]; // true when more rows exist past this page
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,7 @@
|
||||||
#include <QStringList>
|
#include <QStringList>
|
||||||
#include <QVariant>
|
#include <QVariant>
|
||||||
#include <QVariantList>
|
#include <QVariantList>
|
||||||
|
#include <QtGlobal>
|
||||||
#include <google/protobuf/stubs/common.h>
|
#include <google/protobuf/stubs/common.h>
|
||||||
#include <libcockatrice/deck_list/deck_list.h>
|
#include <libcockatrice/deck_list/deck_list.h>
|
||||||
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
#include <libcockatrice/protocol/pb/game_replay.pb.h>
|
||||||
|
|
@ -2249,8 +2250,14 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
||||||
bool &game,
|
bool &game,
|
||||||
bool &room,
|
bool &room,
|
||||||
int &range,
|
int &range,
|
||||||
int &maxresults)
|
int &maxresults,
|
||||||
|
int offset,
|
||||||
|
const QString &beforeTime,
|
||||||
|
bool *hasMore)
|
||||||
{
|
{
|
||||||
|
if (hasMore != nullptr) {
|
||||||
|
*hasMore = false;
|
||||||
|
}
|
||||||
|
|
||||||
QList<ServerInfo_ChatMessage> results;
|
QList<ServerInfo_ChatMessage> results;
|
||||||
ServerInfo_ChatMessage chatMessage;
|
ServerInfo_ChatMessage chatMessage;
|
||||||
|
|
@ -2259,8 +2266,31 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (user.isEmpty() && ipaddress.isEmpty() && gameid.isEmpty() && gamename.isEmpty()) {
|
// Page size guard: always bound the scan so a filter-less browse of room
|
||||||
// To ensure quick results and minimal lag, require an indexed field
|
// history cannot become a 50M-row fetch. Zero means "client did not set a
|
||||||
|
// size", so fall back to one smooth-scroll page.
|
||||||
|
const int pageSize = maxresults <= 0 ? 200 : qBound(1, maxresults, 1000);
|
||||||
|
maxresults = pageSize;
|
||||||
|
const int safeOffset = qMax(0, offset);
|
||||||
|
|
||||||
|
const bool hasIndexedFilter = !user.isEmpty() || !ipaddress.isEmpty() || !gameid.isEmpty() || !gamename.isEmpty();
|
||||||
|
const bool hasLocationFilter = chat || game || room;
|
||||||
|
if (!hasLocationFilter) {
|
||||||
|
// No target_type restriction would mix private chat into a room/game
|
||||||
|
// browse, so refuse rather than leak.
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
// A browse narrowed only by a message substring has no index to serve it, and `range`
|
||||||
|
// has no upper bound on the wire: the client offers 20 days, but a hand-built command
|
||||||
|
// can ask for any value and every LIKE candidate in it is a row the server examines.
|
||||||
|
// Clamping here keeps the guard independent of what the client happens to send.
|
||||||
|
constexpr int maxFilterlessRangeHours = 20 * 24;
|
||||||
|
if (!hasIndexedFilter && range > maxFilterlessRangeHours) {
|
||||||
|
range = maxFilterlessRangeHours;
|
||||||
|
}
|
||||||
|
if (!hasIndexedFilter && range <= 0) {
|
||||||
|
// Filter-less browse must stay time-bounded (idx_log_time) so the
|
||||||
|
// query stays an index range scan instead of a full table scan.
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2315,10 +2345,23 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
||||||
queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)");
|
queryString.append(" AND log_time >= DATE_SUB(now(), INTERVAL :range_time HOUR)");
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxresults) {
|
if (!beforeTime.isEmpty()) {
|
||||||
queryString.append(" LIMIT :limit_size");
|
// Pages after the first run against the snapshot fixed by the first one, so rows
|
||||||
|
// logged while the moderator scrolls can no longer push the OFFSET window down and
|
||||||
|
// repeat a row already on screen.
|
||||||
|
queryString.append(" AND log_time <= :before_time");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Newest-first keeps "going back from today" stable across pages and lets
|
||||||
|
// the client append older rows at the bottom for smooth infinite scroll.
|
||||||
|
queryString.append(" ORDER BY log_time DESC");
|
||||||
|
|
||||||
|
// Fetch one extra row when the caller wants a has_more cursor so a full
|
||||||
|
// page does not need a second empty round-trip to prove exhaustion.
|
||||||
|
const bool detectMore = (hasMore != nullptr);
|
||||||
|
const int fetchSize = detectMore ? pageSize + 1 : pageSize;
|
||||||
|
queryString.append(" LIMIT :limit_size OFFSET :offset_size");
|
||||||
|
|
||||||
QSqlQuery *query = prepareQuery(queryString);
|
QSqlQuery *query = prepareQuery(queryString);
|
||||||
if (!user.isEmpty()) {
|
if (!user.isEmpty()) {
|
||||||
query->bindValue(":user_name", user);
|
query->bindValue(":user_name", user);
|
||||||
|
|
@ -2338,9 +2381,11 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
||||||
if (range) {
|
if (range) {
|
||||||
query->bindValue(":range_time", range);
|
query->bindValue(":range_time", range);
|
||||||
}
|
}
|
||||||
if (maxresults) {
|
if (!beforeTime.isEmpty()) {
|
||||||
query->bindValue(":limit_size", maxresults);
|
query->bindValue(":before_time", beforeTime);
|
||||||
}
|
}
|
||||||
|
query->bindValue(":limit_size", fetchSize);
|
||||||
|
query->bindValue(":offset_size", safeOffset);
|
||||||
|
|
||||||
if (!execSqlQuery(query)) {
|
if (!execSqlQuery(query)) {
|
||||||
qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error";
|
qCWarning(DatabaseInterfaceLog) << "Failed to collect log history information: SQL Error";
|
||||||
|
|
@ -2360,6 +2405,11 @@ QList<ServerInfo_ChatMessage> Servatrice_DatabaseInterface::getMessageLogHistory
|
||||||
results << chatMessage;
|
results << chatMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (detectMore && results.size() > pageSize) {
|
||||||
|
results.removeLast();
|
||||||
|
*hasMore = true;
|
||||||
|
}
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -219,7 +219,10 @@ public:
|
||||||
bool &game,
|
bool &game,
|
||||||
bool &room,
|
bool &room,
|
||||||
int &range,
|
int &range,
|
||||||
int &maxresults);
|
int &maxresults,
|
||||||
|
int offset = 0,
|
||||||
|
const QString &beforeTime = QString(),
|
||||||
|
bool *hasMore = nullptr);
|
||||||
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
|
QList<ServerInfo_UserSession> getUserSessions(const QString &userName, int limit);
|
||||||
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName);
|
QList<ServerInfo_UserAlt> getUserAlts(const QString &userName);
|
||||||
QList<ServerInfo_ModeratorLogin> getModeratorLastLogins();
|
QList<ServerInfo_ModeratorLogin> getModeratorLastLogins();
|
||||||
|
|
|
||||||
|
|
@ -1743,12 +1743,17 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com
|
||||||
|
|
||||||
int dateRange = cmd.date_range();
|
int dateRange = cmd.date_range();
|
||||||
int maximumResults = cmd.maximum_results();
|
int maximumResults = cmd.maximum_results();
|
||||||
|
const int offset = cmd.has_offset() ? static_cast<int>(cmd.offset()) : 0;
|
||||||
|
const QString beforeTime = cmd.has_before_time() ? QString::fromStdString(cmd.before_time()) : QString();
|
||||||
|
|
||||||
Response_ViewLogHistory *re = new Response_ViewLogHistory;
|
Response_ViewLogHistory *re = new Response_ViewLogHistory;
|
||||||
|
|
||||||
if (servatrice->getEnableLogQuery()) {
|
if (servatrice->getEnableLogQuery()) {
|
||||||
QListIterator<ServerInfo_ChatMessage> messageIterator(sqlInterface->getMessageLogHistory(
|
bool hasMore = false;
|
||||||
userName, ipAddress, gameName, gameID, message, chatType, gameType, roomType, dateRange, maximumResults));
|
QList<ServerInfo_ChatMessage> history =
|
||||||
|
sqlInterface->getMessageLogHistory(userName, ipAddress, gameName, gameID, message, chatType, gameType,
|
||||||
|
roomType, dateRange, maximumResults, offset, beforeTime, &hasMore);
|
||||||
|
QListIterator<ServerInfo_ChatMessage> messageIterator(history);
|
||||||
while (messageIterator.hasNext()) {
|
while (messageIterator.hasNext()) {
|
||||||
ServerInfo_ChatMessage chatMessage = messageIterator.next();
|
ServerInfo_ChatMessage chatMessage = messageIterator.next();
|
||||||
if (!allowPrivateChat) {
|
if (!allowPrivateChat) {
|
||||||
|
|
@ -1756,6 +1761,7 @@ Response::ResponseCode AbstractServerSocketInterface::cmdGetLogHistory(const Com
|
||||||
}
|
}
|
||||||
re->add_log_message()->CopyFrom(chatMessage);
|
re->add_log_message()->CopyFrom(chatMessage);
|
||||||
}
|
}
|
||||||
|
re->set_has_more(hasMore);
|
||||||
} else {
|
} else {
|
||||||
ServerInfo_ChatMessage chatMessage;
|
ServerInfo_ChatMessage chatMessage;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue