[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:
BruebachL 2026-11-02 07:36:49 +01:00 • committed by GitHub
parent 3cf8bdb00e
commit 94e6281d87
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 321 additions and 91 deletions

View file

@ -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();
roomTable->resizeColumnsToContents(); if (isFirstPage) {
roomTable->resizeColumnsToContents();
}
} else { } else {
roomTable->hide(); roomTable->hide();
} }
if (gameCounter) { if (gameCounter) {
gameTable->resizeColumnsToContents(); if (isFirstPage) {
gameTable->resizeColumnsToContents();
}
gameTable->show(); gameTable->show();
} else { } else {
gameTable->hide(); gameTable->hide();
} }
if (chatCounter) { if (chatCounter) {
chatTable->resizeColumnsToContents(); if (isFirstPage) {
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()

View file

@ -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:

View file

@ -87,14 +87,16 @@ message Command_ViewLogHistory {
extend DeveloperCommand { extend DeveloperCommand {
optional Command_ViewLogHistory dev_ext = 1001; optional Command_ViewLogHistory dev_ext = 1001;
} }
optional string user_name = 1; // user that created message optional string user_name = 1; // user that created message
optional string ip_address = 2; // ip address of user that created message optional string ip_address = 2; // ip address of user that created message
optional string game_name = 3; // client id of user that created the message optional string game_name = 3; // client id of user that created the message
optional string game_id = 4; // game number the message was sent to optional string game_id = 4; // game number the message was sent to
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 {

View file

@ -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
} }

View file

@ -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;
} }

View file

@ -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();

View file

@ -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;