mirror of
https://github.com/wekan/wekan.git
synced 2026-11-05 01:20:07 -05:00
Paginate the admin lists honestly: one page of ten, index-backed.
Admin Panel / People showed the admin on every one of its 578 pages, and Admin Panel / Problems / Broken cards was one endless page under a pager that said "1 / 1". Same bug in two shapes: the pane subscribes to a publication that sends exactly one page, then reads the page back out of minimongo - which holds far more than the page. The logged-in user's own record is always there (accounts publishes it), and so is every card of every board the admin has opened. A plain find(query) cannot tell those apart from the page. So the server names the page. getPeoplePageIds returns the ids of the People page with the publication's own selector, sort and window; the Broken cards, Cards and Boards report publications send the ids of the page they just sent alongside it. Both panes render that list, in that order, and nothing else. Every paginated page in WeKan now loads ten rows at a time, from one constant - TABLE_PAGE_ROWS_PER_PAGE - including the pages that draw a pager of their own: the reports and event streams, the four People panes, Translation, the search pages and the archive. docs/Design/Page/Table.md records the rule and its two deliberate exceptions. And the counting behind those pagers is index-backed. Every count already asks the database for a count rather than fetching the rows, but several counted and paged on unindexed fields: People sorts users newest-first, so on an instance with 14000 users each page of ten sorted all 14000 first. Broken cards asks an $or over boardId/swimlaneId/listId/type, which uses no index at all unless every branch has one. The missing indexes are created at startup by the idempotent ensureIndex, so an upgrade adds them by itself. Thanks to xet7 !
This commit is contained in:
1 parent
b5b7f063a2
commit
c1add5bc5e
20 files changed
+633
-32
No files matched your search
@@ -2,8 +2,11 @@ import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
||||
import Boards from '/models/boards';
|
||||
import { Utils } from '/client/lib/utils';
|
||||
import { TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
|
||||
|
||||
const ARCHIVED_BOARDS_PER_PAGE = 30;
|
||||
// The app's one rows-per-page (docs/Design/Page/Table.md): the archive pages
|
||||
// like every other paginated page, whatever pager it draws.
|
||||
const ARCHIVED_BOARDS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
|
||||
Template.archivedBoards.onCreated(function () {
|
||||
// Server-side pagination: only the current page of archived boards is ever
|
||||
|
||||
@@ -6,7 +6,9 @@ import Cards from '/models/cards';
|
||||
import Rules from '/models/rules';
|
||||
import ImpersonatedUsers from '/models/impersonatedUsers';
|
||||
import RecoveryEvents from '/models/recoveryEvents';
|
||||
import { buildHeader, buildRows, pageInfo } from '/models/lib/tablePage';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { buildHeader, buildRows, docsByIds, pageInfo, TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
|
||||
import { REPORT_PAGE_COLLECTION } from '/models/lib/reportPageIndex';
|
||||
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
|
||||
import Settings from '/models/settings';
|
||||
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
||||
@@ -45,15 +47,35 @@ function collectionResults(collection, sort) {
|
||||
return collection.find({}, sort ? { sort } : {});
|
||||
}
|
||||
|
||||
// The page index the report publications send (models/lib/reportPageIndex.js).
|
||||
// Client-only: there is no `report_pages` collection on the server, the publication
|
||||
// just addresses documents to that name over DDP.
|
||||
const ReportPages = new Mongo.Collection(REPORT_PAGE_COLLECTION);
|
||||
|
||||
// The documents of the page the SERVER named, in the order it sorted them.
|
||||
//
|
||||
// `collectionResults` above shows whatever the collection holds, which is right
|
||||
// only for a collection nothing else fills. Cards and Boards are not those: every
|
||||
// card of every board the admin has opened is in minimongo, so Broken cards drew
|
||||
// hundreds of rows while its pager - counted on the server - said "1 / 1", and the
|
||||
// Cards and Boards reports showed the same rows on every page.
|
||||
function reportPageResults(collection, reportId) {
|
||||
const index = ReportPages.findOne(reportId);
|
||||
const ids = (index && index.ids) || [];
|
||||
if (!ids.length) return [];
|
||||
return docsByIds(ids, collection.find({ _id: { $in: ids } }).fetch());
|
||||
}
|
||||
|
||||
function collectionResultsCount(collection) {
|
||||
return collection.find().count();
|
||||
}
|
||||
|
||||
// --- adminReports template ---
|
||||
|
||||
// Rows per page for the paginated reports (files, rules, boards, cards).
|
||||
// Kept in sync with the admin People panel so all admin lists page alike.
|
||||
const REPORTS_PER_PAGE = 25;
|
||||
// Rows per page for the paginated reports (files, rules, boards, cards). The one
|
||||
// rows-per-page of the whole app (docs/Design/Page/Table.md), so every list pages
|
||||
// alike - a report is not a different kind of page than People or Translation.
|
||||
const REPORTS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
|
||||
// Static description of each paginated report: which page/count reactive vars
|
||||
// it uses, which publication feeds it and which count method backs it. Lets a
|
||||
@@ -533,7 +555,7 @@ const REPORT_TABLES = {
|
||||
},
|
||||
'report-boards': {
|
||||
emptyKey: 'no-results',
|
||||
docs: () => collectionResults(Boards, { sort: 1 }).fetch(),
|
||||
docs: () => reportPageResults(Boards, 'report-boards'),
|
||||
columns: [
|
||||
{ label: 'Title', value: d => abbreviate(d.title) },
|
||||
{ label: 'Id', value: d => abbreviate(d._id) },
|
||||
@@ -550,7 +572,9 @@ const REPORT_TABLES = {
|
||||
emptyKey: 'no-results',
|
||||
// A broken card is one with no board, swimlane or list - so those cells are
|
||||
// exactly the ones that may be empty, and that is the point of the row.
|
||||
docs: () => collectionResults(Cards, { boardId: 1, createdAt: -1 }).fetch(),
|
||||
// The page comes from the publication's own index, not from "every card in
|
||||
// minimongo": that is what made this report one endless page.
|
||||
docs: () => reportPageResults(Cards, 'report-broken'),
|
||||
columns: [
|
||||
{ label: 'Card Title', value: d => abbreviate(d.title) },
|
||||
{ label: 'Id', value: d => abbreviate(d._id) },
|
||||
@@ -563,8 +587,8 @@ const REPORT_TABLES = {
|
||||
},
|
||||
'report-cards': {
|
||||
emptyKey: 'no-results',
|
||||
// Match the server publication's index-backed sort (see cards.js).
|
||||
docs: () => collectionResults(Cards, { boardId: 1, createdAt: -1 }).fetch(),
|
||||
// The publication's page, in the publication's order (see cards.js).
|
||||
docs: () => reportPageResults(Cards, 'report-cards'),
|
||||
columns: [
|
||||
{ label: 'Card Title', value: d => abbreviate(d.title) },
|
||||
{ label: 'Board', value: d => abbreviate(d.board()?.title) },
|
||||
@@ -639,7 +663,7 @@ function reportTablePageData(tmpl) {
|
||||
// Reads the eventlog collection through the admin-only eventLogPage/eventLogCount
|
||||
// methods and shows a paginated, searchable, READ-ONLY table (no acknowledge —
|
||||
// that lives only on the Summary page). See docs/Security/Remediation/WeKan.md.
|
||||
const EVENTS_PER_PAGE = 25;
|
||||
const EVENTS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
|
||||
// The stream's title is not needed here any more: the pane heading is rendered
|
||||
// once for every Admin Panel pane from the open menu entry
|
||||
|
||||
@@ -7,7 +7,7 @@ import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
|
||||
// rendering nothing. That is what left Admin Panel / People / People with no
|
||||
// table, no search box and no pager, while Organizations, Teams and Domains -
|
||||
// which use neither function - drew theirs normally.
|
||||
import { buildActions, buildFilters, buildHeader, buildRows, pageInfo } from "/models/lib/tablePage";
|
||||
import { buildActions, buildFilters, buildHeader, buildRows, docsByIds, pageInfo, TABLE_PAGE_ROWS_PER_PAGE } from "/models/lib/tablePage";
|
||||
import { avatarUpdateCounter } from '/client/components/users/avatarUpdateCounter';
|
||||
import { InfiniteScrolling } from '/client/lib/infiniteScrolling';
|
||||
import LockoutSettings from '/models/lockoutSettings';
|
||||
@@ -40,10 +40,22 @@ const TENANT_ORG_FIELDS = [
|
||||
'orgLegalNotice',
|
||||
];
|
||||
|
||||
const orgsPerPage = 25;
|
||||
const teamsPerPage = 25;
|
||||
const usersPerPage = 25;
|
||||
const domainsPerPage = 25;
|
||||
// One rows-per-page for the whole app (docs/Design/Page/Table.md): these four
|
||||
// panes page exactly like every other paginated page in WeKan.
|
||||
const orgsPerPage = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
const teamsPerPage = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
const usersPerPage = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
const domainsPerPage = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
|
||||
// The People table renders the page the SERVER named (getPeoplePageIds), so adding
|
||||
// or deleting a user has to ask for that page again - the page's contents changed
|
||||
// without any of the things the table watches (the query, the page number) changing.
|
||||
// The create and delete handlers live in their own popup templates, so this is a
|
||||
// module-level counter rather than something on the People instance.
|
||||
const peopleListVersion = new ReactiveVar(0);
|
||||
function peopleListChanged() {
|
||||
peopleListVersion.set(peopleListVersion.get() + 1);
|
||||
}
|
||||
let userOrgsTeamsAction = ""; //poosible actions 'addOrg', 'addTeam', 'removeOrg' or 'removeTeam' when adding or modifying a user
|
||||
let selectedUserChkBoxUserIds = [];
|
||||
|
||||
@@ -71,6 +83,11 @@ Template.people.onCreated(function () {
|
||||
this.numberOrgs = new ReactiveVar(0);
|
||||
this.numberTeams = new ReactiveVar(0);
|
||||
this.numberPeople = new ReactiveVar(0);
|
||||
// The ids the `people` publication sent for the CURRENT page, in its order. The
|
||||
// browser holds more user documents than one page - the logged-in user's own
|
||||
// record is always in minimongo - so the table renders this list, not everything
|
||||
// a `Users.find()` happens to match. See getPeoplePageIds in server/models/users.js.
|
||||
this.peoplePageIds = new ReactiveVar([]);
|
||||
this.userFilterType = new ReactiveVar('all');
|
||||
// The search box lives in the shared controls row now, so keep the term in
|
||||
// state rather than reading it back out of a DOM id.
|
||||
@@ -345,6 +362,22 @@ Template.people.onCreated(function () {
|
||||
this.refreshTeamsCount();
|
||||
});
|
||||
|
||||
// Which users this page consists of, asked for with the same query/limit/skip
|
||||
// as the subscription right below it. Without it the table cannot tell the
|
||||
// page's documents from the other user documents in minimongo.
|
||||
peopleListVersion.get(); // ask again when a user was added or deleted
|
||||
Meteor.call('getPeoplePageIds', this.findUsersOptions.get(), limitUsers, skipUsers,
|
||||
(error, ids) => {
|
||||
if (error) {
|
||||
console.error('Failed to load the people page:', error);
|
||||
return;
|
||||
}
|
||||
this.peoplePageIds.set(Array.isArray(ids) ? ids : []);
|
||||
// The total moves with the page's contents, so it is refreshed here too:
|
||||
// the subscription's ready callback only fires the first time.
|
||||
this.refreshUsersCount();
|
||||
});
|
||||
|
||||
this.subscribe('people', this.findUsersOptions.get(), limitUsers, skipUsers, () => {
|
||||
this.loadNextPageLocked = false;
|
||||
const nextPeakBefore = this.infiniteScrolling.getNextPeak();
|
||||
@@ -411,14 +444,22 @@ function firstPeoplePaneId(user) {
|
||||
// a select-all pair. Same two slots (docs/Design/Page/Table.md).
|
||||
// People: interactive rows again, and two of its headers are templates - the
|
||||
// new-user row and the select-all checkbox (docs/Design/Page/Table.md).
|
||||
// One page of users, as published. The 'people' publication already applies
|
||||
// server-side limit/skip sorted createdAt:-1, so this must NOT re-slice: doing so
|
||||
// paginated an already-paginated set, which is why page 2 once showed a single
|
||||
// stray doc (the admin's own user record, always in minimongo) and later pages
|
||||
// were empty.
|
||||
// One page of users: the ones the server put on this page, in its order.
|
||||
//
|
||||
// The 'people' publication applies limit/skip sorted createdAt:-1 server-side, so
|
||||
// this must NOT re-slice - that paginated an already-paginated set. But it must not
|
||||
// read the page back with a bare `Users.find(query)` either: minimongo holds user
|
||||
// documents this page has nothing to do with - above all the logged-in user's own
|
||||
// record, which accounts publishes and which therefore matched on EVERY page, so
|
||||
// the admin looking at the list saw themselves on all 578 of them (and anyone else
|
||||
// another subscription had pulled in could show up twice).
|
||||
//
|
||||
// `getPeoplePageIds` names the page; the documents still come from the publication.
|
||||
// An id whose document has not arrived yet is simply not rendered yet.
|
||||
function peopleDocs(tpl) {
|
||||
return Users.find(tpl.findUsersOptions.get(), {
|
||||
sort: { createdAt: -1 },
|
||||
const ids = tpl.peoplePageIds.get();
|
||||
if (!ids.length) return [];
|
||||
const docs = Users.find({ _id: { $in: ids } }, {
|
||||
fields: {
|
||||
_id: 1,
|
||||
username: 1,
|
||||
@@ -429,6 +470,8 @@ function peopleDocs(tpl) {
|
||||
services: 1,
|
||||
},
|
||||
}).fetch();
|
||||
// The server's order, not minimongo's: `$in` does not preserve it.
|
||||
return docsByIds(ids, docs);
|
||||
}
|
||||
|
||||
const PEOPLE_COLUMNS = [
|
||||
@@ -2104,6 +2147,9 @@ Template.newUserPopup.events({
|
||||
} else {
|
||||
usernameMessageElement.hide();
|
||||
emailMessageElement.hide();
|
||||
// The new user belongs on the first page (the list is newest first), so
|
||||
// the table has to ask which users that page holds now.
|
||||
peopleListChanged();
|
||||
Popup.back();
|
||||
}
|
||||
},
|
||||
@@ -2228,6 +2274,8 @@ Template.settingsUserPopup.events({
|
||||
if (process.env.DEBUG === 'true') {
|
||||
console.log('User deleted successfully:', result);
|
||||
}
|
||||
// One row fewer: which users this page holds has changed.
|
||||
peopleListChanged();
|
||||
Popup.back();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -4,6 +4,7 @@ import Cards from '../../models/cards';
|
||||
import SessionData from '../../models/usersessiondata';
|
||||
import {QueryDebug} from "../../config/query-classes";
|
||||
import {OPERATOR_DEBUG} from "../../config/search-const";
|
||||
import { TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
|
||||
|
||||
// Plain helper class for search pages with pagination.
|
||||
// Not a BlazeComponent; instantiated in each template's onCreated.
|
||||
@@ -22,7 +23,9 @@ export class CardSearchPaged {
|
||||
this.resultsCount = 0;
|
||||
this.totalHits = 0;
|
||||
this.queryErrors = null;
|
||||
this.resultsPerPage = 25;
|
||||
// The one rows-per-page of the app (docs/Design/Page/Table.md): a search page
|
||||
// is a paginated page too, so it loads the same 10 rows at a time.
|
||||
this.resultsPerPage = TABLE_PAGE_ROWS_PER_PAGE;
|
||||
this.sessionId = SessionData.getSessionId();
|
||||
this.subscriptionHandle = null;
|
||||
this.serverError = new ReactiveVar(false);
|
||||
|
||||
@@ -161,14 +161,38 @@ beside it.
|
||||
the subscription and the "page X / N" counter, so what is fetched and what is
|
||||
displayed cannot drift apart.
|
||||
- The publication applies `limit`/`skip` server-side and sends only that page, so
|
||||
only those rows reach minimongo. The client re-applies the publication's sort so
|
||||
the displayed order matches the server page; it must **not** re-slice an
|
||||
already-paginated set.
|
||||
only those rows reach minimongo. The client renders **that page**; it must
|
||||
**not** re-slice an already-paginated set.
|
||||
- The total row count comes from a separate count method, called when the page is
|
||||
opened or the search changes — **not** on every prev/next click. The total
|
||||
cannot change because you moved to the next page, and recounting there added a
|
||||
second round trip to every click.
|
||||
- `perPage` is `TABLE_PAGE_ROWS_PER_PAGE` (25), one constant for every table.
|
||||
|
||||
### Rows per page — one number, `TABLE_PAGE_ROWS_PER_PAGE`, for the whole app
|
||||
|
||||
**Every paginated page in WeKan loads ten rows at a time.** The number lives once,
|
||||
as `TABLE_PAGE_ROWS_PER_PAGE` in `models/lib/tablePage.js`, and every page reads it
|
||||
from there — including the pages that are **not** built from this design and draw a
|
||||
pager of their own. A page that writes its own number is a bug, whichever template
|
||||
it uses:
|
||||
|
||||
| Where | Reads it as |
|
||||
| --- | --- |
|
||||
| Every table page (`pageInfo`'s default) | `TABLE_PAGE_ROWS_PER_PAGE` |
|
||||
| Admin Panel / Problems — all reports and the event streams | `REPORTS_PER_PAGE`, `EVENTS_PER_PAGE` |
|
||||
| Admin Panel / People — People, Organizations, Teams, Domains | `usersPerPage`, `orgsPerPage`, `teamsPerPage`, `domainsPerPage` |
|
||||
| Admin Panel / Settings / Translation | `TABLE_PAGE_ROWS_PER_PAGE` |
|
||||
| The search pages (`CardSearchPaged`: global search, due cards, broken cards) | `resultsPerPage` |
|
||||
| Archived boards | `ARCHIVED_BOARDS_PER_PAGE` |
|
||||
|
||||
Two deliberate exceptions, both of which have no pager and are therefore not
|
||||
paginated pages: the **activity feed**, which is infinite scroll and whose page size
|
||||
is a live-cursor cost decision (see the note in
|
||||
`client/components/activities/activities.js`), and `PER_PAGE_MAX` in
|
||||
`models/lib/domainTablePage.js`, which is an upper bound on what a *client may ask
|
||||
for*, not a page size. `PER_PAGE_DEFAULT` in that same module is the same ten,
|
||||
written out rather than imported because a plain-node test `require()`s the file;
|
||||
`tests/tablePage.test.cjs` pins the two together.
|
||||
|
||||
## Columns
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
const ImpersonatedUsers = new Mongo.Collection('impersonatedUsers');
|
||||
@@ -78,4 +79,13 @@ ImpersonatedUsers.attachSchema(
|
||||
}),
|
||||
);
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Admin Panel / Problems / Impersonation pages these newest-first and counts
|
||||
// them with the same selector (server/publications/impersonationReport.js).
|
||||
const { ensureIndex } = require('/server/lib/mongoStartup');
|
||||
Meteor.startup(async () => {
|
||||
await ensureIndex(ImpersonatedUsers, { createdAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default ImpersonatedUsers;
|
||||
@@ -12,7 +12,11 @@
|
||||
// search filter, the requested column sort and the requested page, returning
|
||||
// only that page plus the total so the client can render pagination controls.
|
||||
|
||||
const PER_PAGE_DEFAULT = 25;
|
||||
// The app's one rows-per-page (TABLE_PAGE_ROWS_PER_PAGE in models/lib/tablePage.js,
|
||||
// docs/Design/Page/Table.md). Written out rather than imported because this module
|
||||
// is loaded by a plain-node test with require(), which cannot parse ESM imports;
|
||||
// tests/tablePage.test.cjs pins the two to the same number.
|
||||
const PER_PAGE_DEFAULT = 10;
|
||||
const PER_PAGE_MAX = 200;
|
||||
|
||||
function paginateDomains(rows, opts = {}) {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
// WHICH documents one page of an Admin Panel report consists of.
|
||||
//
|
||||
// The paginated reports (Problems / Broken cards, Cards, Boards) publish exactly
|
||||
// one page: server-side search, sort, limit and skip, sent with `this.added`. The
|
||||
// pane then reads that page back out of minimongo - and minimongo holds far more
|
||||
// than the page. Every card of every board the admin has opened is in there, every
|
||||
// board they are a member of, everything the previous report left behind. A plain
|
||||
// `Cards.find({})` cannot tell those apart from the page, so Broken cards rendered
|
||||
// hundreds of rows while its pager - which counts on the server - correctly said
|
||||
// "1 / 1". Admin Panel / People had the same bug in its own shape: the admin's own
|
||||
// user record is always in minimongo, so the admin appeared on all 578 pages.
|
||||
//
|
||||
// The publication knows the answer, so it says it: alongside the page it sends one
|
||||
// small document listing the page's ids IN THE ORDER IT SORTED THEM. The pane
|
||||
// renders that list (see docsByIds in models/lib/tablePage.js) and nothing else.
|
||||
// It is a DDP-only collection - no server-side Mongo collection is created for it;
|
||||
// the client declares a Mongo.Collection with this name to receive it.
|
||||
|
||||
export const REPORT_PAGE_COLLECTION = 'report_pages';
|
||||
|
||||
// Send the index for `reportId` from inside a publication (`this`).
|
||||
// Documents without an `_id` are skipped rather than published as a null id.
|
||||
export function publishReportPage(publication, reportId, docs) {
|
||||
const ids = (Array.isArray(docs) ? docs : [])
|
||||
.map(doc => doc && doc._id)
|
||||
.filter(Boolean);
|
||||
publication.added(REPORT_PAGE_COLLECTION, reportId, { ids });
|
||||
}
|
||||
+27
-3
@@ -13,9 +13,12 @@
|
||||
// `value` returns display text; `userId`, when given, makes the cell a link to
|
||||
// that user (the admin "edit user" popup) instead of plain text.
|
||||
|
||||
// One page of rows. Kept in one place so every table pages alike and so the
|
||||
// publication limit and the "page X / N" counter can never drift apart.
|
||||
export const TABLE_PAGE_ROWS_PER_PAGE = 25;
|
||||
// One page of rows, for EVERY paginated page in WeKan - the ones built from the
|
||||
// shared table page and the ones with a pager of their own. Kept in one place so
|
||||
// they all page alike, and so a publication's limit and the "page X / N" counter
|
||||
// beside it can never drift apart. Ten rows: a page that fits on a screen without
|
||||
// scrolling is what a pager is for.
|
||||
export const TABLE_PAGE_ROWS_PER_PAGE = 10;
|
||||
|
||||
// Equal share of the table width for `count` columns, as a CSS percentage
|
||||
// string. `table-layout: fixed` already divides the width evenly, so this exists
|
||||
@@ -62,6 +65,27 @@ function cellText(column, doc) {
|
||||
return typeof out === 'string' ? out : String(out);
|
||||
}
|
||||
|
||||
// The documents of ONE page, given the ids the server put on that page.
|
||||
//
|
||||
// A paginated pane subscribes to a publication that sends one page, then reads the
|
||||
// page back out of minimongo - where the browser also holds documents that have
|
||||
// nothing to do with this page (the logged-in user's own record above all, which
|
||||
// accounts always publishes). A plain `find(query)` cannot tell them apart, which
|
||||
// is how Admin Panel / People showed the admin on every one of its 578 pages.
|
||||
//
|
||||
// So the server names the page and this puts it back in that order: `$in` returns
|
||||
// documents in no particular order, and an id whose document has not arrived yet is
|
||||
// left out rather than rendered as an empty row.
|
||||
export function docsByIds(ids, docs) {
|
||||
const list = Array.isArray(ids) ? ids : [];
|
||||
const byId = new Map(
|
||||
(Array.isArray(docs) ? docs : [])
|
||||
.filter(Boolean)
|
||||
.map(doc => [doc._id || doc.id, doc]),
|
||||
);
|
||||
return list.map(id => byId.get(id)).filter(Boolean);
|
||||
}
|
||||
|
||||
// Build the rows the shared template iterates. One cell per column, in column
|
||||
// order, so a row can never be shorter than the header (which is how a
|
||||
// hand-written table ends up with its columns shifted by one).
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
@@ -83,4 +84,13 @@ RecoveryEvents.record = async function record(type, opts = {}) {
|
||||
}
|
||||
};
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Admin Panel / Problems / Recovery pages these newest-first and counts them
|
||||
// with the same selector (server/publications/recoveryReport.js).
|
||||
const { ensureIndex } = require('/server/lib/mongoStartup');
|
||||
Meteor.startup(async () => {
|
||||
await ensureIndex(RecoveryEvents, { createdAt: -1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default RecoveryEvents;
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { Mongo } from 'meteor/mongo';
|
||||
import { Meteor } from 'meteor/meteor';
|
||||
const { SimpleSchema } = require('/imports/simpleSchema');
|
||||
|
||||
const Rules = new Mongo.Collection('rules');
|
||||
@@ -84,4 +85,14 @@ Rules.helpers({
|
||||
},
|
||||
});
|
||||
|
||||
if (Meteor.isServer) {
|
||||
// Admin Panel / Problems / Rules pages every rule sorted by board, and counts
|
||||
// them with the same selector (server/publications/rules.js). Without an index
|
||||
// that is a full-collection scan plus an in-memory sort for every page of ten.
|
||||
const { ensureIndex } = require('/server/lib/mongoStartup');
|
||||
Meteor.startup(async () => {
|
||||
await ensureIndex(Rules, { boardId: 1 });
|
||||
});
|
||||
}
|
||||
|
||||
export default Rules;
|
||||
+1
-1
@@ -25,7 +25,7 @@
|
||||
},
|
||||
"scripts": {
|
||||
"test": "meteor test --once --driver-package meteortesting:mocha",
|
||||
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/ferretdbOplog.test.cjs && node tests/ferretdbOplogReset.test.cjs && node tests/ferretdbTextDataBackup.test.cjs && node tests/recoveryPlan.test.cjs && node tests/recoveryReportQuery.test.cjs && node tests/recoveryEventsJsonl.test.cjs && node tests/recoveryReportWiring.test.cjs && node tests/recoveryMaintenance.test.cjs && node tests/importMemberMapping.test.cjs && node tests/boardMemberMapPlan.test.cjs && node tests/withDeadline.test.cjs && node tests/importVirtualMembers.test.cjs && node tests/sandstormMigrationBridge.test.cjs && node tests/fileNameDisplay.test.cjs && node tests/uploadFileName.test.cjs && node tests/diskSpace.test.cjs && node tests/fileTypeCorrection.test.cjs && node tests/fileHardeningGuards.test.cjs && node tests/cpuHighTracker.test.cjs && node tests/cpuMonitorWiring.test.cjs && node tests/buttonThemeColors.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/popupAfterConfirm.test.cjs && node tests/defaultBoard.test.cjs && node tests/upcomingUiGuards.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/filterDateNull.test.cjs && node tests/listMoveSwimlane.test.cjs && node tests/cardDragSwimlaneScrollRefresh.test.cjs && node tests/undoRedoSelection.test.cjs && node tests/softDelete.test.cjs && node tests/globalThemeColor.test.cjs && node tests/themeCategories.test.cjs && node tests/themeColorPicker.test.cjs && node tests/uiFonts.test.cjs && node tests/sandstormAutoOpen.test.cjs && node tests/createListParams.test.cjs && node tests/addBoardTileHeight.test.cjs && node tests/multiSelectionButton.test.cjs && node tests/headerHamburger.test.cjs && node tests/changeLanguageColumns.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/zipbleed.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/changeAvatarPopup.test.cjs && node tests/devOverlayRuntimeErrors.test.cjs && node tests/boardHeaderOneLine.test.cjs && node tests/headerDragscroll.test.cjs && node tests/jadeCompiles.test.cjs && node tests/templateIncludesResolve.test.cjs && node tests/mobileModeFullWidth.test.cjs && node tests/minicardDragHandle.test.cjs && node tests/mobileModeConsistency.test.cjs && node tests/mobileAddList.test.cjs && node tests/mobileBoardFit.test.cjs && node tests/translationPane.test.cjs && node tests/securityReportScript.test.cjs && node tests/tablePage.test.cjs && node tests/leftMenu.test.cjs && node tests/loginEmailPanesMoved.test.cjs && node tests/tenants.test.cjs && node tests/tenantAdmin.test.cjs && node tests/tenantBackup.test.cjs && node tests/tenantWiring.test.cjs && node tests/themeAccents.test.cjs && node tests/spinnerPreview.test.cjs && node tests/adminEdLine truncated
|
||||
"test:unit:node": "node tests/filenameSanitizer.test.cjs && node tests/cardBoardConsistency.test.cjs && node tests/cardUrl.test.cjs && node tests/defaultSwimlane.test.cjs && node tests/cardMoveModifier.test.cjs && node tests/authenticationMethod.test.cjs && node tests/linkedCardTarget.test.cjs && node tests/staleWhileRevalidate.test.cjs && node tests/restArrayParam.test.cjs && node tests/minicardLabelHeight.test.cjs && node tests/dateUtils.normalizeDigits.test.cjs && node tests/reconcileBoardTeamMembers.test.cjs && node tests/rulesBoardSelector.test.cjs && node tests/ruleActionResolve.test.cjs && node tests/boardBackground.test.cjs && node tests/weekStart.test.cjs && node tests/ldapUsernameSlug.test.cjs && node tests/ldapGroupAllowlist.test.cjs && node tests/ldapConnectionRelease.test.cjs && node tests/htmljsArrayContent.test.cjs && node tests/cardDescriptionDraft.test.cjs && node tests/commentDraft.test.cjs && node tests/attachmentDeleteGuard.test.cjs && node tests/ruleMoveAction.test.cjs && node tests/snapMigrationRecovery.test.cjs && node tests/migrationAttachmentExtraction.test.cjs && node tests/attachmentRepair.test.cjs && node tests/attachmentStoragePath.test.cjs && node tests/schemaUpgradeSteps.test.cjs && node tests/cpuExec.test.cjs && node tests/cpuExecWiring.test.cjs && node tests/ferretdbHasData.test.cjs && node tests/ferretdbPolling.test.cjs && node tests/ferretdbWaitTimeout.test.cjs && node tests/ferretdbOplog.test.cjs && node tests/ferretdbOplogReset.test.cjs && node tests/ferretdbTextDataBackup.test.cjs && node tests/recoveryPlan.test.cjs && node tests/recoveryReportQuery.test.cjs && node tests/recoveryEventsJsonl.test.cjs && node tests/recoveryReportWiring.test.cjs && node tests/recoveryMaintenance.test.cjs && node tests/importMemberMapping.test.cjs && node tests/boardMemberMapPlan.test.cjs && node tests/withDeadline.test.cjs && node tests/importVirtualMembers.test.cjs && node tests/sandstormMigrationBridge.test.cjs && node tests/fileNameDisplay.test.cjs && node tests/uploadFileName.test.cjs && node tests/diskSpace.test.cjs && node tests/fileTypeCorrection.test.cjs && node tests/fileHardeningGuards.test.cjs && node tests/cpuHighTracker.test.cjs && node tests/cpuMonitorWiring.test.cjs && node tests/buttonThemeColors.test.cjs && node tests/uiDensity.test.cjs && node tests/editorSubmitKey.test.cjs && node tests/isoWeek.test.cjs && node tests/boardCopyDefaults.test.cjs && node tests/linkedCardTitle.test.cjs && node tests/commentActivity.test.cjs && node tests/swimlaneSort.test.cjs && node tests/subtaskCopy.test.cjs && node tests/subtasksDefaultBoard.test.cjs && node tests/scrollbarCss.test.cjs && node tests/minicardBidiIsolation.test.cjs && node tests/notificationCleanup.test.cjs && node tests/cardSearch.test.cjs && node tests/linkedCardMembers.test.cjs && node tests/verificationEmail.test.cjs && node tests/boardSortReorder.test.cjs && node tests/checklistItemTitles.test.cjs && node tests/swimlaneFilter.test.cjs && node tests/popupOffset.test.cjs && node tests/popupAfterConfirm.test.cjs && node tests/defaultBoard.test.cjs && node tests/upcomingUiGuards.test.cjs && node tests/linkedCardCover.test.cjs && node tests/inviteEmailLanguage.test.cjs && node tests/domainTablePage.test.cjs && node tests/mongoSelectorSafety.test.cjs && node tests/filterDateNull.test.cjs && node tests/listMoveSwimlane.test.cjs && node tests/cardDragSwimlaneScrollRefresh.test.cjs && node tests/undoRedoSelection.test.cjs && node tests/softDelete.test.cjs && node tests/globalThemeColor.test.cjs && node tests/themeCategories.test.cjs && node tests/themeColorPicker.test.cjs && node tests/uiFonts.test.cjs && node tests/sandstormAutoOpen.test.cjs && node tests/createListParams.test.cjs && node tests/addBoardTileHeight.test.cjs && node tests/multiSelectionButton.test.cjs && node tests/headerHamburger.test.cjs && node tests/changeLanguageColumns.test.cjs && node tests/cardsLoading.test.cjs && node tests/base64Chunk.test.cjs && node tests/backupPaths.test.cjs && node tests/zipbleed.test.cjs && node tests/backupCron.test.cjs && node tests/avatarUrlSafety.test.cjs && node tests/changeAvatarPopup.test.cjs && node tests/devOverlayRuntimeErrors.test.cjs && node tests/boardHeaderOneLine.test.cjs && node tests/headerDragscroll.test.cjs && node tests/jadeCompiles.test.cjs && node tests/templateIncludesResolve.test.cjs && node tests/mobileModeFullWidth.test.cjs && node tests/minicardDragHandle.test.cjs && node tests/mobileModeConsistency.test.cjs && node tests/mobileAddList.test.cjs && node tests/mobileBoardFit.test.cjs && node tests/translationPane.test.cjs && node tests/securityReportScript.test.cjs && node tests/tablePage.test.cjs && node tests/leftMenu.test.cjs && node tests/loginEmailPanesMoved.test.cjs && node tests/tenants.test.cjs && node tests/tenantAdmin.test.cjs && node tests/tenantBackup.test.cjs && node tests/tenantWiring.test.cjs && node tests/themeAccents.test.cjs && node tests/spinnerPreview.test.cjs && node tests/adminEdLine truncated
|
||||
"test:unit:all": "npm run test:unit:node && node tests/cards.stickers.test.js && node tests/stickers.test.js && node tests/stickers.packs.test.js && node tests/linkCardPopup.test.js && node tests/trelloCreator.import.test.js && node tests/trelloImport.maplink.test.js && node tests/trelloImport.zip.test.js && node tests/wekanCreator.import.test.js && node tests/wekanCreator.inconsistent.test.js && node tests/unit/oauth2-admin-groups.test.js && node tests/rtl.test.js",
|
||||
"test:watch": "meteor test --driver-package meteortesting:mocha",
|
||||
"test:e2e": "node tests/e2e/list-regressions.js",
|
||||
|
||||
@@ -550,6 +550,11 @@ Meteor.startup(async () => {
|
||||
{ unique: true },
|
||||
);
|
||||
await ensureIndex(Boards, { 'members.userId': 1 });
|
||||
// The Boards report pages by the boards' own order, and the archive pages the
|
||||
// archived ones of a member - both counted with the same selector, so both are
|
||||
// index-backed rather than a scan + in-memory sort.
|
||||
await ensureIndex(Boards, { sort: 1 });
|
||||
await ensureIndex(Boards, { archived: 1, 'members.userId': 1 });
|
||||
});
|
||||
|
||||
Boards.after.insert(async (userId, doc) => {
|
||||
|
||||
@@ -512,6 +512,14 @@ Meteor.startup(async () => {
|
||||
// and caused SQLITE_BUSY (#6480). This compound index covers that filter.
|
||||
await ensureIndex(Cards, { boardId: 1, archived: 1 });
|
||||
await ensureIndex(Cards, { parentId: 1 });
|
||||
// Admin Panel / Problems / Broken cards asks for cards with NO board, swimlane
|
||||
// or list, or an unknown type - an $or, which can only use an index if each of
|
||||
// its branches has one. `boardId` is covered by { boardId, createdAt } above;
|
||||
// these are the other three, so counting the report (and paging it) does not
|
||||
// scan every card in the database.
|
||||
await ensureIndex(Cards, { swimlaneId: 1 });
|
||||
await ensureIndex(Cards, { listId: 1 });
|
||||
await ensureIndex(Cards, { type: 1 });
|
||||
Meteor.defer(() => {
|
||||
addCronJob();
|
||||
});
|
||||
|
||||
@@ -1682,6 +1682,17 @@ Meteor.startup(async () => {
|
||||
await ensureIndex(Lists, value);
|
||||
}
|
||||
await ensureIndex(Users, { modifiedAt: -1 });
|
||||
// Admin Panel / People pages users newest-first and counts them with the same
|
||||
// selector (server/publications/people.js, getUsersCollectionCount,
|
||||
// getPeoplePageIds). Without these, every page of an instance with tens of
|
||||
// thousands of users sorted the WHOLE collection in memory to hand back ten
|
||||
// rows, and each filter counted by scanning it. The filters are compound with
|
||||
// `createdAt` so one index serves both the count and the page it belongs to.
|
||||
await ensureIndex(Users, { createdAt: -1 });
|
||||
await ensureIndex(Users, { loginDisabled: 1, createdAt: -1 });
|
||||
await ensureIndex(Users, { isAdmin: 1, createdAt: -1 });
|
||||
await ensureIndex(Users, { 'orgs.orgId': 1, createdAt: -1 });
|
||||
await ensureIndex(Users, { 'services.accounts-lockout.unlockTime': 1 });
|
||||
Meteor.defer(() => {
|
||||
repairLegacyAvatarUrls();
|
||||
});
|
||||
@@ -2195,6 +2206,56 @@ Meteor.methods({
|
||||
return typeof cursor.countAsync === 'function' ? await cursor.countAsync() : cursor.count();
|
||||
},
|
||||
|
||||
// WHICH users are on one page of Admin Panel > People > People, in order.
|
||||
//
|
||||
// The `people` publication sends exactly one page, but the browser holds more
|
||||
// user documents than that page: the logged-in user's own record is always in
|
||||
// minimongo (accounts publishes it), and so is anyone another subscription has
|
||||
// pulled in. The table read the page back with a plain `Users.find(query)`, which
|
||||
// cannot tell those apart from the page - so the admin looking at the list
|
||||
// appeared on EVERY page of it, and each person could be listed more than once.
|
||||
//
|
||||
// The ids are the missing piece: with them the client can render the page the
|
||||
// server actually sent, in the server's order, and nothing else. Only ids are
|
||||
// returned - the documents themselves still arrive through the publication, with
|
||||
// its field list - and the scoping is the same rule module the publication and
|
||||
// the count use, so a per-tenant Global Admin gets their own org's page here too.
|
||||
async getPeoplePageIds(query = {}, limit = 25, skip = 0) {
|
||||
check(query, Match.OneOf(Object, null, undefined));
|
||||
check(limit, Number);
|
||||
check(skip, Match.OneOf(Number, null, undefined));
|
||||
|
||||
if (!this.userId) {
|
||||
throw new Meteor.Error('not-logged-in', 'User must be logged in');
|
||||
}
|
||||
|
||||
const currentUser = await ReactiveCache.getUser(
|
||||
{ _id: this.userId },
|
||||
{ fields: { isAdmin: 1, orgs: 1 } },
|
||||
);
|
||||
|
||||
if (!tenantAdmin.canOpenAdminPanel(currentUser)) {
|
||||
throw new Meteor.Error('not-authorized', 'Admin access required');
|
||||
}
|
||||
|
||||
// The same selector, the same sort and the same window as the publication -
|
||||
// anything else would name a different page than the one that was sent.
|
||||
const cursor = await ReactiveCache.getUsers(
|
||||
tenantAdmin.peopleScopeSelector(currentUser, query || {}),
|
||||
{
|
||||
limit,
|
||||
skip: skip || 0,
|
||||
sort: { createdAt: -1 },
|
||||
fields: { _id: 1 },
|
||||
},
|
||||
true,
|
||||
);
|
||||
const docs = typeof cursor.fetchAsync === 'function'
|
||||
? await cursor.fetchAsync()
|
||||
: cursor.fetch();
|
||||
return docs.map(doc => doc._id);
|
||||
},
|
||||
|
||||
// #5850: Admin Panel > People > Domains. Returns the list of email-address
|
||||
// domains across all users and the count of users per domain (each user
|
||||
// counted once by their primary email's domain), sorted by count desc then
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
// 2. the user has starred
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { publishComposite } from 'meteor/reywood:publish-composite';
|
||||
import { publishReportPage } from '/models/lib/reportPageIndex';
|
||||
import { findWhere } from '/imports/lib/collectionHelpers';
|
||||
import Users from "../../models/users";
|
||||
import Org from "../../models/org";
|
||||
@@ -196,6 +197,9 @@ Meteor.publish('boardsReport', async function(searchTerm = '', limit, skip = 0)
|
||||
for (const doc of users) { const { _id, ...fields } = doc; this.added('users', _id, fields); }
|
||||
for (const doc of teams) { const { _id, ...fields } = doc; this.added('team', _id, fields); }
|
||||
for (const doc of orgs) { const { _id, ...fields } = doc; this.added('org', _id, fields); }
|
||||
// WHICH boards this page is, in this order: the admin's own boards are in
|
||||
// minimongo whatever page is open, so the pane must render the named page only.
|
||||
publishReportPage(this, 'report-boards', boards);
|
||||
this.ready();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { ReactiveCache } from '/imports/reactiveCache';
|
||||
import { publishComposite } from 'meteor/reywood:publish-composite';
|
||||
import { publishReportPage } from '/models/lib/reportPageIndex';
|
||||
import { findWhere } from '/imports/lib/collectionHelpers';
|
||||
import escapeForRegex from 'escape-string-regexp';
|
||||
import Users from '../../models/users';
|
||||
@@ -1059,6 +1060,10 @@ Meteor.publish('brokenCardsReport', async function(searchTerm = '', limit, skip
|
||||
for (const doc of boards) { const { _id, ...fields } = doc; this.added('boards', _id, fields); }
|
||||
for (const doc of lists) { const { _id, ...fields } = doc; this.added('lists', _id, fields); }
|
||||
for (const doc of swimlanes) { const { _id, ...fields } = doc; this.added('swimlanes', _id, fields); }
|
||||
// WHICH cards this page is, in this order. Minimongo holds every card of every
|
||||
// board the admin has opened, so without this the pane rendered all of them -
|
||||
// hundreds of rows under a pager that correctly said "1 / 1".
|
||||
publishReportPage(this, 'report-broken', cards);
|
||||
this.ready();
|
||||
});
|
||||
|
||||
@@ -1323,6 +1328,8 @@ Meteor.publish('cardsReport', async function(searchTerm = '', limit, skip = 0) {
|
||||
for (const doc of lists) { const { _id, ...fields } = doc; this.added('lists', _id, fields); }
|
||||
for (const doc of swimlanes) { const { _id, ...fields } = doc; this.added('swimlanes', _id, fields); }
|
||||
for (const doc of users) { const { _id, ...fields } = doc; this.added('users', _id, fields); }
|
||||
// The page, named - see the note in brokenCardsReport above.
|
||||
publishReportPage(this, 'report-cards', cards);
|
||||
this.ready();
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
'use strict';
|
||||
|
||||
// A paginated admin list must show the page the SERVER sent - only it, all of it,
|
||||
// in its order.
|
||||
//
|
||||
// Both panes read their page back out of minimongo, which holds far more than the
|
||||
// page:
|
||||
//
|
||||
// * Admin Panel / People rendered `Users.find(query)`. The logged-in user's own
|
||||
// record is always in minimongo (accounts publishes it), so the admin looking at
|
||||
// the list appeared on EVERY one of its 578 pages, and anyone another
|
||||
// subscription had pulled in could appear twice.
|
||||
//
|
||||
// * Admin Panel / Problems / Broken cards rendered `Cards.find({})`. Every card of
|
||||
// every board the admin had opened is in minimongo, so the report was one endless
|
||||
// page under a pager that - counting on the server - correctly said "1 / 1". The
|
||||
// Cards and Boards reports beside it read the same way.
|
||||
//
|
||||
// The publication (or, for People, a method) now NAMES the page, and the pane
|
||||
// renders that list. This pins the naming, the rendering, and the pure function
|
||||
// that puts the documents back in the server's order.
|
||||
//
|
||||
// Run: node tests/adminPageRows.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
(async () => {
|
||||
const { docsByIds } = await import('../models/lib/tablePage.js');
|
||||
const { REPORT_PAGE_COLLECTION, publishReportPage } =
|
||||
await import('../models/lib/reportPageIndex.js');
|
||||
|
||||
console.log('adminPageRows:');
|
||||
|
||||
test('the page is exactly what the server named, in its order', () => {
|
||||
const ids = ['c', 'a', 'b'];
|
||||
// Minimongo order is not the server's, and it holds a document that is not on
|
||||
// this page at all - the admin's own record, the one that was on every page.
|
||||
const inCache = [
|
||||
{ _id: 'a', title: 'A' },
|
||||
{ _id: 'me', title: 'the admin' },
|
||||
{ _id: 'b', title: 'B' },
|
||||
{ _id: 'c', title: 'C' },
|
||||
];
|
||||
assert.deepStrictEqual(docsByIds(ids, inCache).map(d => d._id), ['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
test('an id whose document has not arrived is left out, not drawn empty', () => {
|
||||
// The ids come back from their own round trip, so they can be there a moment
|
||||
// before the documents are. A row with no document is a row of blank cells.
|
||||
assert.deepStrictEqual(docsByIds(['a', 'late'], [{ _id: 'a' }]).map(d => d._id), ['a']);
|
||||
assert.deepStrictEqual(docsByIds([], [{ _id: 'a' }]), []);
|
||||
assert.deepStrictEqual(docsByIds(null, null), []);
|
||||
assert.deepStrictEqual(docsByIds(['a'], [null, undefined, { _id: 'a' }]).length, 1);
|
||||
});
|
||||
|
||||
test('a publication names its page with the ids it just sent', () => {
|
||||
const sent = [];
|
||||
const publication = { added: (coll, id, fields) => sent.push([coll, id, fields]) };
|
||||
publishReportPage(publication, 'report-broken', [{ _id: 'x' }, { _id: 'y' }]);
|
||||
assert.deepStrictEqual(sent, [[REPORT_PAGE_COLLECTION, 'report-broken', { ids: ['x', 'y'] }]]);
|
||||
// A document with no id is skipped rather than published as a null id.
|
||||
sent.length = 0;
|
||||
publishReportPage(publication, 'r', [{ _id: 'x' }, {}, null]);
|
||||
assert.deepStrictEqual(sent[0][2], { ids: ['x'] });
|
||||
});
|
||||
|
||||
test('People: the server names the page with the publication\'s own window', () => {
|
||||
const server = read('server/models/users.js');
|
||||
const at = server.indexOf('async getPeoplePageIds(');
|
||||
assert.ok(at !== -1, 'the method must exist');
|
||||
const method = server.slice(at, at + 1800);
|
||||
// Same guard as the count method beside it: this must not become a way to
|
||||
// enumerate user ids.
|
||||
assert.ok(/check\(query,/.test(method) && /check\(limit, Number\)/.test(method),
|
||||
'every argument is checked');
|
||||
assert.ok(/canOpenAdminPanel/.test(method), 'admin only');
|
||||
assert.ok(/peopleScopeSelector/.test(method),
|
||||
'and scoped by the same rule module as the publication');
|
||||
// Same page as the publication, or it would name rows that were never sent.
|
||||
const pub = read('server/publications/people.js');
|
||||
for (const part of ['sort: { createdAt: -1 }', 'limit,', 'skip: skip || 0,']) {
|
||||
assert.ok(method.includes(part), `the method must use ${part}`);
|
||||
assert.ok(pub.includes(part), `the publication uses ${part}`);
|
||||
}
|
||||
assert.ok(/fields: \{ _id: 1 \}/.test(method), 'ids only - the documents come from the publication');
|
||||
});
|
||||
|
||||
test('People: the table renders that page and nothing else', () => {
|
||||
const client = read('client/components/settings/peopleBody.js');
|
||||
assert.ok(/Meteor\.call\('getPeoplePageIds'/.test(client), 'it asks which users the page holds');
|
||||
assert.ok(/_id: \{ \$in: ids \}/.test(client), 'and looks up exactly those');
|
||||
assert.ok(/docsByIds\(ids, docs\)/.test(client), 'in the server\'s order');
|
||||
// The bug itself: reading the page back with the search query.
|
||||
assert.ok(!/Users\.find\(tpl\.findUsersOptions\.get\(\)/.test(client),
|
||||
'no bare query find - that is what put the admin on every page');
|
||||
// Adding or deleting a user changes which users the page holds, and neither
|
||||
// the query nor the page number changes with it.
|
||||
assert.ok(/peopleListChanged\(\)/.test(client), 'create/delete ask for the page again');
|
||||
assert.strictEqual((client.match(/peopleListChanged\(\);/g) || []).length, 2,
|
||||
'both the create and the delete handler');
|
||||
assert.ok(/peopleListVersion\.get\(\)/.test(client), 'and the table watches that');
|
||||
});
|
||||
|
||||
test('Problems: the three reports over shared collections name their pages', () => {
|
||||
const cards = read('server/publications/cards.js');
|
||||
assert.ok(/publishReportPage\(this, 'report-broken', cards\)/.test(cards));
|
||||
assert.ok(/publishReportPage\(this, 'report-cards', cards\)/.test(cards));
|
||||
const boards = read('server/publications/boards.js');
|
||||
assert.ok(/publishReportPage\(this, 'report-boards', boards\)/.test(boards));
|
||||
});
|
||||
|
||||
test('Problems: and the pane renders the named page', () => {
|
||||
const client = read('client/components/settings/adminReports.js');
|
||||
// From REPORT_TABLES on: each report id appears earlier in reportConfig too.
|
||||
const tables = client.slice(client.indexOf('const REPORT_TABLES = {'));
|
||||
for (const reportId of ['report-broken', 'report-cards', 'report-boards']) {
|
||||
const at = tables.indexOf(`'${reportId}': {`);
|
||||
assert.ok(at !== -1, `${reportId} is a report table`);
|
||||
const spec = tables.slice(at, at + 700);
|
||||
assert.ok(new RegExp(`docs: \\(\\) => reportPageResults\\([A-Za-z]+, '${reportId}'\\)`).test(spec),
|
||||
`${reportId} must render the page the publication named`);
|
||||
assert.ok(!/docs: \(\) => collectionResults\((Cards|Boards)/.test(spec),
|
||||
`${reportId} must not render everything the collection happens to hold`);
|
||||
}
|
||||
// The client side of the DDP-only index collection, from the shared name.
|
||||
assert.ok(/new Mongo\.Collection\(REPORT_PAGE_COLLECTION\)/.test(client),
|
||||
'the index collection is declared from the shared constant, not a typed string');
|
||||
assert.ok(/docsByIds\(ids, collection\.find\(\{ _id: \{ \$in: ids \} \}\)\.fetch\(\)\)/.test(client),
|
||||
'and the page is looked up by those ids, in that order');
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
})();
|
||||
@@ -0,0 +1,149 @@
|
||||
'use strict';
|
||||
|
||||
// Counting the pages must not read the rows.
|
||||
//
|
||||
// Every paginated page in WeKan asks the server two things: one page of rows, and
|
||||
// the TOTAL, so it can say "page X / N". Two ways to get that total wrong:
|
||||
//
|
||||
// 1. Fetching the documents and taking `.length`. That ships (or at least
|
||||
// materialises) the whole collection to answer a number - the thing pagination
|
||||
// exists to avoid. Every count here must go through the cursor's `count`
|
||||
// command instead.
|
||||
//
|
||||
// 2. Counting - or paging - on a field with no index. The database then scans the
|
||||
// collection for the count and sorts it in memory for the page. It works, and
|
||||
// it gets slower every day: Admin Panel / People sorts users newest-first, so
|
||||
// on an instance with 14000 users each page of ten sorted all 14000 first.
|
||||
//
|
||||
// So this pins the count implementations AND the indexes behind the selectors and
|
||||
// sorts those pages use. `ensureIndex` (server/lib/mongoStartup.js) is idempotent
|
||||
// and runs at startup, so a missing index is created on upgrade rather than being
|
||||
// something an admin has to know about.
|
||||
//
|
||||
// Run: node tests/paginationIndexes.test.cjs
|
||||
|
||||
const assert = require('assert');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const ROOT = path.resolve(__dirname, '..');
|
||||
const read = rel => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
||||
|
||||
let passed = 0;
|
||||
function test(name, fn) { fn(); passed += 1; console.log(' ok -', name); }
|
||||
|
||||
// Every count method behind a paginated page, and the file it lives in.
|
||||
const COUNT_METHODS = [
|
||||
['server/models/users.js', 'getUsersCollectionCount'],
|
||||
['server/models/org.js', 'getOrgsCollectionCount'],
|
||||
['server/models/team.js', 'getTeamsCollectionCount'],
|
||||
['server/models/translation.js', 'getTranslationsCollectionCount'],
|
||||
['server/publications/cards.js', 'getCardsReportCount'],
|
||||
['server/publications/cards.js', 'getBrokenCardsReportCount'],
|
||||
['server/publications/boards.js', 'getBoardsReportCount'],
|
||||
['server/publications/boards.js', 'getArchivedBoardsCount'],
|
||||
['server/publications/rules.js', 'getRulesReportCount'],
|
||||
['server/publications/attachments.js', 'getAttachmentsReportCount'],
|
||||
['server/publications/impersonationReport.js', 'getImpersonationReportCount'],
|
||||
['server/publications/recoveryReport.js', 'getRecoveryReportCount'],
|
||||
['models/eventLog.js', 'eventLogCount'],
|
||||
];
|
||||
|
||||
// An index is written as an object literal in an ensureIndex() call. Compare on
|
||||
// the text between the braces, whitespace-normalised, so the test does not depend
|
||||
// on how the literal is spaced.
|
||||
function indexesOf(rel) {
|
||||
const src = read(rel);
|
||||
return [...src.matchAll(/ensureIndex\(\s*\w+\s*,\s*(\{[\s\S]*?\})\s*[,)]/g)]
|
||||
.map(m => m[1].replace(/\s+/g, ' ').trim());
|
||||
}
|
||||
function hasIndex(rel, literal) {
|
||||
const want = literal.replace(/\s+/g, ' ').trim();
|
||||
return indexesOf(rel).includes(want);
|
||||
}
|
||||
|
||||
console.log('paginationIndexes:');
|
||||
|
||||
test('no count materialises the rows to count them', () => {
|
||||
for (const [file, method] of COUNT_METHODS) {
|
||||
const src = read(file);
|
||||
const at = src.indexOf(`${method}(`);
|
||||
assert.ok(at !== -1, `${file}: ${method} must exist`);
|
||||
const body = src.slice(at, at + 1600);
|
||||
const end = body.indexOf('\n },');
|
||||
const method_body = end > 0 ? body.slice(0, end) : body;
|
||||
assert.ok(/count(Async)?\(\)/.test(method_body),
|
||||
`${method} must ask the database for the count`);
|
||||
// The failure this guards: fetch it all, then take the length.
|
||||
assert.ok(!/\.fetch(Async)?\(\)[\s\S]{0,40}\.length/.test(method_body),
|
||||
`${method} must not fetch the rows just to count them`);
|
||||
}
|
||||
});
|
||||
|
||||
test('People pages and counts users on indexed fields', () => {
|
||||
// Sorted newest-first by the publication, the id method and the count.
|
||||
assert.ok(hasIndex('server/models/users.js', '{ createdAt: -1 }'),
|
||||
'the sort of every People page must be indexed');
|
||||
// Each filter of the People pane, compound with that same sort so ONE index
|
||||
// serves the count and the page it belongs to.
|
||||
for (const literal of [
|
||||
'{ loginDisabled: 1, createdAt: -1 }', // active / inactive
|
||||
'{ isAdmin: 1, createdAt: -1 }', // admins
|
||||
"{ 'orgs.orgId': 1, createdAt: -1 }", // the per-tenant admin's scope
|
||||
"{ 'services.accounts-lockout.unlockTime': 1 }", // locked
|
||||
]) {
|
||||
assert.ok(hasIndex('server/models/users.js', literal),
|
||||
`People filters by ${literal} - it must be indexed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('Broken cards can use an index for every branch of its $or', () => {
|
||||
// The report asks for cards with no board, no swimlane, no list, or an unknown
|
||||
// type. An $or uses an index only if EVERY branch has one, so one unindexed
|
||||
// field puts the whole count back to a collection scan.
|
||||
const pub = read('server/publications/cards.js');
|
||||
const selector = pub.slice(pub.indexOf('const BROKEN_CARDS_SELECTOR'),
|
||||
pub.indexOf('const BROKEN_CARDS_SELECTOR') + 320);
|
||||
for (const field of ['boardId', 'swimlaneId', 'listId', 'type']) {
|
||||
assert.ok(selector.includes(field), `the selector still asks about ${field}`);
|
||||
}
|
||||
// boardId is the prefix of the existing { boardId, createdAt } index, which is
|
||||
// also the report's sort.
|
||||
assert.ok(hasIndex('server/models/cards.js', '{ boardId: 1, createdAt: -1 }'));
|
||||
for (const literal of ['{ swimlaneId: 1 }', '{ listId: 1 }', '{ type: 1 }']) {
|
||||
assert.ok(hasIndex('server/models/cards.js', literal),
|
||||
`broken cards branches on ${literal} - it must be indexed`);
|
||||
}
|
||||
});
|
||||
|
||||
test('the other paginated reports sort on indexed fields too', () => {
|
||||
assert.ok(hasIndex('server/models/boards.js', '{ sort: 1 }'),
|
||||
'the Boards report pages by the boards\' own order');
|
||||
assert.ok(hasIndex('server/models/boards.js', "{ archived: 1, 'members.userId': 1 }"),
|
||||
'the archive pages one member\'s archived boards');
|
||||
assert.ok(hasIndex('models/rules.js', '{ boardId: 1 }'),
|
||||
'the Rules report sorts by board');
|
||||
assert.ok(hasIndex('models/impersonatedUsers.js', '{ createdAt: -1 }'));
|
||||
assert.ok(hasIndex('models/recoveryEvents.js', '{ createdAt: -1 }'));
|
||||
assert.ok(hasIndex('models/eventLog.js', '{ stream: 1, at: -1 }'),
|
||||
'the event streams filter by stream and sort by time');
|
||||
assert.ok(hasIndex('models/attachments.server.js', "{ 'meta.boardId': 1 }"),
|
||||
'the Files report is scoped by board');
|
||||
});
|
||||
|
||||
test('the indexes are created on startup, idempotently', () => {
|
||||
// ensureIndex only adds what is missing, so an upgrade creates the new ones and
|
||||
// never fails on an index that is already there.
|
||||
const startup = read('server/lib/mongoStartup.js');
|
||||
assert.ok(/IDEMPOTENT/i.test(startup) || /only add the ones that are missing/i.test(startup),
|
||||
'mongoStartup must document that it only creates what is missing');
|
||||
for (const file of ['server/models/users.js', 'server/models/cards.js',
|
||||
'server/models/boards.js', 'models/rules.js', 'models/impersonatedUsers.js',
|
||||
'models/recoveryEvents.js']) {
|
||||
const src = read(file);
|
||||
assert.ok(/Meteor\.startup\(/.test(src) && /ensureIndex\(/.test(src),
|
||||
`${file} must create its indexes at startup`);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`\n${passed} tests passed`);
|
||||
@@ -260,6 +260,43 @@ test('the subscribed window comes from the same helper as the counter', () => {
|
||||
'the hand-rolled second paginator must be gone');
|
||||
});
|
||||
|
||||
test('every paginated page loads the SAME ten rows at a time', () => {
|
||||
// One number for the whole app, wherever the pager is drawn: the shared table
|
||||
// page, the Admin Panel reports and event streams, the People panes, the search
|
||||
// pages and the archive. A page that writes its own is the bug this pins.
|
||||
assert.strictEqual(lib.TABLE_PAGE_ROWS_PER_PAGE, 10,
|
||||
'the app pages ten rows at a time (docs/Design/Page/Table.md)');
|
||||
const sources = {
|
||||
'client/components/settings/adminReports.js':
|
||||
['const REPORTS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;',
|
||||
'const EVENTS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;'],
|
||||
'client/components/settings/peopleBody.js':
|
||||
['const orgsPerPage = TABLE_PAGE_ROWS_PER_PAGE;',
|
||||
'const teamsPerPage = TABLE_PAGE_ROWS_PER_PAGE;',
|
||||
'const usersPerPage = TABLE_PAGE_ROWS_PER_PAGE;',
|
||||
'const domainsPerPage = TABLE_PAGE_ROWS_PER_PAGE;'],
|
||||
'client/lib/cardSearch.js':
|
||||
['this.resultsPerPage = TABLE_PAGE_ROWS_PER_PAGE;'],
|
||||
'client/components/boards/boardArchive.js':
|
||||
['const ARCHIVED_BOARDS_PER_PAGE = TABLE_PAGE_ROWS_PER_PAGE;'],
|
||||
};
|
||||
for (const [file, lines] of Object.entries(sources)) {
|
||||
const src = read(file);
|
||||
for (const line of lines) {
|
||||
assert.ok(src.includes(line), `${file} must read the shared number: ${line}`);
|
||||
}
|
||||
assert.ok(src.includes('TABLE_PAGE_ROWS_PER_PAGE }') ||
|
||||
/TABLE_PAGE_ROWS_PER_PAGE[,\s}]/.test(src.slice(0, src.indexOf('\n\n'))) ||
|
||||
/import \{[^}]*TABLE_PAGE_ROWS_PER_PAGE/.test(src),
|
||||
`${file} must import it rather than redefine it`);
|
||||
}
|
||||
// The one module that cannot import it - a plain-node test require()s it - must
|
||||
// still carry the same number.
|
||||
const { PER_PAGE_DEFAULT } = require('../models/lib/domainTablePage');
|
||||
assert.strictEqual(PER_PAGE_DEFAULT, lib.TABLE_PAGE_ROWS_PER_PAGE,
|
||||
'domainTablePage.PER_PAGE_DEFAULT must not drift from the shared number');
|
||||
});
|
||||
|
||||
// ── the doc ─────────────────────────────────────────────────────────────────
|
||||
|
||||
test('the design doc lists the pages and they exist in code', () => {
|
||||
|
||||
Reference in new issue
Block a user