diff --git a/app/federation/client/admin/dashboard.css b/app/federation/client/admin/dashboard.css new file mode 100644 index 0000000000000..31b7ade58d7ae --- /dev/null +++ b/app/federation/client/admin/dashboard.css @@ -0,0 +1,141 @@ +.status { + flex: 0 0 auto; + + width: 6px; + height: 6px; + margin: 0 7px; + + border-radius: 50%; +} + +.status.stable { + background-color: #2de0a5; +} + +.status.unstable { + background-color: #ffd21f; +} + +.status.failing { + background-color: #f5455c; +} + +.frame { + display: flex; + flex-direction: row; +} + +.group { + display: flex; + flex-direction: row; + flex: 100%; + + max-width: 100%; + margin: 10px; + + border-width: 1px; + align-items: center; + justify-content: center; +} + +.group.left { + justify-content: flex-start; +} + +.group.wrap { + flex-wrap: wrap; +} + +.overview-column { + flex: 100%; + + min-height: 20px; + margin: 15px 0; +} + +.overview-column.small { + max-width: 20%; +} + +.group .overview-column:not(:last-child) { + border-right: 1px solid #e9e9e9; +} + +.group .overview-column:nth-child(5n) { + border-right: 0; +} + +.overview-pill { + display: flex; + + width: 100%; + padding: 0 10px; + + user-select: text; + text-align: center; + align-items: center; +} + +.overview-item { + width: 100%; + + user-select: text; + text-align: center; +} + +.overview-item > .title { + display: inline-block; + + margin-top: 8px; + + text-transform: uppercase; + + color: #9ea2a8; + + font-size: 0.875rem; + font-weight: 300; +} + +.overview-item > .value { + display: inline-block; + + width: 100%; + + text-transform: capitalize; + + color: #383838; + + font-size: 1.75rem; + font-weight: 400; + line-height: 1; +} + +@media screen and (max-width: 925px) { + .overview-item > .title { + font-size: 0.5rem; + } + + .overview-item > .value { + font-size: 1rem; + } +} + +@media screen and (max-width: 800px) { + .overview-item > .title { + font-size: 0.875rem; + } + + .overview-item > .value { + font-size: 1.75rem; + } +} + +@media screen and (max-width: 600px) { + .overview-item > .title { + font-size: 0.5rem; + } + + .overview-item > .value { + font-size: 1rem; + } +} diff --git a/app/federation/client/admin/dashboard.html b/app/federation/client/admin/dashboard.html new file mode 100644 index 0000000000000..476cc9472dfbe --- /dev/null +++ b/app/federation/client/admin/dashboard.html @@ -0,0 +1,33 @@ + diff --git a/app/federation/client/admin/dashboard.js b/app/federation/client/admin/dashboard.js new file mode 100644 index 0000000000000..eb4f6d55d470c --- /dev/null +++ b/app/federation/client/admin/dashboard.js @@ -0,0 +1,92 @@ +import { Meteor } from 'meteor/meteor'; +import { Tracker } from 'meteor/tracker'; +import { Template } from 'meteor/templating'; +import { ReactiveVar } from 'meteor/reactive-var'; + +import { BlazeLayout } from 'meteor/kadira:blaze-layout'; +import { FlowRouter } from 'meteor/kadira:flow-router'; +import { AdminBox } from '../../../ui-utils'; +import { hasRole } from '../../../authorization'; + +import './dashboard.html'; +import './dashboard.css'; + +// Template controller +let templateInstance; // current template instance/context + +// Methods +const updateOverviewData = () => { + Meteor.call('federation:getOverviewData', (error, result) => { + if (error) { + console.log(error); + + return; + // return handleError(error); + } + + const { data } = result; + + templateInstance.federationOverviewData.set(data); + }); +}; + +const updatePeerStatuses = () => { + Meteor.call('federation:getPeerStatuses', (error, result) => { + if (error) { + console.log(error); + + return; + // return handleError(error); + } + + const { data } = result; + + templateInstance.federationPeerStatuses.set(data); + }); +}; + +const updateData = () => { + updateOverviewData(); + updatePeerStatuses(); +}; + +Template.dashboard.helpers({ + federationOverviewData() { + return templateInstance.federationOverviewData.get(); + }, + federationPeerStatuses() { + return templateInstance.federationPeerStatuses.get(); + }, +}); + +// Events +Template.dashboard.onCreated(function() { + templateInstance = Template.instance(); + + this.federationOverviewData = new ReactiveVar(); + this.federationPeerStatuses = new ReactiveVar(); +}); + +Template.dashboard.onRendered(() => { + Tracker.autorun(updateData); + + setInterval(updateData, 10000); +}); + +// Route setup + +FlowRouter.route('/admin/federation-dashboard', { + name: 'federation-dashboard', + action() { + BlazeLayout.render('main', { center: 'dashboard', old: true }); + }, +}); + +AdminBox.addOption({ + icon: 'discover', + href: 'admin/federation-dashboard', + i18nLabel: 'Federation Dashboard', + permissionGranted() { + return hasRole(Meteor.userId(), 'admin'); + }, +}); diff --git a/app/federation/client/index.js b/app/federation/client/index.js index 573845cba35a5..14655cf4b6a72 100644 --- a/app/federation/client/index.js +++ b/app/federation/client/index.js @@ -1,23 +1,2 @@ -import { MessageTypes } from '../../ui-utils/client'; - -// Register message types -MessageTypes.registerType({ - id: 'rejected-message-by-peer', - system: true, - message: 'This_message_was_rejected_by__peer__peer', - data(message) { - return { - peer: message.peer, - }; - }, -}); -MessageTypes.registerType({ - id: 'peer-does-not-exist', - system: true, - message: 'The_peer__peer__does_not_exist', - data(message) { - return { - peer: message.peer, - }; - }, -}); +import './messageTypes'; +import './admin/dashboard'; diff --git a/app/federation/client/messageTypes.js b/app/federation/client/messageTypes.js new file mode 100644 index 0000000000000..573845cba35a5 --- /dev/null +++ b/app/federation/client/messageTypes.js @@ -0,0 +1,23 @@ +import { MessageTypes } from '../../ui-utils/client'; + +// Register message types +MessageTypes.registerType({ + id: 'rejected-message-by-peer', + system: true, + message: 'This_message_was_rejected_by__peer__peer', + data(message) { + return { + peer: message.peer, + }; + }, +}); +MessageTypes.registerType({ + id: 'peer-does-not-exist', + system: true, + message: 'The_peer__peer__does_not_exist', + data(message) { + return { + peer: message.peer, + }; + }, +}); diff --git a/app/federation/server/PeerClient.js b/app/federation/server/PeerClient.js index 1de518caa906e..3a263c55901af 100644 --- a/app/federation/server/PeerClient.js +++ b/app/federation/server/PeerClient.js @@ -224,7 +224,7 @@ export class PeerClient { propagateEvent(e) { this.log(`propagateEvent: ${ e.t }`); - const { peer: domain } = e; + const { peer: domain, options: eventOptions } = e; const peer = Federation.peerDNS.searchPeer(domain); @@ -249,7 +249,7 @@ export class PeerClient { 'POST', '/api/v1/federation.events', { payload }, - { total: 5, stepSize: 500, stepMultiplier: 10 } + eventOptions.retry || { total: 5, stepSize: 500, stepMultiplier: 10 } ); FederationEvents.setEventAsFullfilled(e); diff --git a/app/federation/server/PeerPinger.js b/app/federation/server/PeerPinger.js index 48e984da66402..c6d18fd7bff30 100644 --- a/app/federation/server/PeerPinger.js +++ b/app/federation/server/PeerPinger.js @@ -8,31 +8,31 @@ import { ping } from './methods/ping'; import moment from 'moment'; export class PeerPinger { - constructor() { - this.config = { - pingInterval: 5000 - }; + constructor() { + this.config = { + pingInterval: 5000, + }; - this.peers = []; - } + this.peers = []; + } - log(message) { - logger.pinger.info(message); - } + log(message) { + logger.pinger.info(message); + } - start() { - this.pingAllPeers(); - } + start() { + this.pingAllPeers(); + } - pingAllPeers() { - const lastSeenAt = moment().subtract(10, 'm').toDate(); + pingAllPeers() { + const lastSeenAt = moment().subtract(10, 'm').toDate(); - const peers = FederationPeers.find({ $or: [{ last_seen_at: null }, { last_seen_at: { $lte: lastSeenAt } }] }).fetch(); + const peers = FederationPeers.find({ $or: [{ last_seen_at: null }, { last_seen_at: { $lte: lastSeenAt } }] }).fetch(); - const pingResults = ping(peers.map(p => p.peer)); + const pingResults = ping(peers.map((p) => p.peer)); - FederationPeers.updateStatuses(pingResults); + FederationPeers.updateStatuses(pingResults); - Meteor.setTimeout(this.pingAllPeers.bind(this), this.config.pingInterval); - } + Meteor.setTimeout(this.pingAllPeers.bind(this), this.config.pingInterval); + } } diff --git a/app/federation/server/PeerServer/PeerServer.js b/app/federation/server/PeerServer/PeerServer.js index 6f7e8f84f1ba2..5ab243e8e97cf 100644 --- a/app/federation/server/PeerServer/PeerServer.js +++ b/app/federation/server/PeerServer/PeerServer.js @@ -1,7 +1,7 @@ import { callbacks } from '../../../callbacks'; import { setReaction } from '../../../reactions/server'; import { addUserToRoom, removeUserFromRoom, deleteMessage } from '../../../lib'; -import { Rooms, Subscriptions } from '../../../models'; +import { Rooms, Subscriptions, FederationPeers } from '../../../models'; import { FederatedMessage, FederatedRoom, FederatedUser } from '../federatedResources'; import { logger } from '../logger.js'; @@ -56,6 +56,9 @@ export class PeerServer { // Then, create the room, if needed federatedRoom.create(); + + // Refresh federation peers + FederationPeers.refreshPeers(); } handleRoomCreatedEvent(e) { @@ -76,6 +79,9 @@ export class PeerServer { // Then, create the room, if needed federatedRoom.create(true); + + // Refresh federation peers + FederationPeers.refreshPeers(); } handleUserJoinedEvent(e) { @@ -103,6 +109,9 @@ export class PeerServer { // Refresh room's federation federatedRoom.refreshFederation(); + + // Refresh federation peers + FederationPeers.refreshPeers(); } handleUserAddedEvent(e) { @@ -139,6 +148,9 @@ export class PeerServer { // Refresh room's federation federatedRoom.refreshFederation(); + + // Refresh federation peers + FederationPeers.refreshPeers(); } handleUserLeftEvent(e) { @@ -166,6 +178,9 @@ export class PeerServer { // Refresh room's federation federatedRoom.refreshFederation(); + + // Refresh federation peers + FederationPeers.refreshPeers(); } handleUserRemovedEvent(e) { @@ -197,6 +212,9 @@ export class PeerServer { // Refresh room's federation federatedRoom.refreshFederation(); + + // Refresh federation peers + FederationPeers.refreshPeers(); } handleUserMutedEvent(e) { diff --git a/app/federation/server/PeerServer/routes/events.js b/app/federation/server/PeerServer/routes/events.js index c68dfaabc73f2..78629bb876169 100644 --- a/app/federation/server/PeerServer/routes/events.js +++ b/app/federation/server/PeerServer/routes/events.js @@ -53,7 +53,7 @@ API.v1.addRoute( return API.v1.failure('Event was not sent'); } - Federation.peerServer.log(`Received event:${e.t}`); + Federation.peerServer.log(`Received event:${ e.t }`); try { switch (e.t) { @@ -103,7 +103,7 @@ API.v1.addRoute( Federation.peerServer.handleMessagesUnsetReactionEvent(e); break; default: - throw new Error(`Invalid event:${e.t}`); + throw new Error(`Invalid event:${ e.t }`); } Federation.peerServer.log('Success, responding...'); @@ -111,12 +111,14 @@ API.v1.addRoute( // Respond return API.v1.success(); } catch (err) { - Federation.peerServer.log( - `Error handling event:${e.t} - ${err.toString()}` + console.log(err); + + Federation.peerServer.error( + `Error handling event:${ e.t } - ${ err.toString() }` ); return API.v1.failure( - `Error handling event:${e.t} - ${err.toString()}`, + `Error handling event:${ e.t } - ${ err.toString() }`, err.error || 'unknown-error' ); } diff --git a/app/federation/server/federatedResources/FederatedRoom.js b/app/federation/server/federatedResources/FederatedRoom.js index e329ffe2f0522..1f47e1e37b69d 100644 --- a/app/federation/server/federatedResources/FederatedRoom.js +++ b/app/federation/server/federatedResources/FederatedRoom.js @@ -27,11 +27,9 @@ export class FederatedRoom extends FederatedResource { const { owner } = extras; if (owner) { - if (!owner && room.federation) { - this.federatedOwner = FederatedUser.loadByFederationId(localPeerIdentifier, room.federation.ownerId); - } else { - this.federatedOwner = FederatedUser.loadOrCreate(localPeerIdentifier, owner); - } + this.federatedOwner = FederatedUser.loadOrCreate(localPeerIdentifier, owner); + } else if (!owner && room.federation && room.federation.ownerId) { + this.federatedOwner = FederatedUser.loadByFederationId(localPeerIdentifier, room.federation.ownerId); } // Set base federation @@ -41,6 +39,9 @@ export class FederatedRoom extends FederatedResource { ownerId: this.federatedOwner ? this.federatedOwner.getFederationId() : null, }; + // Keep room's owner id + this.federationOwnerId = room.federation && room.federation.ownerId; + // Set room property this.room = room; } @@ -83,6 +84,12 @@ export class FederatedRoom extends FederatedResource { for (const user of users) { const federatedUser = FederatedUser.loadOrCreate(localPeerIdentifier, user); + // Set owner if it does not exist + if (!this.federatedOwner && user._id === this.federationOwnerId) { + this.federatedOwner = federatedUser; + this.room.federation.ownerId = this.federatedOwner.getFederationId(); + } + // Keep the federated user this.federatedUsers.push(federatedUser); } diff --git a/app/federation/server/index.js b/app/federation/server/index.js index c43b2f91b9d33..c8aa85901ea1d 100644 --- a/app/federation/server/index.js +++ b/app/federation/server/index.js @@ -14,6 +14,7 @@ import { PeerPinger } from './PeerPinger'; import { PeerServer } from './PeerServer'; import * as SettingsUpdater from './settingsUpdater'; +import './methods/dashboard'; import { addUser } from './methods/addUser'; import { searchUsers } from './methods/searchUsers'; import { ping } from './methods/ping'; @@ -66,11 +67,11 @@ peerClient.start(); // Start the server, setting up all the endpoints peerServer.start(); -// Start the pinger, to check the status of other peers +// Start the pinger, to check the status of all peers peerPinger.start(); const updateSettings = _.debounce( - Meteor.bindEnvironment(function () { + Meteor.bindEnvironment(function() { const _enabled = settings.get('FEDERATION_Enabled'); if (!_enabled) { diff --git a/app/federation/server/methods/addUser.js b/app/federation/server/methods/addUser.js index 45f83096d67be..024f31466a7dc 100644 --- a/app/federation/server/methods/addUser.js +++ b/app/federation/server/methods/addUser.js @@ -1,5 +1,5 @@ import { Meteor } from 'meteor/meteor'; -import { Users } from '../../../models'; +import { Users, FederationPeers } from '../../../models'; import { Federation } from '../'; import { logger } from '../logger'; @@ -32,6 +32,9 @@ export function addUser(identifier) { try { // Create the local user user = Users.create(localUser); + + // Refresh the peers list + FederationPeers.refreshPeers(); } catch (err) { // If the user already exists, return the existing user if (err.code === 11000) { diff --git a/app/federation/server/methods/dashboard.js b/app/federation/server/methods/dashboard.js new file mode 100644 index 0000000000000..a104e9c765388 --- /dev/null +++ b/app/federation/server/methods/dashboard.js @@ -0,0 +1,69 @@ +import { Meteor } from 'meteor/meteor'; +import moment from 'moment'; + +import { FederationEvents, FederationPeers, Users } from '../../../models'; +import { Federation } from '../'; + +export function federationGetOverviewData() { + if (!Meteor.userId()) { + throw new Meteor.Error('not-authorized'); + } + + const numberOfEvents = FederationEvents.find({ t: { $ne: 'png' } }).count(); + const numberOfFederatedUsers = Users.find({ federation: { $exists: true }, 'federation.peer': { $ne: Federation.localIdentifier } }).count(); + const numberOfActivePeers = FederationPeers.find({ active: true, peer: { $ne: Federation.localIdentifier } }).count(); + const numberOfInactivePeers = FederationPeers.find({ active: false, peer: { $ne: Federation.localIdentifier } }).count(); + + return { + data: [{ + title: 'Number_of_events', + value: numberOfEvents, + }, { + title: 'Number_of_federated_users', + value: numberOfFederatedUsers, + }, { + title: 'Number_of_active_peers', + value: numberOfActivePeers, + }, { + title: 'Number_of_inactive_peers', + value: numberOfInactivePeers, + }], + }; +} + +export function federationGetPeerStatuses() { + if (!Meteor.userId()) { + throw new Meteor.Error('not-authorized'); + } + + const peers = FederationPeers.find({ peer: { $ne: Federation.localIdentifier } }).fetch(); + + const peerStatuses = []; + + const stabilityLimit = moment().subtract(5, 'days'); + + for (const { peer, active, last_seen_at: lastSeenAt, last_failure_at: lastFailureAt } of peers) { + let status = 'failing'; + + if (active && lastFailureAt && moment(lastFailureAt).isAfter(stabilityLimit)) { + status = 'unstable'; + } else if (active) { + status = 'stable'; + } + + peerStatuses.push({ + peer, + status, + statusAt: active ? lastSeenAt : lastFailureAt, + }); + } + + return { + data: peerStatuses, + }; +} + +Meteor.methods({ + 'federation:getOverviewData': federationGetOverviewData, + 'federation:getPeerStatuses': federationGetPeerStatuses, +}); diff --git a/app/lib/server/functions/deleteUser.js b/app/lib/server/functions/deleteUser.js index a17f81731f899..94c629e5f170e 100644 --- a/app/lib/server/functions/deleteUser.js +++ b/app/lib/server/functions/deleteUser.js @@ -1,7 +1,7 @@ import { Meteor } from 'meteor/meteor'; import { TAPi18n } from 'meteor/tap:i18n'; import { FileUpload } from '../../../file-upload'; -import { Users, Subscriptions, Messages, Rooms, Integrations } from '../../../models'; +import { Users, Subscriptions, Messages, Rooms, Integrations, FederationPeers } from '../../../models'; import { hasRole, getUsersInRole } from '../../../authorization'; import { settings } from '../../../settings'; import { Notifications } from '../../../notifications'; @@ -96,4 +96,7 @@ export const deleteUser = function(userId) { } Users.removeById(userId); // Remove user from users database + + // Refresh the peers list + FederationPeers.refreshPeers(); }; diff --git a/app/models/server/models/FederationEvents.js b/app/models/server/models/FederationEvents.js index ff3def039cacc..472aee6fb46c3 100644 --- a/app/models/server/models/FederationEvents.js +++ b/app/models/server/models/FederationEvents.js @@ -45,13 +45,14 @@ class FederationEventsModel extends Base { ); } - createEvent(type, payload, peer) { + createEvent(type, payload, peer, options) { const record = { t: type, ts: new Date(), fulfilled: false, payload, peer, + options, }; record._id = this.insert(record); @@ -63,11 +64,11 @@ class FederationEventsModel extends Base { return record; } - createEventForPeers(type, payload, peers) { + createEventForPeers(type, payload, peers, options = {}) { const records = []; for (const peer of peers) { - const record = this.createEvent(type, payload, peer); + const record = this.createEvent(type, payload, peer, options); records.push(record); } @@ -77,7 +78,7 @@ class FederationEventsModel extends Base { // Create a `ping(png)` event ping(peers) { - return this.createEventForPeers('png', {}, peers); + return this.createEventForPeers('png', {}, peers, { retry: { total: 1 } }); } // Create a `directRoomCreated(drc)` event diff --git a/app/models/server/models/FederationPeers.js b/app/models/server/models/FederationPeers.js index 4937dfa38aff4..b558660d10310 100644 --- a/app/models/server/models/FederationPeers.js +++ b/app/models/server/models/FederationPeers.js @@ -1,26 +1,57 @@ +import { Meteor } from 'meteor/meteor'; + import { Base } from './_Base'; +import { Users } from '../'; class FederationPeersModel extends Base { - constructor() { - super('federation_peers'); - } - - updateStatuses(seenPeers) { - for (const peer of Object.keys(seenPeers)) { - const seen = seenPeers[peer]; - - const updateQuery = {}; - - if (seen) { - updateQuery.active = true; - updateQuery.last_seen_at = new Date(); - } else { - updateQuery.active = false; - } - - this.update({ peer }, { $set: updateQuery }); - } - } + constructor() { + super('federation_peers'); + } + + refreshPeers() { + const collectionObj = this.model.rawCollection(); + const findAndModify = Meteor.wrapAsync(collectionObj.findAndModify, collectionObj); + + const users = Users.find({ federation: { $exists: true } }).fetch(); + + const peers = [...new Set(users.map((u) => u.federation.peer))]; + + for (const peer of peers) { + findAndModify( + { peer }, + [], + { + $setOnInsert: { + active: false, + peer, + last_seen_at: null, + last_failure_at: null, + }, + }, + { upsert: true } + ); + } + + this.remove({ peer: { $nin: peers } }); + } + + updateStatuses(seenPeers) { + for (const peer of Object.keys(seenPeers)) { + const seen = seenPeers[peer]; + + const updateQuery = {}; + + if (seen) { + updateQuery.active = true; + updateQuery.last_seen_at = new Date(); + } else { + updateQuery.active = false; + updateQuery.last_failure_at = new Date(); + } + + this.update({ peer }, { $set: updateQuery }); + } + } } export const FederationPeers = new FederationPeersModel(); diff --git a/packages/rocketchat-i18n/i18n/en.i18n.json b/packages/rocketchat-i18n/i18n/en.i18n.json index 46632f82d7b2d..779d1b108ee68 100644 --- a/packages/rocketchat-i18n/i18n/en.i18n.json +++ b/packages/rocketchat-i18n/i18n/en.i18n.json @@ -1292,6 +1292,7 @@ "Favorites": "Favorites", "Feature_Depends_on_Livechat_Visitor_navigation_as_a_message_to_be_enabled": "This feature depends on \"Send Visitor Navigation History as a Message\" to be enabled.", "Features_Enabled": "Features Enabled", + "Federation_Dashboard": "Federation Dashboard", "FEDERATION_Discovery_Method": "Discovery Method", "FEDERATION_Discovery_Method_Description": "You can use the hub or a SRV and a TXT entry on your DNS records.", "FEDERATION_Domain": "Domain", @@ -2138,6 +2139,10 @@ "Notify_active_in_this_room": "Notify active users in this room", "Notify_all_in_this_room": "Notify all in this room", "Num_Agents": "# Agents", + "Number_of_active_peers": "Number of active peers", + "Number_of_events": "Number of events", + "Number_of_federated_users": "Number of federated users", + "Number_of_inactive_peers": "Number of inactive peers", "Number_of_messages": "Number of messages", "OAuth Apps": "OAuth Apps", "OAuth_Application": "OAuth Application", @@ -3199,4 +3204,4 @@ "Your_question": "Your question", "Your_server_link": "Your server link", "Your_workspace_is_ready": "Your workspace is ready to use 🎉" -} +} \ No newline at end of file diff --git a/server/startup/migrations/v141.js b/server/startup/migrations/v141.js index ff5acfd1ac6cb..52aa3d490889a 100644 --- a/server/startup/migrations/v141.js +++ b/server/startup/migrations/v141.js @@ -3,18 +3,18 @@ import { Migrations } from '../../../app/migrations/server'; import { Users, FederationPeers } from '../../../app/models/server'; Migrations.add({ - version: 141, - up() { - const users = Users.find({ federation: { $exists: true } }).fetch(); + version: 141, + up() { + const users = Users.find({ federation: { $exists: true } }).fetch(); - let peers = [...new Set(users.map(u => u.federation.peer))]; + let peers = [...new Set(users.map((u) => u.federation.peer))]; - peers = peers.map(peer => ({ - active: false, - peer, - last_seen_at: null, - })); + peers = peers.map((peer) => ({ + active: false, + peer, + last_seen_at: null, + })); - FederationPeers.model.rawCollection().insertMany(peers); - }, + FederationPeers.model.rawCollection().insertMany(peers); + }, });