diff --git a/public/language/en-GB/admin/advanced/events.json b/public/language/en-GB/admin/advanced/events.json index 9327eef8d3..a249bb9721 100644 --- a/public/language/en-GB/admin/advanced/events.json +++ b/public/language/en-GB/admin/advanced/events.json @@ -9,5 +9,9 @@ "filter-type": "Event Type", "filter-start": "Start Date", "filter-end": "End Date", + "filter-user": "Filter by User", + "filter-user.placeholder": "Type user name to filter...", + "filter-group": "Filter by Group", + "filter-group.placeholder": "Type group name to filter...", "filter-per-page": "Per Page" } \ No newline at end of file diff --git a/public/src/admin/advanced/events.js b/public/src/admin/advanced/events.js index 112c76f985..ecf494bf31 100644 --- a/public/src/admin/advanced/events.js +++ b/public/src/admin/advanced/events.js @@ -1,7 +1,7 @@ 'use strict'; -define('admin/advanced/events', ['bootbox', 'alerts'], function (bootbox, alerts) { +define('admin/advanced/events', ['bootbox', 'alerts', 'autocomplete'], function (bootbox, alerts, autocomplete) { const Events = {}; Events.init = function () { @@ -30,6 +30,21 @@ define('admin/advanced/events', ['bootbox', 'alerts'], function (bootbox, alerts }); }); + $('#user-group-select').on('change', function () { + const val = $(this).val(); + $('#username').toggleClass('hidden', val !== 'username'); + if (val !== 'username') { + $('#username').val(''); + } + $('#group').toggleClass('hidden', val !== 'group'); + if (val !== 'group') { + $('#group').val(''); + } + }); + + autocomplete.user($('#username')); + autocomplete.group($('#group')); + $('#apply').on('click', Events.refresh); }; diff --git a/src/cli/manage.js b/src/cli/manage.js index f69c786680..82472d115e 100644 --- a/src/cli/manage.js +++ b/src/cli/manage.js @@ -130,7 +130,11 @@ async function listPlugins() { async function listEvents(count = 10) { await db.init(); - const eventData = await events.getEvents('', 0, count - 1); + const eventData = await events.getEvents({ + filter: '', + start: 0, + stop: count - 1, + }); console.log(chalk.bold(`\nDisplaying last ${count} administrative events...`)); eventData.forEach((event) => { console.log(` * ${chalk.green(String(event.timestampISO))} ${chalk.yellow(String(event.type))}${event.text ? ` ${event.text}` : ''} (uid: ${event.uid ? event.uid : 0})`); diff --git a/src/controllers/admin/events.js b/src/controllers/admin/events.js index 3d59892090..bc94437975 100644 --- a/src/controllers/admin/events.js +++ b/src/controllers/admin/events.js @@ -3,6 +3,8 @@ const db = require('../../database'); const events = require('../../events'); const pagination = require('../../pagination'); +const user = require('../../user'); +const groups = require('../../groups'); const eventsController = module.exports; @@ -11,18 +13,35 @@ eventsController.get = async function (req, res) { const itemsPerPage = parseInt(req.query.perPage, 10) || 20; const start = (page - 1) * itemsPerPage; const stop = start + itemsPerPage - 1; + let uids; + if (req.query.username) { + uids = [await user.getUidByUsername(req.query.username)]; + } else if (req.query.group) { + uids = await groups.getMembers(req.query.group, 0, -1); + } // Limit by date let from = req.query.start ? new Date(req.query.start) || undefined : undefined; let to = req.query.end ? new Date(req.query.end) || undefined : new Date(); - from = from && from.setHours(0, 0, 0, 0); // setHours returns a unix timestamp (Number, not Date) - to = to && to.setHours(23, 59, 59, 999); // setHours returns a unix timestamp (Number, not Date) + from = from && from.setUTCHours(0, 0, 0, 0); // setHours returns a unix timestamp (Number, not Date) + to = to && to.setUTCHours(23, 59, 59, 999); // setHours returns a unix timestamp (Number, not Date) const currentFilter = req.query.type || ''; - const [eventCount, eventData, counts] = await Promise.all([ - db.sortedSetCount(`events:time${currentFilter ? `:${currentFilter}` : ''}`, from || '-inf', to), - events.getEvents(currentFilter, start, stop, from || '-inf', to), + events.getEventCount({ + filter: currentFilter, + uids, + from: from || '-inf', + to, + }), + events.getEvents({ + filter: currentFilter, + uids, + start, + stop, + from: from || '-inf', + to, + }), db.sortedSetsCard([''].concat(events.types).map(type => `events:time${type ? `:${type}` : ''}`)), ]); diff --git a/src/events.js b/src/events.js index 7329aa486e..41e1f0d29b 100644 --- a/src/events.js +++ b/src/events.js @@ -87,30 +87,114 @@ events.log = async function (data) { const eid = await db.incrObjectField('global', 'nextEid'); data.timestamp = Date.now(); data.eid = eid; - + const setKeys = [ + 'events:time', + `events:time:${data.type}`, + ]; + if (data.hasOwnProperty('uid') && data.uid) { + setKeys.push(`events:time:uid:${data.uid}`); + } await Promise.all([ - db.sortedSetsAdd([ - 'events:time', - `events:time:${data.type}`, - ], data.timestamp, eid), + db.sortedSetsAdd(setKeys, data.timestamp, eid), db.setObject(`event:${eid}`, data), ]); plugins.hooks.fire('action:events.log', { data: data }); }; -events.getEvents = async function (filter, start, stop, from, to) { - // from/to optional - if (from === undefined) { - from = '-inf'; +// filter, start, stop, from(optional), to(optional), uids(optional) +events.getEvents = async function (options) { + // backwards compatibility + if (arguments.length > 1) { + // eslint-disable-next-line prefer-rest-params + const args = Array.prototype.slice.call(arguments); + options = { + filter: args[0], + start: args[1], + stop: args[2], + from: args[3], + to: args[4], + }; } - if (to === undefined) { - to = '+inf'; + // from/to optional + const from = options.hasOwnProperty('from') ? options.from : '-inf'; + const to = options.hasOwnProperty('to') ? options.to : '+inf'; + const { filter, start, stop, uids } = options; + let eids = []; + + if (Array.isArray(uids)) { + if (filter === '') { + eids = await db.getSortedSetRevRangeByScore( + uids.map(uid => `events:time:uid:${uid}`), + start, + stop === -1 ? -1 : stop - start + 1, + to, + from + ); + } else { + eids = await Promise.all( + uids.map( + uid => db.getSortedSetRevIntersect({ + sets: [`events:time:uid:${uid}`, `events:time:${filter}`], + start: 0, + stop: -1, + weights: [1, 0], + withScores: true, + }) + ) + ); + + eids = _.flatten(eids) + .filter( + i => (from === '-inf' || i.score >= from) && (to === '+inf' || i.score <= to) + ) + .sort((a, b) => b.score - a.score) + .slice(start, stop + 1) + .map(i => i.value); + } + } else { + eids = await db.getSortedSetRevRangeByScore( + `events:time${filter ? `:${filter}` : ''}`, + start, + stop === -1 ? -1 : stop - start + 1, + to, + from + ); } - const eids = await db.getSortedSetRevRangeByScore(`events:time${filter ? `:${filter}` : ''}`, start, stop === -1 ? -1 : stop - start + 1, to, from); return await events.getEventsByEventIds(eids); }; +events.getEventCount = async (options) => { + const { filter, uids, from, to } = options; + + if (Array.isArray(uids)) { + if (filter === '') { + const counts = await Promise.all( + uids.map(uid => db.sortedSetCount(`events:time:uid:${uid}`, from, to)) + ); + return counts.reduce((prev, cur) => prev + cur, 0); + } + + const eids = await Promise.all( + uids.map( + uid => db.getSortedSetRevIntersect({ + sets: [`events:time:uid:${uid}`, `events:time:${filter}`], + start: 0, + stop: -1, + weights: [1, 0], + withScores: true, + }) + ) + ); + + return _.flatten(eids).filter( + i => (from === '-inf' || i.score >= from) && (to === '+inf' || i.score <= to) + ).length; + } + + return await db.sortedSetCount(`events:time${filter ? `:${filter}` : ''}`, from || '-inf', to); +}; + events.getEventsByEventIds = async (eids) => { let eventsData = await db.getObjects(eids.map(eid => `event:${eid}`)); eventsData = eventsData.filter(Boolean); @@ -163,7 +247,11 @@ async function addUserData(eventsData, field, objectName) { events.deleteEvents = async function (eids) { const keys = eids.map(eid => `event:${eid}`); const eventData = await db.getObjectsFields(keys, ['type']); - const sets = _.uniq(['events:time'].concat(eventData.map(e => `events:time:${e.type}`))); + const sets = _.uniq( + ['events:time'] + .concat(eventData.map(e => `events:time:${e.type}`)) + .concat(eventData.map(e => `events:time:uid:${e.uid}`)) + ); await Promise.all([ db.deleteAll(keys), db.sortedSetRemove(sets, eids), diff --git a/src/upgrades/3.8.0/events-uid-filter.js b/src/upgrades/3.8.0/events-uid-filter.js new file mode 100644 index 0000000000..f9a2d5b6a2 --- /dev/null +++ b/src/upgrades/3.8.0/events-uid-filter.js @@ -0,0 +1,31 @@ +/* eslint-disable no-await-in-loop */ + +'use strict'; + +const db = require('../../database'); +const batch = require('../../batch'); + +module.exports = { + name: 'Add user filter to acp events', + timestamp: Date.UTC(2024, 3, 1), + method: async function () { + const { progress } = this; + + await batch.processSortedSet(`events:time`, async (eids) => { + const eventData = await db.getObjects(eids.map(eid => `event:${eid}`)); + const bulkAdd = []; + eventData.forEach((event) => { + if (event && event.hasOwnProperty('uid') && event.uid && event.eid) { + bulkAdd.push( + [`events:time:uid:${event.uid}`, event.timestamp || Date.now(), event.eid] + ); + } + }); + await db.sortedSetAddBulk(bulkAdd); + progress.incr(eids.length); + }, { + batch: 500, + progress, + }); + }, +}; diff --git a/src/views/admin/advanced/events.tpl b/src/views/admin/advanced/events.tpl index 0eafb03749..f20eca0547 100644 --- a/src/views/admin/advanced/events.tpl +++ b/src/views/admin/advanced/events.tpl @@ -50,6 +50,14 @@ +