mirror of
https://github.com/wekan/wekan.git
synced 2026-11-08 03:22:32 -05:00
PASSWORD_MIN_LENGTH (default 8) is now the length below which a sign-in is REPORTED, in the new Admin Panel / Problems / Password length, and PASSWORD_MIN_LENGTH_ENFORCE (off by default) is what makes it a rule: a shorter new password is refused, and an account that signs in with an older shorter one sees only "Change your password" until it has a long enough one. Signing in is never refused, enforced or not, so nobody is locked out by a minimum set after their password. LDAP, OAuth2, OIDC, SAML, CAS and Sandstorm accounts are neither reported nor asked: their password is their provider's. An administrator's Set password no longer applies WeKan's policy to such an account. Meteor sends a password hashed, so only the browser knows its length: it notes it on the sign-in form and, after a sign-in that worked, says "it was short" - never the password or its length. The page has both settings and a table, one row per account: username, first and last short-password login, the latest IPv4 and IPv6 address with the country's flag and city when a CDN says where it is, the count, and when it was fixed, with the Too short (default) / Fixed / All filter. Fixed is a changed password not seen short since; a short one seen again reopens the row. Changing one's own password - Member Settings, the forced change, a reset link - now signs EVERY session of the account out, the one that changed it too, and only the new password signs in again. The browser goes to the sign-in page as soon as it is signed out, since the server closes the connection before the answer can arrive. Documented in docs/Features/Admin-Panel/Problems/Password-length.md, the settings on every platform. The new strings are in every locale in English for now. Tests: passwordPolicy, passwordChangeRequired and shortPasswordLogins (node); password-min-length-signup-login, password-length-report and password-change-other-sessions (Playwright, Chromium and WebKit), which change each setting on the page and check what it does, and that sign-up with no mail server registers and signs the user in. Thanks to xet7 !
2046 lines
93 KiB
JavaScript
2046 lines
93 KiB
JavaScript
import { formatDateForDisplay } from '/client/lib/dateDisplay';
|
|
import { ReactiveCache } from '/imports/reactiveCache';
|
|
import { Session } from 'meteor/session';
|
|
import { Random } from 'meteor/random';
|
|
import { Tracker } from 'meteor/tracker';
|
|
import { FlowRouter } from 'meteor/ostrio:flow-router-extra';
|
|
// The per-pane URLs of the Admin Panel. docs/Features/Admin-Panel/Admin-Panel-URLs.md
|
|
import { adminPath } from '/models/lib/adminUrls';
|
|
import { TAPi18n } from '/imports/i18n';
|
|
import Attachments from '/models/attachments';
|
|
import Boards from '/models/boards';
|
|
import Cards from '/models/cards';
|
|
import Rules from '/models/rules';
|
|
import ImpersonatedUsers from '/models/impersonatedUsers';
|
|
import RecoveryEvents from '/models/recoveryEvents';
|
|
import { Mongo } from 'meteor/mongo';
|
|
import { adjacentPage, buildFilters, buildHeader, buildRows, docsByIds, pageInfo, TABLE_PAGE_ROWS_PER_PAGE } from '/models/lib/tablePage';
|
|
const { addressReportColumns } = require('/models/lib/addressReportColumns');
|
|
const { officeRowsByPerson } = require('/models/lib/loginTally');
|
|
import { ReportPages } from '/client/lib/reportPages';
|
|
import { leftMenuData, paneTitle } from '/models/lib/leftMenu';
|
|
import Settings from '/models/settings';
|
|
import { errorMessage } from '/client/lib/errorMessage';
|
|
const { cleanFileName } = require('/imports/lib/fileNameDisplay');
|
|
const { attachmentKind } = require('/models/lib/attachmentKind');
|
|
const { filesize } = require('filesize');
|
|
|
|
// --- Shared helper functions (formerly AdminReport base class methods) ---
|
|
|
|
function yesOrNo(value) {
|
|
if (value) {
|
|
return TAPi18n.__('yes');
|
|
} else {
|
|
return TAPi18n.__('no');
|
|
}
|
|
}
|
|
|
|
function fileSizeHelper(size) {
|
|
let ret = "";
|
|
if (typeof size === 'number') {
|
|
ret = filesize(size);
|
|
}
|
|
return ret;
|
|
}
|
|
|
|
function abbreviate(text) {
|
|
if (text?.length > 30) {
|
|
return `${text.substr(0, 29)}...`;
|
|
}
|
|
return text;
|
|
}
|
|
|
|
// The same audit trail is relevant where permanent deletion is enabled and where
|
|
// its events are reviewed. Keep one sentence so the two panes cannot drift apart.
|
|
const PERMANENT_DELETE_RECOVERY_DESCRIPTION =
|
|
'The permanent-delete setting must be enabled before a delete icon is shown. Recovery logs setting changes and every successful, failed, or unauthorized permanent-delete attempt, including Done status, user ID, username, trusted IPv4 or IPv6 address and available location. Board deletion records IDs and titles.';
|
|
|
|
// History.md §12.3-12.4: there is no per-attachment delete, here or anywhere.
|
|
const FILES_REPORT_DELETE_DESCRIPTION =
|
|
'Attachments are never deleted one at a time. Delete on a card soft-deletes the attachment and keeps the file; the card history shows who deleted it and restores it. An attachment and its file are removed only when the permanent-delete setting is enabled and its board is deleted from the archive, which Recovery records as a board deletion.';
|
|
|
|
// The report publications already send only the current page (server-side
|
|
// search + limit/skip, sorted). Display exactly what was published, applying
|
|
// the same sort so minimongo order matches the server page. Re-applying
|
|
// skip/limit here would paginate an already-paginated set.
|
|
function collectionResults(collection, sort) {
|
|
return collection.find({}, sort ? { sort } : {});
|
|
}
|
|
|
|
// The page index the report publications send. Declared once in
|
|
// client/lib/reportPages.js, because `new Mongo.Collection(name)` throws if the
|
|
// name is taken - it used to be declared here, so the second page to need it
|
|
// (/public) could not have one.
|
|
|
|
// 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();
|
|
}
|
|
|
|
// --- adminProblems template ---
|
|
|
|
// Rows per page for the paginated reports (files, rules, boards, cards). The one
|
|
// rows-per-page of the whole app (docs/Features/General/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
|
|
// single loadReport() drive all of them the same way.
|
|
function reportConfig(tmpl) {
|
|
return {
|
|
'report-files': { page: tmpl.filesPage, count: tmpl.filesCount, search: tmpl.filesSearch, pub: 'attachmentsList', countMethod: 'getAttachmentsReportCount' },
|
|
'report-rules': { page: tmpl.rulesPage, count: tmpl.rulesCount, search: tmpl.rulesSearch, pub: 'rulesReport', countMethod: 'getRulesReportCount' },
|
|
'report-boards': { page: tmpl.boardsPage, count: tmpl.boardsCount, search: tmpl.boardsSearch, filter: tmpl.boardsFilter, pub: 'boardsReport', countMethod: 'getBoardsReportCount' },
|
|
'report-cards': { page: tmpl.cardsPage, count: tmpl.cardsCount, search: tmpl.cardsSearch, pub: 'cardsReport', countMethod: 'getCardsReportCount' },
|
|
'report-broken': { page: tmpl.brokenPage, count: tmpl.brokenCount, search: tmpl.brokenSearch, pub: 'brokenCardsReport', countMethod: 'getBrokenCardsReportCount' },
|
|
'report-impersonation': { page: tmpl.impersonationPage, count: tmpl.impersonationCount, search: tmpl.impersonationSearch, pub: 'impersonationReport', countMethod: 'getImpersonationReportCount' },
|
|
'report-recovery': { page: tmpl.recoveryPage, count: tmpl.recoveryCount, search: tmpl.recoverySearch, filter: tmpl.recoveryFilter, pub: 'recoveryReport', countMethod: 'getRecoveryReportCount' },
|
|
};
|
|
}
|
|
|
|
Template.adminProblems.onCreated(function () {
|
|
this.subscription = null;
|
|
this.error = new ReactiveVar('');
|
|
this.loading = new ReactiveVar(false);
|
|
|
|
// Pagination state, one current-page + total-count pair per report.
|
|
this.activeReport = new ReactiveVar('');
|
|
this.filesPage = new ReactiveVar(1);
|
|
this.rulesPage = new ReactiveVar(1);
|
|
this.boardsPage = new ReactiveVar(1);
|
|
this.cardsPage = new ReactiveVar(1);
|
|
this.brokenPage = new ReactiveVar(1);
|
|
this.impersonationPage = new ReactiveVar(1);
|
|
this.recoveryPage = new ReactiveVar(1);
|
|
this.filesCount = new ReactiveVar(0);
|
|
this.rulesCount = new ReactiveVar(0);
|
|
this.boardsCount = new ReactiveVar(0);
|
|
this.cardsCount = new ReactiveVar(0);
|
|
this.brokenCount = new ReactiveVar(0);
|
|
this.impersonationCount = new ReactiveVar(0);
|
|
this.recoveryCount = new ReactiveVar(0);
|
|
// Current search term per report (empty string = no filter).
|
|
this.filesSearch = new ReactiveVar('');
|
|
this.rulesSearch = new ReactiveVar('');
|
|
this.boardsSearch = new ReactiveVar('');
|
|
this.cardsSearch = new ReactiveVar('');
|
|
this.brokenSearch = new ReactiveVar('');
|
|
this.impersonationSearch = new ReactiveVar('');
|
|
this.recoverySearch = new ReactiveVar('');
|
|
this.recoveryFilter = new ReactiveVar('all');
|
|
this.boardsFilter = new ReactiveVar('all');
|
|
|
|
// Which search term each report's total count was last computed for, so
|
|
// paging does not recount. See loadReport().
|
|
this.countedFor = {};
|
|
|
|
// (Re)subscribe the given report for its current page and refresh its total
|
|
// count. Server-side search + limit/skip means only the matching page ever
|
|
// reaches minimongo.
|
|
//
|
|
// `recount` (opening a report, or searching in it) re-runs the count method;
|
|
// plain prev/next paging does NOT. The count is a full collection count —
|
|
// getCardsReportCount counts every card in the database — and the total cannot
|
|
// change just because you moved to the next page, so re-running it on every
|
|
// click only added a second server round trip to each prev/next. That is what
|
|
// made paging through a big Cards report feel slow.
|
|
this.loadReport = (reportId, { recount = false } = {}) => {
|
|
const cfg = reportConfig(this)[reportId];
|
|
if (!cfg) {
|
|
return;
|
|
}
|
|
// Note: we deliberately do not toggle the global `loading` spinner here.
|
|
// The spinner is shown once on tab switch (switchMenu); paging/searching
|
|
// re-subscribes silently so the search box keeps focus and the report
|
|
// stays mounted, just like the admin People panel.
|
|
if (this.subscription) {
|
|
this.subscription.stop();
|
|
}
|
|
const searchTerm = cfg.search.get();
|
|
const filter = cfg.filter ? cfg.filter.get() : null;
|
|
const requestKey = filter === null ? searchTerm : `${searchTerm}\u0000${filter}`;
|
|
// Same helper the controls row uses, so the window that is SUBSCRIBED and the
|
|
// "page X / N" that is DISPLAYED can never disagree. Only this page of rows is
|
|
// requested from the server - the publications apply the limit/skip.
|
|
const { limit, skip } = pageInfo(cfg.count.get(), cfg.page.get(), REPORTS_PER_PAGE);
|
|
const subscriptionArgs = filter === null
|
|
? [cfg.pub, searchTerm, limit, skip]
|
|
: [cfg.pub, searchTerm, filter, limit, skip];
|
|
this.subscription = Meteor.subscribe(...subscriptionArgs, {
|
|
onReady: () => {
|
|
this.loading.set(false);
|
|
},
|
|
// A publication that errors (or is stopped) must never leave the report
|
|
// stuck on the loading spinner forever: clear the spinner and, on a real
|
|
// error, surface it. Without this an admin only ever saw an endless spinner
|
|
// whenever a report subscription failed on the server.
|
|
onStop: (error) => {
|
|
if (error) {
|
|
console.error(`Report subscription '${cfg.pub}' failed:`, error);
|
|
this.error.set(error.reason || error.message || String(error));
|
|
}
|
|
this.loading.set(false);
|
|
},
|
|
});
|
|
if (!recount && this.countedFor[reportId] === requestKey) {
|
|
return;
|
|
}
|
|
const countArgs = filter === null
|
|
? [cfg.countMethod, searchTerm]
|
|
: [cfg.countMethod, searchTerm, filter];
|
|
Meteor.call(...countArgs, (error, count) => {
|
|
if (error) {
|
|
console.error(`Failed to load ${cfg.countMethod}:`, error);
|
|
return;
|
|
}
|
|
this.countedFor[reportId] = requestKey;
|
|
const total = count || 0;
|
|
const totalPages = Math.max(1, Math.ceil(total / REPORTS_PER_PAGE));
|
|
// If rows were removed while on a now-out-of-range page, clamp and reload.
|
|
if (cfg.page.get() > totalPages) {
|
|
cfg.page.set(totalPages);
|
|
this.loadReport(reportId);
|
|
return;
|
|
}
|
|
cfg.count.set(total);
|
|
});
|
|
};
|
|
|
|
// Which user the open report was subscribed as. See the autorun below.
|
|
this.subscribedAs = null;
|
|
|
|
// The pane the URL asks for. The route resolved it, so it is always a real
|
|
// pane id; a bare /admin-reports opens Summary. Reactive, so following a link
|
|
// to another report while this page is open switches to it - the route action
|
|
// runs again without re-creating the template.
|
|
// docs/Features/Admin-Panel/Admin-Panel-URLs.md
|
|
//
|
|
// It depends on Meteor.userId() as well, and that is not decoration. Opening
|
|
// a report BY ITS ADDRESS - /admin/problems/files typed, bookmarked, or just
|
|
// refreshed - is a full page load, and Meteor resumes the login from
|
|
// localStorage ASYNCHRONOUSLY. The route sets problemsOpenPane before that
|
|
// lands, so the subscription was made with no user; the publication's admin
|
|
// check then answered `this.ready()` with no rows, and nothing ever
|
|
// re-subscribed because this autorun did not depend on the user. The pane drew
|
|
// its column headers, "No results" and a "1 / 1" pager over data that was
|
|
// plainly there - while the count METHOD, called later from the same page,
|
|
// happily reported five. Reached from the menu it worked, because by then the
|
|
// login had landed; only the address did not.
|
|
//
|
|
// openReportPane() returns early when the pane is already open, so re-running
|
|
// it after the login would do nothing at all - hence the second branch, which
|
|
// re-subscribes the report that is already open now that there is a user to
|
|
// subscribe as.
|
|
//
|
|
// The ONLY two things that may re-run this autorun are the pane id and the
|
|
// user - read reactively, above the nonreactive body. Everything the body
|
|
// touches (activeReport, and the count/page/search that loadReport reads
|
|
// through pageInfo) is read INSIDE Tracker.nonreactive, on purpose:
|
|
//
|
|
// 1. loadReport() calls its count method and then cfg.count.set(...). If the
|
|
// autorun depended on cfg.count, that set would re-run it - and a
|
|
// Meteor.subscribe made inside an autorun is AUTO-CANCELLED when the
|
|
// autorun re-runs. The re-run then took the `else if` (same user, same
|
|
// pane), did NOT re-subscribe, and left the report with no subscription
|
|
// at all: "attachments in minimongo: 0", the empty table over data that
|
|
// was plainly there. From the menu it worked only because switchMenu
|
|
// calls openReportPane from an EVENT, not a computation, so that
|
|
// subscribe was never auto-managed. By URL it was, which is why only the
|
|
// address failed.
|
|
// 2. The subscription's lifetime is ours to manage (loadReport stops the
|
|
// previous one; onDestroyed below stops the last), not the autorun's, so
|
|
// it must not be created inside this computation's reactive scope.
|
|
this.autorun(() => {
|
|
const paneId = Session.get('problemsOpenPane');
|
|
const userId = Meteor.userId();
|
|
Tracker.nonreactive(() => {
|
|
if (!paneId) {
|
|
return;
|
|
}
|
|
if (paneId !== this.activeReport.get()) {
|
|
openReportPane(this, paneId);
|
|
this.subscribedAs = userId;
|
|
} else if (userId !== this.subscribedAs) {
|
|
this.subscribedAs = userId;
|
|
if (reportConfig(this)[paneId]) {
|
|
this.loadReport(paneId, { recount: true });
|
|
}
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// The report subscription is created OUTSIDE a reactive computation now (see the
|
|
// autorun above), so the autorun no longer tears it down - this does, when the
|
|
// panel is left.
|
|
Template.adminProblems.onDestroyed(function () {
|
|
if (this.subscription) {
|
|
this.subscription.stop();
|
|
}
|
|
});
|
|
|
|
// The Problems side menu, as data (docs/Features/All-Boards/Left-Menu.md). Every entry
|
|
// used to be six lines of markup plus its own click handler; the twelve handlers
|
|
// are now the single .js-left-menu-item one below.
|
|
const PROBLEMS_MENU = [
|
|
{ id: 'report-summary', icon: 'fa-list', labelKey: 'summary' },
|
|
{ separator: true },
|
|
// Two groups, each named by a heading rather than by another entry: there is
|
|
// nothing to click on a group title (docs/Features/All-Boards/Left-Menu.md). Both use an
|
|
// i18n key the app already has.
|
|
{ heading: true, labelKey: 'settings' },
|
|
// Moved here from Admin Panel / Features. They are settings rather than reports -
|
|
// which is what this heading says - but they are what an admin reaches for when
|
|
// something is unsafe or noisy, which is what this page is about. Performance
|
|
// sits with the reports below, beside the Speed / Tests / CPU usage streams it is
|
|
// about.
|
|
{ id: 'features-security', icon: 'fa-shield', labelKey: 'features-security', emoji: true },
|
|
{ id: 'features-delete', icon: 'fa-trash', labelKey: 'delete' },
|
|
{ id: 'features-notifications', icon: 'fa-bell', labelKey: 'features-notifications', emoji: true },
|
|
// Did last night's backup work? The status and history of the Admin Panel
|
|
// backup (backupRunsStatus.jade), with a link to its settings in Attachments /
|
|
// Backup. A failed or overdue backup is also listed on Summary.
|
|
{ id: 'features-backup', icon: 'fa-archive', labelKey: 'backup' },
|
|
{ separator: true },
|
|
{ heading: true, labelKey: 'reports' },
|
|
// "Security Report", not "Security": the Features pane above is also called
|
|
// Security, and the two sit in the same menu.
|
|
{ id: 'report-security', icon: 'fa-shield', labelKey: 'securityReportTitle' },
|
|
{ id: 'report-impersonation', icon: 'fa-user-secret', labelKey: 'impersonationReportTitle' },
|
|
// Who signs in with a password shorter than PASSWORD_MIN_LENGTH, and the two
|
|
// settings that decide it (passwordLengthReport.js).
|
|
{ id: 'report-password-length', icon: 'fa-key', labelKey: 'password-length-report-title' },
|
|
{ id: 'features-performance', icon: 'fa-tachometer', labelKey: 'features-performance', emoji: true },
|
|
{ id: 'report-speed', icon: 'fa-tachometer', labelKey: 'speedReportTitle' },
|
|
{ id: 'report-tests', icon: 'fa-flask', labelKey: 'testsReportTitle' },
|
|
{ id: 'report-cpu', icon: 'fa-tachometer', labelKey: 'cpuReportTitle' },
|
|
// The CPU monitor's siblings, with labels every language already translates.
|
|
{ id: 'report-ram', icon: 'fa-microchip', labelKey: 'memory-usage' },
|
|
{ id: 'report-disk', icon: 'fa-hdd-o', labelKey: 'sandstorm-disk-usage' },
|
|
{ id: 'report-instrumentation', icon: 'fa-line-chart', labelKey: 'instrumentationReportTitle' },
|
|
{ id: 'report-broken', icon: 'fa-chain-broken', labelKey: 'broken-cards' },
|
|
{ id: 'report-files', icon: 'fa-paperclip', labelKey: 'filesReportTitle' },
|
|
{ id: 'report-rules', icon: 'fa-magic', labelKey: 'rulesReportTitle' },
|
|
{ id: 'report-boards', icon: 'fa-columns', labelKey: 'boardsReportTitle' },
|
|
{ id: 'report-cards', icon: 'fa-id-card-o', labelKey: 'cardsReportTitle' },
|
|
{ id: 'report-recovery', icon: 'fa-medkit', labelKey: 'recoveryReportTitle' },
|
|
// Where people log in from, grouped by address: the offices, VPNs and NATs
|
|
// several accounts share (models/lib/loginTally.js).
|
|
{ id: 'report-office', icon: 'fa-building', labelKey: 'officeReportTitle' },
|
|
// What the REST API is being USED for - who called what, how often. Not a
|
|
// problem report: it sits here because this is where an admin looks at what
|
|
// the server is being asked to do (models/lib/apiUsage.js).
|
|
{ id: 'report-api', icon: 'fa-plug', labelKey: 'apiReportTitle' },
|
|
// What the database itself said, classified: which database type, what it
|
|
// means and what to do (server/lib/databaseProblems.js).
|
|
{ id: 'report-database', icon: 'fa-database', labelKey: 'databaseReportTitle' },
|
|
// What the FILESYSTEM said: a stored file that is no longer the file WeKan
|
|
// stored, and whether this server stopped cleanly
|
|
// (docs/Security/Remediation/WeKan.md §13).
|
|
{ id: 'report-integrity', icon: 'fa-fingerprint', labelKey: 'integrityReportTitle' },
|
|
// What went wrong sending or receiving email: the mail server refused a send,
|
|
// an IMAP mailbox could not be read, an email for a board that no longer takes
|
|
// email (models/lib/mailProblems.js).
|
|
{ id: 'report-mail', icon: 'fa-envelope', labelKey: 'mailReportTitle' },
|
|
];
|
|
|
|
Template.adminProblems.helpers({
|
|
showFileStatusAudit() {
|
|
return ['report-files', 'report-integrity', 'report-recovery'].includes(Template.instance().activeReport.get());
|
|
},
|
|
menuItems() {
|
|
// The pane opens on Summary, before any menu click has set activeReport.
|
|
return leftMenuData(PROBLEMS_MENU,
|
|
Template.instance().activeReport.get() || 'report-summary');
|
|
},
|
|
// The heading above the pane: the open menu entry's own label
|
|
// (docs/Features/All-Boards/Left-Menu.md). The report tables and the event streams
|
|
// stopped passing a title to the shared table page when this arrived - the menu
|
|
// entry and the report title were the same i18n key, and both would have printed.
|
|
paneTitleData() {
|
|
return paneTitle(PROBLEMS_MENU,
|
|
Template.instance().activeReport.get() || 'report-summary');
|
|
},
|
|
// The shared table page for whichever paginated report is open (null for the
|
|
// Summary / Broken cards panes, which are not table pages).
|
|
tablePageData() {
|
|
return reportTablePageData(Template.instance());
|
|
},
|
|
loading() {
|
|
return Template.instance().loading;
|
|
},
|
|
// WHICH PANE IS OPEN, asked once - `else if isPane 'report-cpu'` in the
|
|
// template, instead of a ReactiveVar, a reset, a setter and a helper per pane.
|
|
//
|
|
// Eleven booleans used to restate what `activeReport` already held, and each
|
|
// new pane needed all four edits to appear. Filesystem integrity got three of
|
|
// them: the menu entry set `tmpl.showIntegrity`, the template asked for
|
|
// `showIntegrity.get`, and the helper was never written - which in Blaze is
|
|
// not an error but a falsy value, so the pane drew a blank page while Summary
|
|
// went on counting the problems it could not show. There is one helper to
|
|
// forget now, and forgetting it is not subtle: every pane goes blank at once.
|
|
// tests/adminPaneHelpers.test.cjs pins both halves.
|
|
isPane(id) {
|
|
return Template.instance().activeReport.get() === id;
|
|
},
|
|
|
|
// --- Pagination helpers, passed down into each report sub-template ---
|
|
hasFilesPrevPage() { return Template.instance().filesPage.get() > 1; },
|
|
hasFilesNextPage() {
|
|
const tpl = Template.instance();
|
|
return tpl.filesPage.get() < Math.max(1, Math.ceil((tpl.filesCount.get() || 0) / REPORTS_PER_PAGE));
|
|
},
|
|
|
|
hasRulesPrevPage() { return Template.instance().rulesPage.get() > 1; },
|
|
hasRulesNextPage() {
|
|
const tpl = Template.instance();
|
|
return tpl.rulesPage.get() < Math.max(1, Math.ceil((tpl.rulesCount.get() || 0) / REPORTS_PER_PAGE));
|
|
},
|
|
|
|
hasBoardsPrevPage() { return Template.instance().boardsPage.get() > 1; },
|
|
hasBoardsNextPage() {
|
|
const tpl = Template.instance();
|
|
return tpl.boardsPage.get() < Math.max(1, Math.ceil((tpl.boardsCount.get() || 0) / REPORTS_PER_PAGE));
|
|
},
|
|
|
|
hasCardsPrevPage() { return Template.instance().cardsPage.get() > 1; },
|
|
hasCardsNextPage() {
|
|
const tpl = Template.instance();
|
|
return tpl.cardsPage.get() < Math.max(1, Math.ceil((tpl.cardsCount.get() || 0) / REPORTS_PER_PAGE));
|
|
},
|
|
|
|
hasImpersonationPrevPage() { return Template.instance().impersonationPage.get() > 1; },
|
|
hasImpersonationNextPage() {
|
|
const tpl = Template.instance();
|
|
return tpl.impersonationPage.get() < Math.max(1, Math.ceil((tpl.impersonationCount.get() || 0) / REPORTS_PER_PAGE));
|
|
},
|
|
|
|
hasRecoveryPrevPage() { return Template.instance().recoveryPage.get() > 1; },
|
|
hasRecoveryNextPage() {
|
|
const tpl = Template.instance();
|
|
return tpl.recoveryPage.get() < Math.max(1, Math.ceil((tpl.recoveryCount.get() || 0) / REPORTS_PER_PAGE));
|
|
},
|
|
});
|
|
|
|
Template.adminProblems.events({
|
|
'files-report-changed'(event, tmpl) {
|
|
event.stopPropagation();
|
|
tmpl.loadReport('report-files', { recount: true });
|
|
},
|
|
|
|
// One handler for the whole menu: the shared left menu gives every entry the
|
|
// same class and puts the pane id in data-id, so the twelve identical
|
|
// 'click a.js-report-<name>' handlers collapsed to this.
|
|
'click .js-left-menu-item'(event) {
|
|
const tpl = Template.instance();
|
|
switchMenu(event, tpl);
|
|
// ...and into the address bar, so the report can be linked and bookmarked.
|
|
const targetID = $(event.currentTarget || event.target)
|
|
.closest('.js-left-menu-item').data('id');
|
|
const path = adminPath('problems', targetID);
|
|
if (path && FlowRouter.current().path !== path) FlowRouter.go(path);
|
|
},
|
|
|
|
// --- Controls: ONE handler each, for every table page ---
|
|
// The shared table page (docs/Features/General/Table.md) emits the same three
|
|
// controls for every report, so the report is identified by activeReport
|
|
// rather than by a per-report js- class. Twelve prev/next handlers and six
|
|
// search handlers collapsed to these.
|
|
//
|
|
// Clicking a user, and clicking a location, are NOT here: they are the same
|
|
// on every table, so they live on the shared table page itself
|
|
// (client/components/settings/tablePage.js) and reach every report through
|
|
// the template hierarchy. Three identical copies of the edit-user handler is
|
|
// what that replaced.
|
|
'click .js-table-page-prev'(event, tmpl) { goPrevPage(event, tmpl, tmpl.activeReport.get()); },
|
|
'click .js-table-page-next'(event, tmpl) { goNextPage(event, tmpl, tmpl.activeReport.get()); },
|
|
'keydown .js-table-page-search'(event, tmpl) {
|
|
if (event.keyCode === 13 && !event.shiftKey) {
|
|
runSearch(tmpl, tmpl.activeReport.get(), '.js-table-page-search');
|
|
}
|
|
},
|
|
'change .js-table-page-filter'(event, tmpl) {
|
|
const reportId = tmpl.activeReport.get();
|
|
const cfg = reportConfig(tmpl)[reportId];
|
|
if (!cfg || !cfg.filter) return;
|
|
cfg.filter.set($(event.currentTarget).val() || 'all');
|
|
cfg.page.set(1);
|
|
tmpl.loadReport(reportId, { recount: true });
|
|
},
|
|
});
|
|
|
|
function goPrevPage(event, tmpl, reportId) {
|
|
event.preventDefault();
|
|
const cfg = reportConfig(tmpl)[reportId];
|
|
// Specialized Problems panes (event streams and Offices) own their pager.
|
|
// Blaze events bubble through containing templates, so their clicks can also
|
|
// reach this shared report handler. Pane changes can likewise leave a queued
|
|
// click with an old report id. Neither case has an entry in reportConfig.
|
|
if (!cfg) return;
|
|
movePage(cfg.page, cfg.count.get(), -1, REPORTS_PER_PAGE,
|
|
() => tmpl.loadReport(reportId));
|
|
}
|
|
|
|
function goNextPage(event, tmpl, reportId) {
|
|
event.preventDefault();
|
|
const cfg = reportConfig(tmpl)[reportId];
|
|
if (!cfg) return;
|
|
movePage(cfg.page, cfg.count.get(), 1, REPORTS_PER_PAGE,
|
|
() => tmpl.loadReport(reportId));
|
|
}
|
|
|
|
function movePage(page, total, direction, perPage, load) {
|
|
const current = page.get();
|
|
const next = adjacentPage(total, current, direction, perPage);
|
|
if (next === current) return;
|
|
page.set(next);
|
|
load();
|
|
}
|
|
|
|
// Read the report's search box, store the term, reset to page 1 and reload.
|
|
function runSearch(tmpl, reportId, inputSelector) {
|
|
const cfg = reportConfig(tmpl)[reportId];
|
|
if (!cfg) return;
|
|
const value = (tmpl.$(inputSelector).val() || '').trim();
|
|
cfg.search.set(value);
|
|
cfg.page.set(1);
|
|
// A different search term means a different total: recount.
|
|
tmpl.loadReport(reportId, { recount: true });
|
|
}
|
|
|
|
function switchMenu(event, tmpl) {
|
|
// data-id is on the anchor; event.target may be the icon inside it.
|
|
const target = $(event.currentTarget || event.target).closest('.js-left-menu-item');
|
|
openReportPane(tmpl, target.data('id'));
|
|
}
|
|
|
|
// Open a pane BY ID. Split out of switchMenu so the URL can open one too - every
|
|
// left-menu entry has an address now (/admin/problems/cpu, /admin/problems/rules).
|
|
// docs/Features/Admin-Panel/Admin-Panel-URLs.md
|
|
// The panes that need no subscription: they fetch through methods or have
|
|
// nothing to fetch. Everything else falls through to loadReport().
|
|
const SELF_LOADING_PANES = [
|
|
'report-summary',
|
|
'features-performance', 'features-security', 'features-delete', 'features-notifications',
|
|
// backupRunsStatus.jade loads its own data (the backupHistory method).
|
|
'features-backup',
|
|
'report-security', 'report-speed', 'report-tests', 'report-cpu', 'report-ram', 'report-disk',
|
|
'report-instrumentation',
|
|
'report-database', 'report-integrity', 'report-office', 'report-api', 'report-mail',
|
|
// passwordLengthReport.js loads its own rows and settings through methods.
|
|
'report-password-length',
|
|
];
|
|
|
|
function openReportPane(tmpl, targetID) {
|
|
// Re-opening the open pane must do nothing. The active row is rendered from
|
|
// activeReport now, so compare ids instead of reading a DOM class.
|
|
if (targetID && targetID !== tmpl.activeReport.get()) {
|
|
tmpl.loading.set(true);
|
|
if (tmpl.subscription) {
|
|
tmpl.subscription.stop();
|
|
}
|
|
|
|
tmpl.activeReport.set(targetID);
|
|
|
|
// PANES THAT LOAD THEMSELVES. Summary, the Features panes and every
|
|
// event-stream report fetch through methods rather than a subscription, or
|
|
// have nothing to fetch at all: setting activeReport above is the whole of
|
|
// opening them, so there is only the spinner to clear.
|
|
//
|
|
// This list replaces eleven `this.showX = new ReactiveVar(false)`, eleven
|
|
// `tmpl.showX.set(false)` resets, eleven `tmpl.showX.set(true)` branches and
|
|
// eleven `showX()` helpers - forty-four lines that all said the same thing
|
|
// activeReport already said. Adding a pane took seven edits and missing one
|
|
// of them rendered the pane BLANK: the menu set a variable, the template
|
|
// asked for a helper that did not exist, and an undefined helper is simply
|
|
// falsy. Both faults had happened - see the note that used to sit on the
|
|
// integrity helper, and the Offices pane below, which shipped without its
|
|
// `loading.set(false)` and would have spun for ever.
|
|
if (SELF_LOADING_PANES.includes(targetID)) {
|
|
tmpl.loading.set(false);
|
|
} else if ('report-broken' === targetID) {
|
|
// A report like the others now: same controls, same paging, same loader.
|
|
tmpl.brokenPage.set(1);
|
|
tmpl.brokenSearch.set('');
|
|
tmpl.loadReport('report-broken', { recount: true });
|
|
} else if ('report-files' === targetID) {
|
|
tmpl.filesPage.set(1);
|
|
tmpl.filesSearch.set('');
|
|
tmpl.loadReport('report-files', { recount: true });
|
|
} else if ('report-rules' === targetID) {
|
|
tmpl.rulesPage.set(1);
|
|
tmpl.rulesSearch.set('');
|
|
tmpl.loadReport('report-rules', { recount: true });
|
|
} else if ('report-boards' === targetID) {
|
|
tmpl.boardsPage.set(1);
|
|
tmpl.boardsSearch.set('');
|
|
tmpl.loadReport('report-boards', { recount: true });
|
|
} else if ('report-cards' === targetID) {
|
|
tmpl.cardsPage.set(1);
|
|
tmpl.cardsSearch.set('');
|
|
tmpl.loadReport('report-cards', { recount: true });
|
|
} else if ('report-impersonation' === targetID) {
|
|
tmpl.impersonationPage.set(1);
|
|
tmpl.impersonationSearch.set('');
|
|
tmpl.loadReport('report-impersonation', { recount: true });
|
|
} else if ('report-recovery' === targetID) {
|
|
tmpl.recoveryPage.set(1);
|
|
tmpl.recoverySearch.set('');
|
|
tmpl.recoveryFilter.set('all');
|
|
tmpl.loadReport('report-recovery', { recount: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- The six paginated reports: ONE implementation ---
|
|
//
|
|
// Files, Rules, Boards, Cards, Impersonation and Recovery used to be six copies
|
|
// of the same page: the same title + search + prev/next markup, the same
|
|
// currentPage/totalPages helpers, and the same click/keydown handlers, retyped
|
|
// with a different js- prefix each time. They now differ only in the COLUMN
|
|
// SPEC below; everything else - markup, layout, paging, search - comes from the
|
|
// shared table page. See docs/Features/General/Table.md.
|
|
//
|
|
// A column is { label | labelKey, value(doc), align, nowrap, cls, userId(doc) }.
|
|
|
|
function memberNames(members) {
|
|
return (members || [])
|
|
// #5122: removing a member from a board marks it `isActive: false` (the entry
|
|
// stays in board.members for role history / re-activation). Removed members
|
|
// must not be listed as current members.
|
|
.filter(member => member.isActive !== false)
|
|
.map(member => ReactiveCache.getUser(member.userId)?.username || member.userId)
|
|
.join(', ');
|
|
}
|
|
|
|
function userIdNames(userIds) {
|
|
return (userIds || [])
|
|
.map(userId => ReactiveCache.getUser(userId)?.username || userId)
|
|
.join(', ');
|
|
}
|
|
|
|
function userName(userId) {
|
|
if (!userId) return '';
|
|
return ReactiveCache.getUser(userId)?.username || userId;
|
|
}
|
|
|
|
function userInitials(userId) {
|
|
const user = userId && ReactiveCache.getUser(userId);
|
|
const display = user?.profile?.initials || user?.profile?.fullname
|
|
|| user?.username || userId || '';
|
|
return String(display).trim().split(/\s+/).filter(Boolean)
|
|
.map(part => part[0]).join('').slice(0, 2).toUpperCase();
|
|
}
|
|
|
|
function formatDate(date) {
|
|
return date ? formatDateForDisplay(date, true, value => value.toLocaleString()) : '';
|
|
}
|
|
|
|
// Every table on the Problems tab, keyed by its side-menu id. `docs` returns the
|
|
// page that is already in minimongo - the publication sent only that page.
|
|
//
|
|
// No titleKey here: the pane heading is rendered once for every Admin Panel pane
|
|
// from the open menu entry (docs/Features/All-Boards/Left-Menu.md), and PROBLEMS_MENU
|
|
// carries these reports' i18n keys already - the same keys this table used to
|
|
// repeat.
|
|
const REPORT_TABLES = {
|
|
'report-files': {
|
|
additionalDesc: FILES_REPORT_DELETE_DESCRIPTION,
|
|
emptyKey: 'no-results',
|
|
docs: () => {
|
|
// The UNDERLYING reactive minimongo collection: the 'attachmentsList'
|
|
// publication delivers the page via this.added, and a plain Mongo.Collection
|
|
// cursor reacts to those adds and yields plain docs, whereas the ostrio
|
|
// FilesCursor does not reliably re-run in a helper.
|
|
// The page is the one the publication named - opening a single card puts its
|
|
// attachments in minimongo, and those are not rows of this report.
|
|
// Never throw: a throwing helper blanks the whole pane.
|
|
const coll = (Attachments && Attachments.collection) || Attachments;
|
|
try {
|
|
return reportPageResults(coll, 'report-files');
|
|
} catch (e) {
|
|
// Say what went wrong. This used to return [] in silence, and an empty
|
|
// catch on the one helper that fills the table is indistinguishable
|
|
// from "there is nothing to show": the pane draws its headers, says
|
|
// "No results", and nothing anywhere records that it threw. A report
|
|
// that cannot render its rows is worth one line in the console.
|
|
console.error('[report-files] could not read the page of rows:', e);
|
|
return [];
|
|
}
|
|
},
|
|
columns: [
|
|
{
|
|
labelKey: 'preview',
|
|
value: () => '',
|
|
attachment: d => {
|
|
const kind = attachmentKind(d);
|
|
return {
|
|
id: d._id,
|
|
cardId: d.meta?.cardId || '',
|
|
name: cleanFileName(d.name),
|
|
link: Attachments.link.call(d),
|
|
extension: kind.extension || 'file',
|
|
isImage: kind.isImage,
|
|
};
|
|
},
|
|
},
|
|
// The name is URL-decoded, homoglyphs folded and invisible / exploit
|
|
// characters removed, so it is always shown as a plain, readable name.
|
|
{ label: 'Filename', value: d => cleanFileName(d.name) },
|
|
{ label: 'Size (kB)', align: 'end', value: d => fileSizeHelper(d.size) },
|
|
{ label: 'MIME Type', value: d => d.type },
|
|
{ label: 'Attachment ID', value: d => d._id },
|
|
{ label: 'Board ID', value: d => d.meta?.boardId },
|
|
{ label: 'Card ID', value: d => d.meta?.cardId },
|
|
],
|
|
},
|
|
'report-rules': {
|
|
emptyKey: 'no-results',
|
|
// The publication's page, not every rule in minimongo: a board's own rules are
|
|
// there whenever its rules editor has been opened.
|
|
docs: () =>
|
|
reportPageResults(Rules, 'report-rules').map(rule => ({
|
|
_id: rule._id,
|
|
title: rule.title,
|
|
boardTitle: rule.board()?.title,
|
|
action: rule.action(),
|
|
trigger: rule.trigger(),
|
|
})),
|
|
columns: [
|
|
{ label: 'Rule Title', value: d => d.title },
|
|
{ label: 'Board Title', value: d => d.boardTitle },
|
|
{ label: 'actionType', value: d => d.action?.actionType },
|
|
{ label: 'activityType', value: d => d.trigger?.activityType },
|
|
],
|
|
},
|
|
'report-boards': {
|
|
emptyKey: 'no-results',
|
|
docs: () => reportPageResults(Boards, 'report-boards'),
|
|
columns: [
|
|
{ label: 'Title', value: d => abbreviate(d.title) },
|
|
{ label: 'Id', value: d => abbreviate(d._id) },
|
|
{ label: 'Permission', value: d => d.permission },
|
|
{ label: 'Archived?', value: d => yesOrNo(d.archived) },
|
|
{ label: 'Members', value: d => memberNames(d.members) },
|
|
{ label: 'Organizations', value: d => (d.orgs || [])
|
|
.map(o => ReactiveCache.getOrg(o.orgId)?.orgDisplayName || o.orgId).join(', ') },
|
|
{ label: 'Teams', value: d => (d.teams || [])
|
|
.map(t => ReactiveCache.getTeam(t.teamId)?.teamDisplayName || t.teamId).join(', ') },
|
|
],
|
|
},
|
|
'report-broken': {
|
|
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.
|
|
// 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) },
|
|
{ label: 'Board', value: d => abbreviate(d.board()?.title) },
|
|
{ label: 'Swimlane', value: d => abbreviate(d.swimlane()?.title) },
|
|
{ label: 'List', value: d => abbreviate(d.list()?.title) },
|
|
{ label: 'Type', value: d => d.type },
|
|
{ labelKey: 'createdAt', nowrap: true, value: d => formatDate(d.createdAt) },
|
|
],
|
|
},
|
|
'report-cards': {
|
|
emptyKey: 'no-results',
|
|
// 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) },
|
|
{ label: 'Swimlane', value: d => abbreviate(d.swimlane()?.title) },
|
|
{ label: 'List', value: d => abbreviate(d.list()?.title) },
|
|
{ label: 'Members', value: d => userIdNames(d.members) },
|
|
{ label: 'Assignees', value: d => userIdNames(d.assignees) },
|
|
],
|
|
},
|
|
'report-impersonation': {
|
|
emptyKey: 'no-results',
|
|
// The publication already paginates + sorts newest-first; mirror that sort.
|
|
docs: () => collectionResults(ImpersonatedUsers, { createdAt: -1 }).fetch(),
|
|
columns: [
|
|
{ labelKey: 'date', nowrap: true, value: d => formatDate(d.createdAt) },
|
|
// Clicking a username opens the same "Edit user" popup as Admin Panel / People.
|
|
{ labelKey: 'impersonation-admin', value: d => userName(d.adminId), userId: d => d.adminId,
|
|
initials: d => userInitials(d.adminId) },
|
|
{ labelKey: 'impersonation-user', value: d => userName(d.userId), userId: d => d.userId,
|
|
initials: d => userInitials(d.userId) },
|
|
{ labelKey: 'board', value: d => d.boardId },
|
|
{ labelKey: 'reason', value: d => d.reason },
|
|
],
|
|
},
|
|
'report-recovery': {
|
|
descKey: 'recovery-report-desc',
|
|
additionalDesc: PERMANENT_DELETE_RECOVERY_DESCRIPTION,
|
|
emptyKey: 'recovery-no-events',
|
|
docs: () => collectionResults(RecoveryEvents, { createdAt: -1 }).fetch(),
|
|
rowClass: d => `recovery-severity-${d.severity || 'info'}`,
|
|
columns: [
|
|
{
|
|
labelKey: 'done',
|
|
nowrap: true,
|
|
value: () => '',
|
|
icons: d => [
|
|
{
|
|
cls: d.done === false
|
|
? 'fa-exclamation-triangle table-page-status-failed'
|
|
: 'fa-check table-page-status-done',
|
|
title: String(d.done !== false),
|
|
},
|
|
...(d.deletedData === true ? [{
|
|
cls: 'fa-trash table-page-status-deleted',
|
|
title: 'Deleted data',
|
|
}] : []),
|
|
],
|
|
},
|
|
{ labelKey: 'date', nowrap: true, value: d => formatDate(d.createdAt) },
|
|
{ labelKey: 'recovery-event', value: d => d.type },
|
|
{ label: 'User ID', value: d => d.userId },
|
|
{ labelKey: 'username', value: d => d.username },
|
|
...addressReportColumns(),
|
|
{ labelKey: 'recovery-detail', value: d => d.detail },
|
|
],
|
|
},
|
|
};
|
|
|
|
// Build the shared table page context for whichever report is open. Registered
|
|
// on the PARENT template, which already tracks activeReport - so there is one
|
|
// helper, not six, and no child has to reach back up for its parent instance.
|
|
function reportTablePageData(tmpl) {
|
|
const reportId = tmpl.activeReport.get();
|
|
const spec = REPORT_TABLES[reportId];
|
|
const cfg = reportConfig(tmpl)[reportId];
|
|
if (!spec || !cfg) return null;
|
|
const docs = spec.docs() || [];
|
|
const info = pageInfo(cfg.count.get(), cfg.page.get(), REPORTS_PER_PAGE);
|
|
return {
|
|
// No title: the pane heading comes from the open menu entry, once, for every
|
|
// Admin Panel pane (docs/Features/All-Boards/Left-Menu.md) - and this report's menu
|
|
// entry carries the very same i18n key, so both would have printed it.
|
|
descKey: spec.descKey,
|
|
additionalDesc: spec.additionalDesc,
|
|
emptyKey: spec.emptyKey,
|
|
searchTerm: cfg.search.get(),
|
|
filters: cfg.filter ? buildFilters([reportId === 'report-recovery' ? {
|
|
id: 'recovery-status', label: 'Show', options: [
|
|
{ value: 'all', label: 'All' }, { value: 'done', label: 'Done' },
|
|
{ value: 'failed', label: 'Failed' },
|
|
{ value: 'deleted', label: 'Deleted' },
|
|
],
|
|
} : {
|
|
id: 'board-permission', label: 'Permission', options: [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'public', labelKey: 'public' },
|
|
{ value: 'instance', labelKey: 'instance' },
|
|
{ value: 'private', labelKey: 'private' },
|
|
],
|
|
}], cfg.filter.get()) : [],
|
|
header: buildHeader(spec.columns),
|
|
rows: buildRows(docs, spec.columns, { rowClass: spec.rowClass }),
|
|
rowCount: docs.length,
|
|
page: info.page,
|
|
totalPages: info.totalPages,
|
|
hasPrev: info.hasPrev,
|
|
hasNext: info.hasNext,
|
|
};
|
|
}
|
|
// Broken cards has no template of its own any more: it is a column spec in
|
|
// REPORT_TABLES like every other report, rendered by the shared table page. It
|
|
// used to run on the global-search machinery (CardSearchPaged, a session document
|
|
// and the nextPage/previousPage publications), which is why it was the one report
|
|
// in this menu with a different set of controls - its own prev/next, no search
|
|
// box, no total, no "page X / N".
|
|
|
|
// --- Read-only event stream report (Security / Speed / Tests) ---
|
|
// 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 = 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
|
|
// (docs/Features/All-Boards/Left-Menu.md), and PROBLEMS_MENU already carries these exact
|
|
// i18n keys - securityReportTitle, speedReportTitle, testsReportTitle,
|
|
// cpuReportTitle - so the words are unchanged and there is only one of them.
|
|
|
|
const INSTRUMENTATION_COLUMNS = [
|
|
{ label: 'Type', value: row => row.kind },
|
|
{ label: 'Method or publication', value: row => row.name },
|
|
{ label: 'Calls', align: 'end', value: row => row.calls },
|
|
{ label: 'Completed', align: 'end', value: row => row.completed },
|
|
{ label: 'Errors', align: 'end', value: row => row.errors },
|
|
{ label: 'Avg ms', align: 'end', value: row => row.averageMs ?? '' },
|
|
{ label: 'Max ms', align: 'end', value: row => row.maxMs ?? '' },
|
|
];
|
|
|
|
const { summarizeByArea, areaOf } = require('/models/lib/instrumentationAreas');
|
|
|
|
// The operations the table shows: the chosen area, then the search.
|
|
function instrumentationRows(t) {
|
|
const term = t.search.get().toLowerCase();
|
|
const area = t.area.get();
|
|
return (t.snapshot.get()?.operations || []).filter(row =>
|
|
(!area || areaOf(row.name).id === area) &&
|
|
`${row.kind} ${row.name}`.toLowerCase().includes(term));
|
|
}
|
|
|
|
function formatCount(value) {
|
|
try {
|
|
return new Intl.NumberFormat(TAPi18n.getLanguage()).format(value);
|
|
} catch (e) {
|
|
return String(value);
|
|
}
|
|
}
|
|
|
|
Template.instrumentationReport.onCreated(function () {
|
|
this.area = new ReactiveVar(null);
|
|
this.snapshot = new ReactiveVar(null);
|
|
this.error = new ReactiveVar('');
|
|
this.search = new ReactiveVar('');
|
|
this.page = new ReactiveVar(1);
|
|
this.load = () => Meteor.call('getInstrumentationReport', (error, snapshot) => {
|
|
if (error) this.error.set(error.reason || error.message || 'Unable to load instrumentation');
|
|
else {
|
|
this.error.set('');
|
|
this.snapshot.set(snapshot);
|
|
}
|
|
});
|
|
this.load();
|
|
this.timer = Meteor.setInterval(this.load, 5000);
|
|
});
|
|
|
|
Template.instrumentationReport.onDestroyed(function () {
|
|
Meteor.clearInterval(this.timer);
|
|
});
|
|
|
|
Template.instrumentationReport.helpers({
|
|
error() { return Template.instance().error.get(); },
|
|
areas() {
|
|
const t = Template.instance();
|
|
const active = t.area.get();
|
|
return summarizeByArea(t.snapshot.get()?.operations).map(row => {
|
|
const label = TAPi18n.__(row.labelKey);
|
|
const avgText = row.averageMs == null ? '' : `${formatCount(row.averageMs)} ms`;
|
|
return {
|
|
...row,
|
|
label,
|
|
active: row.id === active,
|
|
// At least a sliver, so an area with a handful of calls still shows.
|
|
barPct: Math.max(1, Math.round(row.share * 1000) / 10),
|
|
errorPct: row.errors ? Math.max(4, Math.round(row.errorShare * 1000) / 10) : 0,
|
|
callsText: formatCount(row.calls),
|
|
avgText,
|
|
errorsText: row.errors ? formatCount(row.errors) : '',
|
|
title: [label, `${TAPi18n.__('api-calls')} ${formatCount(row.calls)}`,
|
|
avgText && `${TAPi18n.__('duration')} ${avgText}`,
|
|
`${TAPi18n.__('errors')} ${formatCount(row.errors)}`].filter(Boolean).join(' · '),
|
|
};
|
|
});
|
|
},
|
|
tablePageData() {
|
|
const t = Template.instance();
|
|
const snapshot = t.snapshot.get();
|
|
const all = instrumentationRows(t);
|
|
const info = pageInfo(all.length, t.page.get(), TABLE_PAGE_ROWS_PER_PAGE);
|
|
return {
|
|
header: buildHeader(INSTRUMENTATION_COLUMNS),
|
|
rows: buildRows(all.slice(info.skip, info.skip + info.limit), INSTRUMENTATION_COLUMNS),
|
|
rowCount: all.length,
|
|
total: all.length,
|
|
searchTerm: t.search.get(),
|
|
page: info.page,
|
|
totalPages: info.totalPages,
|
|
hasPrev: info.hasPrev,
|
|
hasNext: info.hasNext,
|
|
statusTemplate: snapshot ? 'instrumentationStatus' : null,
|
|
statusData: snapshot ? {
|
|
...snapshot.connections,
|
|
startedAt: formatEventAt(snapshot.startedAt),
|
|
} : null,
|
|
};
|
|
},
|
|
});
|
|
|
|
Template.instrumentationReport.events({
|
|
'click .js-instrumentation-area'(event, t) {
|
|
const area = event.currentTarget.dataset.area;
|
|
t.area.set(t.area.get() === area ? null : area);
|
|
t.page.set(1);
|
|
},
|
|
'input .js-table-page-search'(event, t) {
|
|
t.search.set(event.currentTarget.value.trim());
|
|
t.page.set(1);
|
|
},
|
|
'click .js-table-page-prev'(event, t) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
t.page.set(adjacentPage(instrumentationRows(t).length, t.page.get(), -1, TABLE_PAGE_ROWS_PER_PAGE));
|
|
},
|
|
'click .js-table-page-next'(event, t) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
t.page.set(adjacentPage(instrumentationRows(t).length, t.page.get(), 1, TABLE_PAGE_ROWS_PER_PAGE));
|
|
},
|
|
});
|
|
|
|
Template.eventStreamReport.onCreated(function () {
|
|
this.stream = this.data.stream;
|
|
this.page = new ReactiveVar(1);
|
|
this.total = new ReactiveVar(0);
|
|
this.rows = new ReactiveVar([]);
|
|
this.search = new ReactiveVar('');
|
|
// CPU usage and Speed pages: the live figure shown between the title and the search
|
|
// box. The event rows below it are a HISTORY of high-CPU periods, so without
|
|
// this the page could not answer "what is the CPU doing right now?" at all.
|
|
this.cpu = new ReactiveVar(null);
|
|
this.load = () => {
|
|
const stream = this.stream;
|
|
const search = this.search.get();
|
|
const page = this.page.get();
|
|
Meteor.call('eventLogCount', stream, search, (err, count) => {
|
|
if (!err) this.total.set(count || 0);
|
|
});
|
|
Meteor.call('eventLogPage', stream, EVENTS_PER_PAGE, (page - 1) * EVENTS_PER_PAGE, search, (err, rows) => {
|
|
if (!err) this.rows.set(Array.isArray(rows) ? rows : []);
|
|
});
|
|
};
|
|
this.load();
|
|
|
|
if (this.stream === 'cpu' || this.stream === 'performance') {
|
|
this.loadCpu = () => {
|
|
Meteor.call('problemDetailReport', 'cpu', (err, res) => {
|
|
if (!err && res) this.cpu.set(res.current || null);
|
|
});
|
|
};
|
|
this.loadCpu();
|
|
// The monitor samples the system every CPU_SAMPLE_MS; refreshing on the same
|
|
// order keeps the header live without polling the server pointlessly often.
|
|
this.cpuTimer = Meteor.setInterval(this.loadCpu, 5000);
|
|
}
|
|
// RAM and Disk usage: the same live line, from their own monitors.
|
|
if (this.stream === 'ram' || this.stream === 'disk') {
|
|
this.loadCpu = () => {
|
|
Meteor.call('problemDetailReport', this.stream, (err, res) => {
|
|
if (!err && res) this.cpu.set(res.current || null);
|
|
});
|
|
};
|
|
this.loadCpu();
|
|
this.cpuTimer = Meteor.setInterval(this.loadCpu, this.stream === 'disk' ? 30000 : 5000);
|
|
}
|
|
});
|
|
|
|
Template.eventStreamReport.onDestroyed(function () {
|
|
if (this.cpuTimer) Meteor.clearInterval(this.cpuTimer);
|
|
});
|
|
|
|
// The event streams (Security, Speed, Tests, CPU usage) use the SAME shared
|
|
// table page as the six reports - same markup, same controls, same layout - so
|
|
// their only difference is these columns and the CPU status row.
|
|
// FROM WHERE, with each address followed by its own location. An instance
|
|
// reached over IPv6 and one reached over IPv4 are different situations.
|
|
//
|
|
// The fold writes `ipv4`/`ipv6` on every row now, but rows written BEFORE it did
|
|
// have only `ip` - so the address is classified here as the fallback, and the
|
|
// history displays correctly instead of showing two empty columns for everything
|
|
// older than this change.
|
|
|
|
// The API stream is a USAGE report, not a problem report, so its columns are the
|
|
// question it answers - who called what, how often, between when and when, from
|
|
// which address - and not one of the problem columns applies. Everything else
|
|
// about the pane is the shared event-stream report: the same template, the same
|
|
// controls, the same paginator, the same loader.
|
|
const API_COLUMNS = [
|
|
// WHO. The stored username, like every other report: it is what the account
|
|
// was called when the calls happened, so a later rename does not rewrite
|
|
// history. Empty for an unauthenticated call, which is not a gap - it is the
|
|
// row for "somebody with no account", and the addresses beside it are what
|
|
// identify them.
|
|
{ labelKey: 'username', value: r => r.username || '', userId: r => r.userId },
|
|
// WHAT. The route PATTERN - `POST /api/boards/:boardId/lists` - so one row is
|
|
// one endpoint rather than one board (models/lib/apiUsage.js).
|
|
{ labelKey: 'api-endpoint', nowrap: true, value: r => r.api || '' },
|
|
// HOW OFTEN, and BETWEEN WHEN AND WHEN. The count and the window are the
|
|
// report: "34 calls" means nothing without the period it covers.
|
|
{ labelKey: 'api-calls', align: 'end', value: r => r.count || 0 },
|
|
{ labelKey: 'api-first-called', nowrap: true, value: r => formatEventAt(r.firstAt) },
|
|
{ labelKey: 'api-last-called', nowrap: true, value: r => formatEventAt(r.at) },
|
|
...addressReportColumns(),
|
|
];
|
|
|
|
const EVENT_STREAM_COLUMNS = [
|
|
{ labelKey: 'event-datetime', nowrap: true, value: r => formatEventAt(r.at) },
|
|
// The `database` stream answers "which database said this" in this column -
|
|
// MongoDB, or FerretDB over SQLite / PostgreSQL / MySQL / MariaDB / SAP HANA -
|
|
// because the same message means different things depending on which it was,
|
|
// and the other streams have a category here.
|
|
{ labelKey: 'event-category', value: r => r.db || r.category },
|
|
{ labelKey: 'event-bleed', value: r => r.bleed || r.kind },
|
|
{ labelKey: 'event-severity', value: r => r.severity, data: r => r.severity },
|
|
{ labelKey: 'event-action', value: r => r.action || r.type },
|
|
{ labelKey: 'event-source', value: r => r.source },
|
|
// WHO tried it. The stored `username` wins over looking the account up: it is
|
|
// what the account was CALLED when the event happened, so a later rename does
|
|
// not rewrite history and a deleted account does not erase it. The lookup
|
|
// stays as the fallback for the older events that predate the stored field.
|
|
{
|
|
labelKey: 'username',
|
|
value: r => r.username || userName(r.userId),
|
|
userId: r => r.userId,
|
|
},
|
|
// FROM WHERE, in the same four columns every report uses. Resolved with the
|
|
// same spoofing-safe rule as the login throttle - X-Forwarded-For only as far
|
|
// as HTTP_FORWARDED_COUNT says to trust it - so neither can be written by
|
|
// sending a header.
|
|
...addressReportColumns(),
|
|
// HOW MANY attempts this row stands for. A canary counts repeats inside its
|
|
// window rather than writing one row each, so "1" is an ordinary event and a
|
|
// larger number is a burst that was deliberately not written out in full
|
|
// (docs/Security/Remediation/WeKan.md §12).
|
|
{
|
|
labelKey: 'event-attempts',
|
|
nowrap: true,
|
|
value: r => (r.count && r.count > 1 ? String(r.count) : ''),
|
|
},
|
|
// The `database` stream's detail is WeKan's reading of the error - what it means
|
|
// and what to do - and it is worth little without the sentence the database
|
|
// itself produced, which is the one thing an admin can search for or paste into
|
|
// an issue. So this cell carries both, advice first, message after. The other
|
|
// streams have no `message`, and their cell is unchanged.
|
|
{ labelKey: 'event-detail', value: r => [r.detail, r.message].filter(Boolean).join(' — ') },
|
|
];
|
|
|
|
// Which columns a stream's table has. One line rather than a template of its
|
|
// own: the panes differ in their columns and in nothing else.
|
|
function columnsFor(stream) {
|
|
return stream === 'api' ? API_COLUMNS : EVENT_STREAM_COLUMNS;
|
|
}
|
|
|
|
function formatEventAt(at) {
|
|
if (!at) return '';
|
|
try { return formatDateForDisplay(at, true, value => value.toISOString().replace('T', ' ').slice(0, 19)); }
|
|
catch (e) { return String(at); }
|
|
}
|
|
|
|
const gb = bytes => (Number(bytes) / (1024 ** 3)).toFixed(1);
|
|
|
|
// The live CPU line.
|
|
function cpuStatus(cpu) {
|
|
return {
|
|
high: cpu.high,
|
|
percent: cpu.percent,
|
|
cores: cpu.cores,
|
|
activity: cpu.activity,
|
|
// "1.42 / 1.10 / 0.98" - one line, no chart, matching the plain-table
|
|
// look of the rest of the Problems pages.
|
|
loadAverage: Array.isArray(cpu.loadAverage)
|
|
? cpu.loadAverage.map(n => (Number.isFinite(n) ? n.toFixed(2) : '?')).join(' / ')
|
|
: '',
|
|
statistics: cpu.statistics ? {
|
|
minimum: cpu.statistics.minimum,
|
|
average: cpu.statistics.average,
|
|
maximum: cpu.statistics.maximum,
|
|
samples: cpu.statistics.samples,
|
|
lowestAt: formatEventAt(cpu.statistics.lowestAt),
|
|
} : null,
|
|
};
|
|
}
|
|
|
|
// The live RAM line: used of total, the percentage, and swap when the machine
|
|
// has any. Numbers only, so it needs no new translation.
|
|
function ramStatus(mem) {
|
|
return {
|
|
high: mem.high,
|
|
percent: mem.ramPct,
|
|
used: gb(mem.ramUsed),
|
|
total: gb(mem.ramTotal),
|
|
swap: mem.swapPct == null ? null : { percent: mem.swapPct, used: gb(mem.swapUsed), total: gb(mem.swapTotal) },
|
|
};
|
|
}
|
|
|
|
// The live disk lines: one per filesystem WeKan writes to.
|
|
function diskStatus(current) {
|
|
return {
|
|
disks: (current.disks || []).map(d => ({
|
|
high: d.high, path: d.path, roles: d.roles.join(', '), percent: d.usedPct,
|
|
available: gb(d.available), total: gb(d.total),
|
|
})),
|
|
};
|
|
}
|
|
|
|
Template.eventStreamReport.helpers({
|
|
tablePageData() {
|
|
const t = Template.instance();
|
|
const rows = t.rows.get() || [];
|
|
const info = pageInfo(t.total.get(), t.page.get(), EVENTS_PER_PAGE);
|
|
const cpu = t.cpu.get();
|
|
return {
|
|
// No title: the pane heading comes from the open menu entry, once, for
|
|
// every Admin Panel pane (docs/Features/All-Boards/Left-Menu.md).
|
|
// The API stream is usage, not problems, so an empty one is not "no new
|
|
// problems" - it is "nothing has called the API", which on most instances
|
|
// means WITH_API is off.
|
|
emptyKey: t.stream === 'api' ? 'api-no-calls' : 'no-new-problems',
|
|
searchTerm: t.search.get(),
|
|
header: buildHeader(columnsFor(t.stream)),
|
|
rows: buildRows(rows, columnsFor(t.stream)),
|
|
rowCount: rows.length,
|
|
page: info.page,
|
|
totalPages: info.totalPages,
|
|
hasPrev: info.hasPrev,
|
|
hasNext: info.hasNext,
|
|
// CPU, RAM and Disk usage have a live status row: the table below it lists
|
|
// PAST high-usage periods, so this line is the only place the page says
|
|
// what is happening right now. Null on the other streams, so the shared
|
|
// page simply renders no status row there.
|
|
statusTemplate: !cpu ? null
|
|
: t.stream === 'ram' ? 'ramCurrentStatus'
|
|
: t.stream === 'disk' ? 'diskCurrentStatus' : 'cpuCurrentStatus',
|
|
statusData: !cpu ? null : t.stream === 'ram' ? ramStatus(cpu)
|
|
: t.stream === 'disk' ? diskStatus(cpu) : cpuStatus(cpu),
|
|
};
|
|
},
|
|
});
|
|
|
|
Template.eventStreamReport.events({
|
|
'input .js-table-page-search'(event, tmpl) {
|
|
tmpl.search.set(event.currentTarget.value.trim());
|
|
tmpl.page.set(1);
|
|
tmpl.load();
|
|
},
|
|
'click .js-table-page-prev'(event, tmpl) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
movePage(tmpl.page, tmpl.total.get(), -1, EVENTS_PER_PAGE, tmpl.load);
|
|
},
|
|
'click .js-table-page-next'(event, tmpl) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
movePage(tmpl.page, tmpl.total.get(), 1, EVENTS_PER_PAGE, tmpl.load);
|
|
},
|
|
});
|
|
|
|
function toggleSettingField(field) {
|
|
const setting = ReactiveCache.getCurrentSetting();
|
|
if (setting) {
|
|
if (field === 'enablePermanentDelete') {
|
|
Meteor.call('setPermanentDeleteEnabled', !setting[field], (err) => {
|
|
if (err) alert(errorMessage(err));
|
|
});
|
|
return;
|
|
}
|
|
Settings.update(setting._id, { $set: { [field]: !setting[field] } });
|
|
}
|
|
}
|
|
|
|
// Performance, Security and Notifications were Admin Panel / Features panes before
|
|
// it was removed; Delete exposes the later soft-delete gate beside them. Blaze
|
|
// resolves a helper, and delivers an event,
|
|
// against the template the element is IN - never an enclosing one - so each pane needs
|
|
// these ON it. One shared pair registered on all three: a handler whose element is not
|
|
// in a given pane simply never fires there, so splitting them per pane would buy
|
|
// nothing.
|
|
const featurePaneHelpers = {
|
|
renderLinksAsPlainText() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).renderLinksAsPlainText;
|
|
},
|
|
alwaysShowCodeAsText() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).alwaysShowCodeAsText;
|
|
},
|
|
disableAllImport() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableAllImport;
|
|
},
|
|
disableAllExport() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableAllExport;
|
|
},
|
|
disableImportAvatars() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableImportAvatars;
|
|
},
|
|
disableExportAvatars() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableExportAvatars;
|
|
},
|
|
anonymizeImportUsers() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).anonymizeImportUsers;
|
|
},
|
|
anonymizeExportUsers() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).anonymizeExportUsers;
|
|
},
|
|
disableActivities() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableActivities;
|
|
},
|
|
disableNotifications() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableNotifications;
|
|
},
|
|
disableWatch() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).disableWatch;
|
|
},
|
|
enablePermanentDelete() {
|
|
return (ReactiveCache.getCurrentSetting() || {}).enablePermanentDelete;
|
|
},
|
|
permanentDeleteRecoveryDescription() {
|
|
return PERMANENT_DELETE_RECOVERY_DESCRIPTION;
|
|
},
|
|
};
|
|
const featurePaneEvents = {
|
|
'click .js-toggle-render-links-as-plain-text'() {
|
|
toggleSettingField('renderLinksAsPlainText');
|
|
},
|
|
'click .js-toggle-always-show-code-as-text'() {
|
|
toggleSettingField('alwaysShowCodeAsText');
|
|
},
|
|
'click .js-toggle-disable-all-import'() {
|
|
toggleSettingField('disableAllImport');
|
|
},
|
|
'click .js-toggle-disable-all-export'() {
|
|
toggleSettingField('disableAllExport');
|
|
},
|
|
'click .js-toggle-disable-import-avatars'() {
|
|
toggleSettingField('disableImportAvatars');
|
|
},
|
|
'click .js-toggle-disable-export-avatars'() {
|
|
toggleSettingField('disableExportAvatars');
|
|
},
|
|
'click .js-toggle-anonymize-import-users'() {
|
|
toggleSettingField('anonymizeImportUsers');
|
|
},
|
|
'click .js-toggle-anonymize-export-users'() {
|
|
toggleSettingField('anonymizeExportUsers');
|
|
},
|
|
'click .js-toggle-disable-activities'() {
|
|
toggleSettingField('disableActivities');
|
|
},
|
|
'click .js-toggle-disable-notifications'() {
|
|
toggleSettingField('disableNotifications');
|
|
},
|
|
'click .js-toggle-disable-watch'() {
|
|
toggleSettingField('disableWatch');
|
|
},
|
|
'click .js-toggle-enable-permanent-delete'() {
|
|
toggleSettingField('enablePermanentDelete');
|
|
},
|
|
};
|
|
for (const tpl of [Template.featuresPerformance, Template.featuresSecurity,
|
|
Template.featuresDelete, Template.featuresNotifications]) {
|
|
tpl.helpers(featurePaneHelpers);
|
|
tpl.events(featurePaneEvents);
|
|
}
|
|
|
|
// ── Admin Panel / Problems / Offices ────────────────────────────────────────
|
|
// Where people log in from, grouped by person. Every address remains its own row
|
|
// so IPv4, IPv6, location and that person's successful-login count stay exact.
|
|
//
|
|
// Through the shared table page like every other report here
|
|
// (docs/Features/General/Table.md): same layout, same controls, same paginator.
|
|
const OFFICES_PER_PAGE = 25;
|
|
|
|
const OFFICE_COLUMNS = [
|
|
{
|
|
labelKey: 'office-people',
|
|
users: d => [{
|
|
userId: d.userId,
|
|
text: d.fullname ? `${d.fullname} (${d.username})` : d.username,
|
|
initials: d.initials,
|
|
avatarUrl: d.avatarUrl,
|
|
}],
|
|
value: () => '',
|
|
},
|
|
...addressReportColumns({ map: true }),
|
|
{ labelKey: 'office-logins', align: 'end', value: d => d.logins },
|
|
{ labelKey: 'office-first-seen', nowrap: true, value: d => formatDate(d.firstAt) },
|
|
{ labelKey: 'office-last-seen', nowrap: true, value: d => formatDate(d.at) },
|
|
];
|
|
|
|
Template.officeReport.onCreated(function () {
|
|
this.rows = new ReactiveVar([]);
|
|
this.total = new ReactiveVar(0);
|
|
this.page = new ReactiveVar(1);
|
|
this.search = new ReactiveVar('');
|
|
this.load = () => {
|
|
Meteor.call('loginOffices', {
|
|
limit: OFFICES_PER_PAGE,
|
|
skip: (this.page.get() - 1) * OFFICES_PER_PAGE,
|
|
search: this.search.get() || undefined,
|
|
}, (err, res) => {
|
|
if (err) return;
|
|
this.rows.set(officeRowsByPerson((res && res.people) || []));
|
|
this.total.set((res && res.total) || 0);
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
|
|
Template.officeReport.helpers({
|
|
tablePageData() {
|
|
const t = Template.instance();
|
|
const rows = t.rows.get() || [];
|
|
const info = pageInfo(t.total.get(), t.page.get(), OFFICES_PER_PAGE);
|
|
return {
|
|
// No title: the pane heading is the open menu entry's own label, once, for
|
|
// every Admin Panel pane (docs/Features/All-Boards/Left-Menu.md).
|
|
descKey: 'office-report-desc',
|
|
emptyKey: 'office-no-results',
|
|
searchTerm: t.search.get(),
|
|
header: buildHeader(OFFICE_COLUMNS),
|
|
rows: buildRows(rows, OFFICE_COLUMNS),
|
|
rowCount: rows.length,
|
|
page: info.page,
|
|
totalPages: info.totalPages,
|
|
hasPrev: info.hasPrev,
|
|
hasNext: info.hasNext,
|
|
};
|
|
},
|
|
});
|
|
|
|
Template.officeReport.events({
|
|
'click .js-table-page-prev'(event, tmpl) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
movePage(tmpl.page, tmpl.total.get(), -1, OFFICES_PER_PAGE, tmpl.load);
|
|
},
|
|
'click .js-table-page-next'(event, tmpl) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
movePage(tmpl.page, tmpl.total.get(), 1, OFFICES_PER_PAGE, tmpl.load);
|
|
},
|
|
'keydown .js-table-page-search'(event, tmpl) {
|
|
if (event.keyCode === 13 && !event.shiftKey) {
|
|
event.preventDefault();
|
|
event.stopPropagation();
|
|
tmpl.search.set(event.currentTarget.value || '');
|
|
tmpl.page.set(1);
|
|
tmpl.load();
|
|
}
|
|
},
|
|
});
|
|
|
|
|
|
Template.fileStatusAudit.onCreated(function () {
|
|
this.result = new ReactiveVar(null);
|
|
this.error = new ReactiveVar('');
|
|
this.disposed = false;
|
|
this.polling = false;
|
|
this.refresh = () => {
|
|
if (this.disposed || this.polling) return;
|
|
this.polling = true;
|
|
Meteor.call('getFileStatusAudit', (error, result) => {
|
|
this.polling = false;
|
|
if (this.disposed) return;
|
|
if (error) this.error.set(error.reason || error.message);
|
|
else this.result.set(result);
|
|
});
|
|
};
|
|
this.refresh();
|
|
this.timer = Meteor.setInterval(this.refresh, 2000);
|
|
});
|
|
Template.fileStatusAudit.onDestroyed(function () {
|
|
this.disposed = true;
|
|
Meteor.clearInterval(this.timer);
|
|
});
|
|
Template.fileStatusAudit.helpers({
|
|
result() { return Template.instance().result.get(); },
|
|
running() { return Template.instance().result.get()?.state === 'running'; },
|
|
error() { return Template.instance().error.get(); },
|
|
totals() {
|
|
return Object.entries(Template.instance().result.get()?.totals || {}).map(([kind, count]) => ({ kind, count }));
|
|
},
|
|
findings() { return (Template.instance().result.get()?.findings || []).map(row => JSON.stringify(row, null, 2)); },
|
|
statusText() {
|
|
const result = Template.instance().result.get();
|
|
return result ? `${result.state} — ${result.phase} — ${new Date(result.startedAt).toLocaleString()}` : '';
|
|
},
|
|
countText() {
|
|
const result = Template.instance().result.get();
|
|
const c = result?.counts;
|
|
return c ? `Records: ${c.records}; versions: ${c.versions}; disk files: ${result.filesystemScanned ? c.diskFiles : 'not scanned'}; history rows: ${c.historyRows}; bytes inspected: ${c.bytesRead}` : '';
|
|
},
|
|
coverageText() {
|
|
const result = Template.instance().result.get();
|
|
if (!result || result.state !== 'partial') return '';
|
|
return `Partial scan: ${(result.limitations || []).join(' ')} Unscanned files cannot be counted as missing or healthy.`;
|
|
},
|
|
});
|
|
function startFileAudit(mode, instance) {
|
|
instance.error.set('');
|
|
Meteor.call('startFileStatusAudit', mode, error => {
|
|
if (instance.disposed) return;
|
|
if (error) instance.error.set(error.reason || error.message);
|
|
instance.refresh();
|
|
});
|
|
}
|
|
Template.fileStatusAudit.events({
|
|
'click .js-file-audit-download'(event, instance) {
|
|
event.preventDefault();
|
|
const report = instance.result.get();
|
|
if (!report) return;
|
|
const url = URL.createObjectURL(new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' }));
|
|
const link = document.createElement('a');
|
|
link.href = url; link.download = 'wekan-file-status.json'; link.click();
|
|
Meteor.setTimeout(() => URL.revokeObjectURL(url), 1000);
|
|
},
|
|
'click .js-file-audit-all'(event, instance) { event.preventDefault(); startFileAudit('all', instance); },
|
|
'click .js-file-audit-types'(event, instance) { event.preventDefault(); startFileAudit('types', instance); },
|
|
'click .js-file-audit-inventory'(event, instance) { event.preventDefault(); startFileAudit('inventory', instance); },
|
|
'click .js-file-audit-cancel'(event, instance) {
|
|
event.preventDefault();
|
|
Meteor.call('cancelFileStatusAudit', error => {
|
|
if (!instance.disposed && error) instance.error.set(error.reason || error.message);
|
|
});
|
|
},
|
|
});
|
|
|
|
|
|
// A method-backed diagnostic view of the retained Sync records. No duplicate
|
|
// recovery events and no collection publication; refresh reads current status.
|
|
Template.syncRecoveryReports.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], total: 0, page: 1 });
|
|
this.error = new ReactiveVar('');
|
|
this.search = new ReactiveVar('');
|
|
this.status = new ReactiveVar('unfinished');
|
|
this.request = 0;
|
|
this.load = (page = 1) => {
|
|
const request = ++this.request;
|
|
this.result.set({ rows: [], total: 0, page });
|
|
Meteor.call('syncRecoveryReport', { search: this.search.get(), status: this.status.get(), page }, (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
this.error.set(error ? TAPi18n.__('sync-recovery-unavailable') : '');
|
|
if (!error) this.result.set(result);
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.syncRecoveryReports.helpers({
|
|
error() { return Template.instance().error.get(); },
|
|
tablePageData() {
|
|
const t = Template.instance();
|
|
const result = t.result.get();
|
|
const info = pageInfo(result.total, result.page, TABLE_PAGE_ROWS_PER_PAGE);
|
|
const statuses = ['all', 'unfinished', 'failed', 'completed-with-warnings', 'completed', 'review-only', 'skipped'];
|
|
return {
|
|
header: buildHeader([{ labelKey: 'date' }, { labelKey: 'list' }, { labelKey: 'status' }, { labelKey: 'details' }]),
|
|
rowTemplate: 'syncRecoveryReportRow', emptyKey: 'sync-report-empty',
|
|
docs: result.rows.map(row => ({ ...row, started: formatDate(row.startedAt),
|
|
hasCounts: row.created !== undefined, statusLabel: `sync-report-${row.status}`,
|
|
sourceRows: (row.coverage?.source?.rows || []).map(field => ({ ...field, reasonLabel: `sync-source-${field.reason}` })),
|
|
normalizedRows: (row.coverage?.rows || []).map(field => ({ ...field, reasonLabel: `sync-source-${field.reason}` })),
|
|
})),
|
|
rowCount: result.rows.length, total: result.total, searchTerm: t.search.get(),
|
|
page: info.page, totalPages: info.totalPages, hasPrev: info.hasPrev, hasNext: info.hasNext,
|
|
actions: [{ id: 'refresh-sync', labelKey: 'refresh' }],
|
|
filters: [{ id: 'sync-status', labelKey: 'status', options: statuses.map(status => ({ value: status,
|
|
labelKey: status === 'all' ? 'sync-recovery-all' : `sync-report-${status}`, selected: status === t.status.get() })) }],
|
|
};
|
|
},
|
|
});
|
|
Template.syncRecoveryReports.events({
|
|
'keydown .js-table-page-search'(event, t) {
|
|
event.stopPropagation();
|
|
if (event.key === 'Enter') { event.preventDefault(); t.search.set(event.currentTarget.value.trim().slice(0, 100)); t.load(); }
|
|
},
|
|
'change .js-table-page-filter'(event, t) {
|
|
event.stopPropagation(); t.status.set(event.currentTarget.value); t.load();
|
|
},
|
|
'click .js-table-page-action'(event, t) {
|
|
event.preventDefault(); event.stopPropagation(); t.load();
|
|
},
|
|
'click .js-table-page-prev, click .js-table-page-next'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const result = t.result.get();
|
|
const next = adjacentPage(result.total, result.page, event.currentTarget.classList.contains('js-table-page-next') ? 1 : -1, TABLE_PAGE_ROWS_PER_PAGE);
|
|
if (next !== result.page) t.load(next);
|
|
},
|
|
});
|
|
|
|
// Recipient summaries use the existing table page controls. No message content
|
|
// or mailbox addresses are published into Minimongo.
|
|
Template.emailRecoveryReports.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], total: 0, page: 1 });
|
|
this.error = new ReactiveVar('');
|
|
this.actionError = new ReactiveVar('');
|
|
this.search = new ReactiveVar('');
|
|
this.busy = new ReactiveVar(false);
|
|
this.request = 0;
|
|
this.commands = new Map();
|
|
this.load = (page = 1) => {
|
|
const request = ++this.request;
|
|
this.result.set({ rows: [], total: 0, page });
|
|
Meteor.call('emailRecoveryReport', { search: this.search.get(), page }, (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
this.error.set(error ? TAPi18n.__('email-recovery-unavailable') : '');
|
|
if (!error) this.result.set(result);
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.emailRecoveryReports.helpers({
|
|
error() { const t = Template.instance(); return t.actionError.get() || t.error.get(); },
|
|
busy() { return Template.instance().busy.get(); },
|
|
tablePageData() {
|
|
const t = Template.instance(), result = t.result.get();
|
|
const info = pageInfo(result.total, result.page, TABLE_PAGE_ROWS_PER_PAGE);
|
|
return {
|
|
header: buildHeader([{ labelKey: 'username' }, { labelKey: 'status' }, { labelKey: 'email-recovery-queued' },
|
|
{ labelKey: 'date' }, { labelKey: 'actions' }]),
|
|
rowTemplate: 'emailRecoveryReportRow', emptyKey: 'email-recovery-empty',
|
|
docs: result.rows.map(row => ({ ...row, busy: t.busy.get(),
|
|
statusLabel: row.paused ? 'email-recovery-paused' : row.failed ? 'email-recovery-attention' : 'email-recovery-pending',
|
|
failureRows: (row.failures || []).map(failure => ({ ...failure, labelKey: `email-failure-${failure.reason}` })),
|
|
oldestText: row.oldest ? formatDate(row.oldest) : '—',
|
|
nextText: !row.paused && row.nextAttemptAt ? formatDate(row.nextAttemptAt) : '—',
|
|
changedText: row.changedAt ? formatDate(row.changedAt) : '',
|
|
})),
|
|
rowCount: result.rows.length, total: result.total, searchTerm: t.search.get(),
|
|
page: info.page, totalPages: info.totalPages, hasPrev: info.hasPrev, hasNext: info.hasNext,
|
|
actions: [{ id: 'refresh-email', labelKey: 'refresh' }],
|
|
};
|
|
},
|
|
});
|
|
Template.emailRecoveryReports.events({
|
|
'keydown .js-table-page-search'(event, t) {
|
|
event.stopPropagation();
|
|
if (event.key === 'Enter') { event.preventDefault(); t.search.set(event.currentTarget.value.trim().slice(0, 100)); t.load(); }
|
|
},
|
|
'click .js-table-page-action'(event, t) {
|
|
event.preventDefault(); event.stopPropagation(); t.load();
|
|
},
|
|
'click .js-table-page-prev, click .js-table-page-next'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const result = t.result.get();
|
|
const next = adjacentPage(result.total, result.page, event.currentTarget.classList.contains('js-table-page-next') ? 1 : -1, TABLE_PAGE_ROWS_PER_PAGE);
|
|
if (next !== result.page) t.load(next);
|
|
},
|
|
'click .js-email-queue-action'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
if (t.busy.get()) return;
|
|
const action = event.currentTarget.dataset.action, userId = this.userId;
|
|
if (action === 'cancel' && !window.confirm(TAPi18n.__('email-recovery-confirm-cancel'))) return;
|
|
const key = `${userId}:${action}`;
|
|
// Retain an uncertain request's identity when the operator retries. A late
|
|
// retry cannot cancel newer messages or undo a newer operator action.
|
|
if (!t.commands.has(key)) t.commands.set(key, Random.id(32));
|
|
t.busy.set(true); t.actionError.set('');
|
|
Meteor.call('controlEmailRecovery', { userId, action, requestId: t.commands.get(key) }, (error, result) => {
|
|
if (t.view.isDestroyed) return;
|
|
t.busy.set(false);
|
|
if (error) t.actionError.set(TAPi18n.__(error.error === 'email-recovery-busy' ? 'email-recovery-busy' : 'email-recovery-failed'));
|
|
else {
|
|
t.commands.delete(key);
|
|
if (result.status === 'superseded') t.actionError.set(TAPi18n.__('email-recovery-superseded'));
|
|
}
|
|
t.load(t.result.get().page);
|
|
});
|
|
},
|
|
});
|
|
|
|
|
|
Template.activityNotificationRecoveryReports.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], total: 0, page: 1 });
|
|
this.search = new ReactiveVar(''); this.error = new ReactiveVar('');
|
|
this.actionError = new ReactiveVar(''); this.busy = new ReactiveVar(false);
|
|
this.request = 0;
|
|
this.load = (page = 1) => {
|
|
const request = ++this.request;
|
|
this.result.set({ rows: [], total: 0, page });
|
|
Meteor.call('activityNotificationRecoveryReport', { search: this.search.get(), page }, (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
this.error.set(error ? TAPi18n.__('activity-recovery-unavailable') : '');
|
|
if (!error) this.result.set(result);
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.activityNotificationRecoveryReports.helpers({
|
|
error() { const t = Template.instance(); return t.actionError.get() || t.error.get(); },
|
|
busy() { return Template.instance().busy.get(); },
|
|
tablePageData() {
|
|
const t = Template.instance(), result = t.result.get();
|
|
const info = pageInfo(result.total, result.page, TABLE_PAGE_ROWS_PER_PAGE);
|
|
return { header: buildHeader([{ labelKey: 'activity' }, { labelKey: 'board' }, { labelKey: 'status' },
|
|
{ labelKey: 'date' }, { labelKey: 'actions' }]),
|
|
rowTemplate: 'activityNotificationRecoveryRow', emptyKey: 'activity-recovery-empty',
|
|
docs: result.rows.map(row => ({ ...row, showPaused: row.paused && row.status !== 'cancelled', statusLabel: `activity-recovery-status-${row.status}`,
|
|
createdText: row.createdAt ? formatDate(row.createdAt) : '—', retryDisabled: t.busy.get() || !row.canRetry,
|
|
controlDisabled: t.busy.get() || !row.canControl, controlLabel: row.paused ? 'activity-recovery-resume' : 'activity-recovery-pause' })),
|
|
rowCount: result.rows.length, total: result.total, searchTerm: t.search.get(),
|
|
page: info.page, totalPages: info.totalPages, hasPrev: info.hasPrev, hasNext: info.hasNext,
|
|
actions: [{ id: 'refresh-activity-notifications', labelKey: 'refresh' },
|
|
// #6768: the whole list at once.
|
|
{ id: 'retry-all-activity-notifications', labelKey: 'activity-recovery-retry-all' },
|
|
{ id: 'cancel-old-activity-notifications', labelKey: 'activity-recovery-cancel-older' }] };
|
|
},
|
|
});
|
|
Template.activityNotificationRecoveryReports.events({
|
|
'keydown .js-table-page-search'(event, t) {
|
|
event.stopPropagation();
|
|
if (event.key === 'Enter') { event.preventDefault(); t.search.set(event.currentTarget.value.trim().slice(0, 100)); t.load(); }
|
|
},
|
|
'click .js-table-page-action'(event, t) {
|
|
event.preventDefault(); event.stopPropagation(); t.actionError.set('');
|
|
const action = event.currentTarget.dataset.action;
|
|
const done = (error, result) => {
|
|
if (t.view.isDestroyed) return;
|
|
t.busy.set(false);
|
|
if (error) t.actionError.set(TAPi18n.__('activity-recovery-control-failed'));
|
|
else if (result) t.actionError.set(TAPi18n.__('activity-recovery-bulk-result', result.cancelled || 0, result.completed || 0, result.remaining || 0));
|
|
t.load();
|
|
};
|
|
if (action === 'retry-all-activity-notifications') {
|
|
t.busy.set(true);
|
|
Meteor.call('retryAllActivityNotifications', done);
|
|
return;
|
|
}
|
|
if (action === 'cancel-old-activity-notifications') {
|
|
const answer = window.prompt(TAPi18n.__('activity-recovery-cancel-older-prompt'), '7');
|
|
const days = Number(answer);
|
|
if (answer === null || !Number.isFinite(days) || days < 0) return;
|
|
t.busy.set(true);
|
|
Meteor.call('cancelActivityNotificationsOlderThan', { days }, done);
|
|
return;
|
|
}
|
|
t.load();
|
|
},
|
|
'click .js-table-page-prev, click .js-table-page-next'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const result = t.result.get();
|
|
const next = adjacentPage(result.total, result.page, event.currentTarget.classList.contains('js-table-page-next') ? 1 : -1, TABLE_PAGE_ROWS_PER_PAGE);
|
|
if (next !== result.page) t.load(next);
|
|
},
|
|
'click .js-cancel-activity-notification'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
if (t.busy.get() || !this.canControl || !window.confirm(TAPi18n.__('activity-recovery-cancel-confirm'))) return;
|
|
t.busy.set(true); t.actionError.set('');
|
|
Meteor.call('cancelActivityNotificationRecovery', { intentId: this.intentId,
|
|
expectedRevision: this.controlRevision, requestId: Random.id(32) }, error => {
|
|
if (t.view.isDestroyed) return;
|
|
t.busy.set(false);
|
|
const known = ['activity-recovery-busy', 'activity-recovery-control-conflict'];
|
|
if (error) t.actionError.set(TAPi18n.__(known.includes(error.error) ? error.error : 'activity-recovery-control-failed'));
|
|
t.load(t.result.get().page);
|
|
});
|
|
},
|
|
'click .js-control-activity-notification'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
if (t.busy.get() || !this.canControl) return;
|
|
t.busy.set(true); t.actionError.set('');
|
|
// Use the displayed revision. Never silently rebase an uncertain request
|
|
// onto a newer administrator decision.
|
|
Meteor.call('controlActivityNotificationRecovery', { intentId: this.intentId, paused: !this.paused,
|
|
expectedRevision: this.controlRevision, requestId: Random.id(32) }, error => {
|
|
if (t.view.isDestroyed) return;
|
|
t.busy.set(false);
|
|
const known = ['activity-recovery-busy', 'activity-recovery-control-conflict'];
|
|
if (error) t.actionError.set(TAPi18n.__(known.includes(error.error) ? error.error : 'activity-recovery-control-failed'));
|
|
t.load(t.result.get().page);
|
|
});
|
|
},
|
|
'click .js-retry-activity-notification'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
if (t.busy.get() || !this.canRetry) return;
|
|
t.busy.set(true); t.actionError.set('');
|
|
Meteor.call('retryActivityNotification', { intentId: this.intentId }, error => {
|
|
if (t.view.isDestroyed) return;
|
|
t.busy.set(false);
|
|
const known = ['activity-recovery-busy', 'activity-recovery-denied', 'activity-recovery-source-unavailable', 'activity-recovery-disabled', 'activity-recovery-paused', 'activity-recovery-status-cancelled'];
|
|
if (error) t.actionError.set(TAPi18n.__(known.includes(error.error) ? error.error : 'activity-recovery-failed'));
|
|
t.load(t.result.get().page);
|
|
});
|
|
},
|
|
});
|
|
|
|
|
|
const RESOLUTION_ERRORS = ['too-early', 'already-resolved', 'resend-in-flight', 'nothing-to-resend',
|
|
'resend-uncertain', 'busy', 'command-changed', 'attempt-invalid'].map(reason => `rule-email-resolution-${reason}`);
|
|
const resolutionError = error => (RESOLUTION_ERRORS.includes(error?.error) ? error.error : 'rule-email-resolution-failed');
|
|
Template.syncRuleEmailRecoveryReports.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], total: 0, page: 0, pageSize: 10 });
|
|
this.search = new ReactiveVar(''); this.status = new ReactiveVar('all');
|
|
this.error = new ReactiveVar(''); this.request = 0;
|
|
// #2713: the administrator's review of one unconfirmed attempt.
|
|
this.detail = new ReactiveVar(null); this.actionError = new ReactiveVar(''); this.busy = new ReactiveVar(false);
|
|
this.openDetail = commandId => {
|
|
this.actionError.set('');
|
|
Meteor.call('syncRuleEmailRecoveryDetail', commandId, (error, detail) => {
|
|
if (this.view.isDestroyed) return;
|
|
if (error) { this.detail.set(null); this.error.set(TAPi18n.__(resolutionError(error))); return; }
|
|
this.detail.set(detail);
|
|
});
|
|
};
|
|
this.decide = (method, args, confirmKey) => {
|
|
const detail = this.detail.get();
|
|
if (!detail || this.busy.get() || !window.confirm(TAPi18n.__(confirmKey))) return;
|
|
this.busy.set(true); this.actionError.set('');
|
|
Meteor.call(method, ...args(detail.commandId), error => {
|
|
if (this.view.isDestroyed) return;
|
|
this.busy.set(false);
|
|
if (error) this.actionError.set(TAPi18n.__(resolutionError(error)));
|
|
this.openDetail(detail.commandId);
|
|
this.load(this.result.get().page);
|
|
});
|
|
};
|
|
this.load = (page = 0) => {
|
|
const request = ++this.request;
|
|
this.error.set('');
|
|
this.result.set({ rows: [], total: 0, page, pageSize: 10 });
|
|
Meteor.call('syncRuleEmailRecoveryReport', { search: this.search.get(), status: this.status.get(), page }, (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
this.error.set(error ? TAPi18n.__('rule-email-recovery-unavailable') : '');
|
|
if (!error) this.result.set(result);
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.syncRuleEmailRecoveryReports.helpers({
|
|
error() { return Template.instance().error.get(); },
|
|
detail() {
|
|
const t = Template.instance(), detail = t.detail.get();
|
|
if (!detail) return null;
|
|
return { ...detail,
|
|
recipients: detail.recipients.map(row => ({ ...row, statusKey: `rule-email-recovery-recipient-${row.status}` })),
|
|
waiting: detail.status === 'unconfirmed', resolvableText: formatDate(detail.resolvableAt),
|
|
actionError: t.actionError.get(), actionsDisabled: t.busy.get() || !detail.resolvable };
|
|
},
|
|
tablePageData() {
|
|
const t = Template.instance(), result = t.result.get();
|
|
// The report API is zero-based; the shared table controls are one-based.
|
|
const info = pageInfo(result.total, result.page + 1, result.pageSize);
|
|
return { header: buildHeader([{ labelKey: 'rule-email-recovery-identifiers' }, { labelKey: 'status' },
|
|
{ labelKey: 'rule-email-recovery-started' }, { labelKey: 'rule-email-recovery-finished' }, { labelKey: 'actions' }]),
|
|
rowTemplate: 'syncRuleEmailRecoveryRow', emptyKey: 'rule-email-recovery-empty',
|
|
docs: result.rows.map(row => ({ ...row, statusLabel: `rule-email-recovery-${row.status}`,
|
|
canReview: row.status === 'unconfirmed' && !!row.commandId,
|
|
startedText: row.startedAt ? formatDate(row.startedAt) : '—', finishedText: row.finishedAt ? formatDate(row.finishedAt) : '—' })),
|
|
rowCount: result.rows.length, total: result.total, searchTerm: t.search.get(),
|
|
page: info.page, totalPages: info.totalPages, hasPrev: info.hasPrev, hasNext: info.hasNext,
|
|
actions: [{ id: 'refresh-rule-email', labelKey: 'refresh' }] };
|
|
},
|
|
});
|
|
Template.syncRuleEmailRecoveryReports.events({
|
|
'keydown .js-table-page-search'(event, t) {
|
|
event.stopPropagation();
|
|
if (event.key === 'Enter') { event.preventDefault(); t.search.set(event.currentTarget.value.trim().slice(0, 128)); t.load(); }
|
|
},
|
|
'click .js-rule-email-review'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.openDetail(event.currentTarget.closest('tr').dataset.command);
|
|
},
|
|
'click .js-rule-email-resend'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.decide('syncRuleEmailRecoveryResend', commandId => [commandId], 'rule-email-recovery-resend-confirm');
|
|
},
|
|
'click .js-rule-email-mark-sent'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.decide('syncRuleEmailRecoveryResolve', commandId => [{ commandId, decision: 'mark-sent' }], 'rule-email-recovery-mark-sent-confirm');
|
|
},
|
|
'click .js-rule-email-drop'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.decide('syncRuleEmailRecoveryResolve', commandId => [{ commandId, decision: 'drop' }], 'rule-email-recovery-drop-confirm');
|
|
},
|
|
'change .js-rule-email-status'(event, t) {
|
|
event.stopPropagation(); t.status.set(event.currentTarget.value); t.load();
|
|
},
|
|
'click .js-table-page-action'(event, t) {
|
|
event.preventDefault(); event.stopPropagation(); t.load(t.result.get().page);
|
|
},
|
|
'click .js-table-page-prev, click .js-table-page-next'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const result = t.result.get();
|
|
const page = adjacentPage(result.total, result.page + 1,
|
|
event.currentTarget.classList.contains('js-table-page-next') ? 1 : -1, result.pageSize) - 1;
|
|
if (page !== result.page) t.load(page);
|
|
},
|
|
});
|
|
|
|
|
|
// Legacy rule emails, maintainer decision of 2026-09-30: an administrator
|
|
// re-binds (recaptures from the current source) or discards each one.
|
|
const LEGACY_ERRORS = ['access-denied', 'source-changed', 'source-unavailable', 'plan-unavailable',
|
|
'attempt-exists', 'not-legacy'].map(reason => `rule-email-legacy-${reason}`);
|
|
Template.syncRuleEmailLegacyCommands.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], total: 0, page: 0, pageSize: 10 });
|
|
this.error = new ReactiveVar(''); this.busy = new ReactiveVar(false); this.request = 0;
|
|
this.load = (page = 0) => {
|
|
const request = ++this.request;
|
|
Meteor.call('syncRuleEmailLegacyList', page, (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
if (error) { this.error.set(TAPi18n.__('rule-email-legacy-unavailable')); return; }
|
|
this.result.set(result);
|
|
});
|
|
};
|
|
this.act = (method, commandId, confirmKey) => {
|
|
if (this.busy.get() || !window.confirm(TAPi18n.__(confirmKey))) return;
|
|
this.busy.set(true); this.error.set('');
|
|
Meteor.call(method, commandId, error => {
|
|
if (this.view.isDestroyed) return;
|
|
this.busy.set(false);
|
|
if (error) this.error.set(TAPi18n.__(LEGACY_ERRORS.includes(error.error) ? error.error : 'rule-email-legacy-failed'));
|
|
this.load(this.result.get().page);
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.syncRuleEmailLegacyCommands.helpers({
|
|
error() { return Template.instance().error.get(); },
|
|
busy() { return Template.instance().busy.get(); },
|
|
rows() {
|
|
const t = Template.instance();
|
|
return t.result.get().rows.map(row => ({ ...row, reasonKey: `rule-email-legacy-reason-${row.reason}`,
|
|
rebindDisabled: t.busy.get() || !row.rebindable }));
|
|
},
|
|
hasPages() { const r = Template.instance().result.get(); return r.total > r.pageSize; },
|
|
noPrev() { return Template.instance().result.get().page === 0; },
|
|
noNext() { const r = Template.instance().result.get(); return (r.page + 1) * r.pageSize >= r.total; },
|
|
pageLabel() { const r = Template.instance().result.get(); return `${r.page + 1} / ${Math.max(1, Math.ceil(r.total / r.pageSize))}`; },
|
|
});
|
|
Template.syncRuleEmailLegacyCommands.events({
|
|
'click .js-rule-email-legacy-rebind'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.act('syncRuleEmailLegacyRebind', event.currentTarget.closest('tr').dataset.command, 'rule-email-legacy-rebind-confirm');
|
|
},
|
|
'click .js-rule-email-legacy-discard'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.act('syncRuleEmailLegacyDiscard', event.currentTarget.closest('tr').dataset.command, 'rule-email-legacy-discard-confirm');
|
|
},
|
|
'click .js-rule-email-legacy-prev, click .js-rule-email-legacy-next'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const r = t.result.get();
|
|
t.load(r.page + (event.currentTarget.classList.contains('js-rule-email-legacy-next') ? 1 : -1));
|
|
},
|
|
});
|
|
|
|
// List Sync operations that can no longer be replayed (server/lib/listSyncStuck.js):
|
|
// an administrator discards one so the list's Sync can run again.
|
|
// The server refuses with list-sync-stuck-<reason>; each known reason has its
|
|
// own stuck-sync-operation-<reason> text.
|
|
const LIST_SYNC_STUCK_REFUSALS = ['missing', 'not-stuck', 'replayable', 'busy'];
|
|
const listSyncStuckErrorKey = code => {
|
|
const reason = typeof code === 'string' && code.startsWith('list-sync-stuck-') ? code.slice('list-sync-stuck-'.length) : '';
|
|
return `stuck-sync-operation-${LIST_SYNC_STUCK_REFUSALS.includes(reason) ? reason : 'failed'}`;
|
|
};
|
|
Template.listSyncStuckOperations.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], truncated: false });
|
|
this.error = new ReactiveVar(''); this.busy = new ReactiveVar(false); this.request = 0;
|
|
this.load = () => {
|
|
const request = ++this.request;
|
|
Meteor.call('listSyncStuckOperations', (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
if (error) { this.error.set(TAPi18n.__('stuck-sync-operation-unavailable')); return; }
|
|
this.result.set(result);
|
|
});
|
|
};
|
|
this.discard = (listId, operationId) => {
|
|
if (this.busy.get() || !window.confirm(TAPi18n.__('stuck-sync-operation-discard-confirm'))) return;
|
|
this.busy.set(true); this.error.set('');
|
|
Meteor.call('listSyncStuckDiscard', { listId, operationId }, error => {
|
|
if (this.view.isDestroyed) return;
|
|
this.busy.set(false);
|
|
if (error) this.error.set(TAPi18n.__(listSyncStuckErrorKey(error.error)));
|
|
this.load();
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.listSyncStuckOperations.helpers({
|
|
error() { return Template.instance().error.get(); },
|
|
busy() { return Template.instance().busy.get(); },
|
|
truncated() { return Template.instance().result.get().truncated; },
|
|
rows() {
|
|
const t = Template.instance();
|
|
return t.result.get().rows.map(row => ({ ...row, reasonKey: `stuck-sync-operation-reason-${row.reason || 'unknown'}`,
|
|
stuckAtText: row.stuckAt ? formatDate(row.stuckAt) : '', replayableNow: row.replayable === true,
|
|
// Only an operation known NOT to be replayable now can be discarded;
|
|
// the server checks again under the list lease.
|
|
discardDisabled: t.busy.get() || row.replayable !== false }));
|
|
},
|
|
});
|
|
Template.listSyncStuckOperations.events({
|
|
'click .js-list-sync-stuck-discard'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
const row = event.currentTarget.closest('tr');
|
|
t.discard(row.dataset.list, row.dataset.operation);
|
|
},
|
|
'click .js-list-sync-stuck-refresh'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.error.set(''); t.load();
|
|
},
|
|
});
|
|
|
|
// Board imports that stopped before finishing (server/lib/importRuns.js): an
|
|
// administrator keeps the partial board as it is, or discards it. The server
|
|
// refuses with import-run-<reason>; each known reason has its own
|
|
// interrupted-import-<reason> text.
|
|
const IMPORT_RUN_REFUSALS = ['missing', 'not-interrupted', 'foreign-board', 'scrum-busy'];
|
|
const importRunErrorKey = code => {
|
|
const reason = typeof code === 'string' && code.startsWith('import-run-') ? code.slice('import-run-'.length) : '';
|
|
return `interrupted-import-${IMPORT_RUN_REFUSALS.includes(reason) ? reason : 'failed'}`;
|
|
};
|
|
Template.interruptedImports.onCreated(function () {
|
|
this.result = new ReactiveVar({ rows: [], truncated: false });
|
|
this.error = new ReactiveVar(''); this.busy = new ReactiveVar(false); this.request = 0;
|
|
this.load = () => {
|
|
const request = ++this.request;
|
|
Meteor.call('importRunsInterrupted', (error, result) => {
|
|
if (this.view.isDestroyed || request !== this.request) return;
|
|
if (error) { this.error.set(TAPi18n.__('interrupted-import-unavailable')); return; }
|
|
this.result.set(result);
|
|
});
|
|
};
|
|
this.act = (method, runId, confirmKey) => {
|
|
if (this.busy.get() || !window.confirm(TAPi18n.__(confirmKey))) return;
|
|
this.busy.set(true); this.error.set('');
|
|
Meteor.call(method, { runId }, error => {
|
|
if (this.view.isDestroyed) return;
|
|
this.busy.set(false);
|
|
if (error) this.error.set(TAPi18n.__(importRunErrorKey(error.error)));
|
|
this.load();
|
|
});
|
|
};
|
|
this.load();
|
|
});
|
|
Template.interruptedImports.helpers({
|
|
error() { return Template.instance().error.get(); },
|
|
busy() { return Template.instance().busy.get(); },
|
|
truncated() { return Template.instance().result.get().truncated; },
|
|
rows() {
|
|
const t = Template.instance();
|
|
return t.result.get().rows.map(row => ({ ...row,
|
|
boardLabel: row.boardTitle ? `${row.boardTitle} (${row.boardId})` : row.boardId,
|
|
startedAtText: row.startedAt ? formatDate(row.startedAt) : '',
|
|
// A discard that stopped halfway is finished by discarding again.
|
|
stateKey: row.state === 'discarding' ? 'interrupted-import-state-discarding'
|
|
: row.interruptedFrom === 'failed' ? 'interrupted-import-state-failed' : 'interrupted-import-state-stopped',
|
|
// Only a board this run created is discarded; the server checks again.
|
|
discardDisabled: t.busy.get() || row.foreignBoard,
|
|
keepDisabled: t.busy.get() || row.state !== 'interrupted' }));
|
|
},
|
|
});
|
|
Template.interruptedImports.events({
|
|
'click .js-interrupted-import-discard'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.act('importRunDiscard', event.currentTarget.closest('tr').dataset.run, 'interrupted-import-discard-confirm');
|
|
},
|
|
'click .js-interrupted-import-keep'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.act('importRunKeep', event.currentTarget.closest('tr').dataset.run, 'interrupted-import-keep-confirm');
|
|
},
|
|
'click .js-interrupted-import-refresh'(event, t) {
|
|
event.preventDefault(); event.stopPropagation();
|
|
t.error.set(''); t.load();
|
|
},
|
|
});
|