From 59d971dc751b0414c5b38c9df4b552e45f5641be Mon Sep 17 00:00:00 2001 From: Corey Hulen Date: Thu, 4 Aug 2016 09:25:37 -0800 Subject: PLT-2899 adding clustering of app servers (#3682) * PLT-2899 adding clustering of app servers * PLT-2899 base framework * PLT-2899 HA backend * PLT-2899 Fixing config file * PLT-2899 adding config syncing * PLT-2899 set System console to readonly when clustering enabled. * PLT-2899 Fixing publish API * PLT-2899 fixing strings --- webapp/client/client.jsx | 16 ++ webapp/components/admin_console/admin_sidebar.jsx | 16 ++ .../components/admin_console/cluster_settings.jsx | 188 +++++++++++++++++++++ webapp/components/admin_console/cluster_table.jsx | 179 ++++++++++++++++++++ .../admin_console/cluster_table_container.jsx | 71 ++++++++ webapp/i18n/en.json | 19 +++ webapp/images/status_green.png | Bin 0 -> 471 bytes webapp/images/status_red.png | Bin 0 -> 468 bytes webapp/routes/route_admin_console.jsx | 5 + webapp/sass/routes/_admin-console.scss | 13 ++ webapp/sass/routes/_compliance.scss | 3 +- webapp/stores/admin_store.jsx | 10 ++ webapp/utils/async_client.jsx | 3 +- 13 files changed, 521 insertions(+), 2 deletions(-) create mode 100644 webapp/components/admin_console/cluster_settings.jsx create mode 100644 webapp/components/admin_console/cluster_table.jsx create mode 100644 webapp/components/admin_console/cluster_table_container.jsx create mode 100644 webapp/images/status_green.png create mode 100644 webapp/images/status_red.png (limited to 'webapp') diff --git a/webapp/client/client.jsx b/webapp/client/client.jsx index 598871002..28d121011 100644 --- a/webapp/client/client.jsx +++ b/webapp/client/client.jsx @@ -4,6 +4,7 @@ import request from 'superagent'; const HEADER_X_VERSION_ID = 'x-version-id'; +const HEADER_X_CLUSTER_ID = 'x-cluster-id'; const HEADER_TOKEN = 'token'; const HEADER_BEARER = 'BEARER'; const HEADER_AUTH = 'Authorization'; @@ -12,6 +13,7 @@ export default class Client { constructor() { this.teamId = ''; this.serverVersion = ''; + this.clusterId = ''; this.logToConsole = false; this.useToken = false; this.token = ''; @@ -152,6 +154,11 @@ export default class Client { if (res.header[HEADER_X_VERSION_ID]) { this.serverVersion = res.header[HEADER_X_VERSION_ID]; } + + this.clusterId = res.header[HEADER_X_CLUSTER_ID]; + if (res.header[HEADER_X_CLUSTER_ID]) { + this.clusterId = res.header[HEADER_X_CLUSTER_ID]; + } } if (err) { @@ -295,6 +302,15 @@ export default class Client { end(this.handleResponse.bind(this, 'getLogs', success, error)); } + getClusterStatus(success, error) { + return request. + get(`${this.getAdminRoute()}/cluster_status`). + set(this.defaultHeaders). + type('application/json'). + accept('application/json'). + end(this.handleResponse.bind(this, 'getClusterStatus', success, error)); + } + getServerAudits(success, error) { return request. get(`${this.getAdminRoute()}/audits`). diff --git a/webapp/components/admin_console/admin_sidebar.jsx b/webapp/components/admin_console/admin_sidebar.jsx index 569885f98..2e7915baf 100644 --- a/webapp/components/admin_console/admin_sidebar.jsx +++ b/webapp/components/admin_console/admin_sidebar.jsx @@ -178,6 +178,7 @@ export default class AdminSidebar extends React.Component { let oauthSettings = null; let ldapSettings = null; let samlSettings = null; + let clusterSettings = null; let complianceSettings = null; let license = null; @@ -213,6 +214,20 @@ export default class AdminSidebar extends React.Component { ); } + if (global.window.mm_license.Cluster === 'true') { + clusterSettings = ( + + } + /> + ); + } + if (global.window.mm_license.SAML === 'true') { samlSettings = ( } /> + {clusterSettings} {this.renderTeams()} diff --git a/webapp/components/admin_console/cluster_settings.jsx b/webapp/components/admin_console/cluster_settings.jsx new file mode 100644 index 000000000..9f392ea0a --- /dev/null +++ b/webapp/components/admin_console/cluster_settings.jsx @@ -0,0 +1,188 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import AdminSettings from './admin_settings.jsx'; +import BooleanSetting from './boolean_setting.jsx'; +import TextSetting from './text_setting.jsx'; + +import {FormattedMessage, FormattedHTMLMessage} from 'react-intl'; +import SettingsGroup from './settings_group.jsx'; +import ClusterTableContainer from './cluster_table_container.jsx'; + +import AdminStore from 'stores/admin_store.jsx'; +import * as Utils from 'utils/utils.jsx'; + +export default class ClusterSettings extends AdminSettings { + constructor(props) { + super(props); + + this.getConfigFromState = this.getConfigFromState.bind(this); + this.renderSettings = this.renderSettings.bind(this); + } + + getConfigFromState(config) { + config.ClusterSettings.Enable = this.state.enable; + config.ClusterSettings.InterNodeListenAddress = this.state.interNodeListenAddress; + + config.ClusterSettings.InterNodeUrls = this.state.interNodeUrls.split(','); + config.ClusterSettings.InterNodeUrls = config.ClusterSettings.InterNodeUrls.map((url) => { + return url.trim(); + }); + + if (config.ClusterSettings.InterNodeUrls.length === 1 && config.ClusterSettings.InterNodeUrls[0] === '') { + config.ClusterSettings.InterNodeUrls = []; + } + + return config; + } + + getStateFromConfig(config) { + const settings = config.ClusterSettings; + + return { + enable: settings.Enable, + interNodeUrls: settings.InterNodeUrls.join(', '), + interNodeListenAddress: settings.InterNodeListenAddress, + showWarning: false + }; + } + + renderTitle() { + return ( +

+ +

+ ); + } + + overrideHandleChange = (id, value) => { + this.setState({ + showWarning: true + }); + + this.handleChange(id, value); + } + + renderSettings() { + const licenseEnabled = global.window.mm_license.IsLicensed === 'true' && global.window.mm_license.Cluster === 'true'; + if (!licenseEnabled) { + return null; + } + + var configLoadedFromCluster = null; + + if (AdminStore.getClusterId()) { + configLoadedFromCluster = ( +
+ + +
+ ); + } + + var warning = null; + if (this.state.showWarning) { + warning = ( +
+ + +
+ ); + } + + var clusterTableContainer = null; + if (this.state.enable) { + clusterTableContainer = (); + } + + return ( + + {configLoadedFromCluster} + {clusterTableContainer} +

+ +

+ {warning} + + } + helpText={ + + } + value={this.state.enable} + onChange={this.overrideHandleChange} + disabled={true} + /> + + } + placeholder={Utils.localizeMessage('admin.cluster.interNodeListenAddressEx', 'Ex ":8075"')} + helpText={ + + } + value={this.state.interNodeListenAddress} + onChange={this.overrideHandleChange} + disabled={true} + /> + + } + placeholder={Utils.localizeMessage('admin.cluster.interNodeUrlsEx', 'Ex "http://10.10.10.30, http://10.10.10.31"')} + helpText={ + + } + value={this.state.interNodeUrls} + onChange={this.overrideHandleChange} + disabled={true} + /> +
+ ); + } +} \ No newline at end of file diff --git a/webapp/components/admin_console/cluster_table.jsx b/webapp/components/admin_console/cluster_table.jsx new file mode 100644 index 000000000..c8a98fd76 --- /dev/null +++ b/webapp/components/admin_console/cluster_table.jsx @@ -0,0 +1,179 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; + +import {FormattedMessage} from 'react-intl'; +import * as Utils from 'utils/utils.jsx'; + +import statusGreen from 'images/status_green.png'; +import statusRed from 'images/status_red.png'; + +export default class ClusterTable extends React.Component { + static propTypes = { + clusterInfos: React.PropTypes.array.isRequired, + reload: React.PropTypes.func.isRequired + } + + render() { + var versionMismatch = ( + + ); + + var configMismatch = ( + + ); + + var version = ''; + var configHash = ''; + + if (this.props.clusterInfos.length) { + version = this.props.clusterInfos[0].version; + configHash = this.props.clusterInfos[0].config_hash; + } + + this.props.clusterInfos.map((clusterInfo) => { + if (clusterInfo.version !== version) { + versionMismatch = ( + + ); + } + + if (clusterInfo.config_hash !== configHash) { + configMismatch = ( + + ); + } + + return null; + }); + + var items = this.props.clusterInfos.map((clusterInfo) => { + var status = null; + + if (clusterInfo.hostname === '') { + clusterInfo.hostname = Utils.localizeMessage('admin.cluster.unknown', 'unknown'); + } + + if (clusterInfo.version === '') { + clusterInfo.version = Utils.localizeMessage('admin.cluster.unknown', 'unknown'); + } + + if (clusterInfo.config_hash === '') { + clusterInfo.config_hash = Utils.localizeMessage('admin.cluster.unknown', 'unknown'); + } + + if (clusterInfo.id === '') { + clusterInfo.id = Utils.localizeMessage('admin.cluster.unknown', 'unknown'); + } + + if (clusterInfo.is_alive) { + status = ( + + ); + } else { + status = ( + + ); + } + + return ( + + {status} + {clusterInfo.hostname} + {versionMismatch} {clusterInfo.version} +
{configMismatch} {clusterInfo.config_hash}
+ {clusterInfo.internode_url} +
{clusterInfo.id}
+ + ); + }); + + return ( +
+
+ +
+ + + + + + + + + + + + + {items} + +
+ + + + + + + + + + + +
+
+ ); + } +} \ No newline at end of file diff --git a/webapp/components/admin_console/cluster_table_container.jsx b/webapp/components/admin_console/cluster_table_container.jsx new file mode 100644 index 000000000..5dad56469 --- /dev/null +++ b/webapp/components/admin_console/cluster_table_container.jsx @@ -0,0 +1,71 @@ +// Copyright (c) 2016 Mattermost, Inc. All Rights Reserved. +// See License.txt for license information. + +import React from 'react'; +import ClusterTable from './cluster_table.jsx'; +import LoadingScreen from '../loading_screen.jsx'; +import Client from 'client/web_client.jsx'; +import * as AsyncClient from 'utils/async_client.jsx'; + +export default class ClusterTableContainer extends React.Component { + constructor(props) { + super(props); + + this.interval = null; + + this.state = { + clusterInfos: null + }; + } + + load = () => { + Client.getClusterStatus( + (data) => { + this.setState({ + clusterInfos: data + }); + }, + (err) => { + AsyncClient.dispatchError(err, 'getClusterStatus'); + } + ); + } + + componentWillMount() { + this.load(); + + // reload the cluster status every 15 seconds + this.interval = setInterval(this.load, 15000); + } + + componentWillUnmount() { + if (this.interval) { + clearInterval(this.interval); + } + } + + reload = (e) => { + if (e) { + e.preventDefault(); + } + + this.setState({ + clusterInfos: null + }); + + this.load(); + } + + render() { + if (this.state.clusterInfos == null) { + return (); + } + + return ( + + ); + } +} \ No newline at end of file diff --git a/webapp/i18n/en.json b/webapp/i18n/en.json index 8a34a8b1d..f53d8d005 100644 --- a/webapp/i18n/en.json +++ b/webapp/i18n/en.json @@ -569,6 +569,24 @@ "admin.saml.usernameAttrTitle": "Username Attribute:", "admin.saml.verifyDescription": "When true, Mattermost verifies that the signature sent from the SAML Response matches the Service Provider Login URL", "admin.saml.verifyTitle": "Verify Signature:", + "admin.cluster.loadedFrom": "This configuration file was loaded from Node ID {clusterId}. Please see the Troubleshooting Guide in our documentation if you are accessing the System Console through a load balancer and experiencing issues.", + "admin.cluster.should_not_change": "WARNING: These settings may not sync with the other servers in the cluster. High Availability inter-node communication will not start until you modify the config.json to be identical on all servers and restart Mattermost. Please see the documentation on how to add or remove a server from the cluster. If you are accessing the System Console through a load balancer and experiencing issues, please see the Troubleshooting Guide in our documentation.", + "admin.cluster.noteDescription": "Changing properties in this section will require a server restart before taking effect. When High Availability mode is enabled, the System Console is set to read-only and can only be changed from the configuration file.", + "admin.cluster.enableTitle": "Enable High Availability Mode:", + "admin.cluster.enableDescription": "When true, Mattermost will run in High Availability mode. Please see documentation to learn more about configuring High Availability for Mattermost.", + "admin.cluster.interNodeListenAddressTitle": "Inter-Node Listen Address:", + "admin.cluster.interNodeListenAddressEx": "Ex \":8075\"", + "admin.cluster.interNodeListenAddressDesc": "The address the server will listen on for communicating with other servers.", + "admin.cluster.interNodeUrlsTitle": "Inter-Node URLs:", + "admin.cluster.interNodeUrlsEx": "Ex \"http://10.10.10.30, http://10.10.10.31\"", + "admin.cluster.interNodeUrlsDesc": "The internal/private URLs of all the Mattermost servers separated by commas.", + "admin.cluster.status_table.reload": " Reload Cluster Status", + "admin.cluster.status_table.status": "Status", + "admin.cluster.status_table.hostname": "Hostname", + "admin.cluster.status_table.version": "Version", + "admin.cluster.status_table.config_hash": "Config File MD5", + "admin.cluster.status_table.url": "Inter-Node URL", + "admin.cluster.status_table.id": "Node ID", "admin.save": "Save", "admin.saving": "Saving Config...", "admin.security.connection": "Connections", @@ -668,6 +686,7 @@ "admin.sidebar.reports": "REPORTING", "admin.sidebar.rmTeamSidebar": "Remove team from sidebar menu", "admin.sidebar.saml": "SAML", + "admin.sidebar.cluster": "High Availability", "admin.sidebar.security": "Security", "admin.sidebar.sessions": "Sessions", "admin.sidebar.settings": "SETTINGS", diff --git a/webapp/images/status_green.png b/webapp/images/status_green.png new file mode 100644 index 000000000..90ae6ce9d Binary files /dev/null and b/webapp/images/status_green.png differ diff --git a/webapp/images/status_red.png b/webapp/images/status_red.png new file mode 100644 index 000000000..e40b8b209 Binary files /dev/null and b/webapp/images/status_red.png differ diff --git a/webapp/routes/route_admin_console.jsx b/webapp/routes/route_admin_console.jsx index 2db29e83b..f20c5c379 100644 --- a/webapp/routes/route_admin_console.jsx +++ b/webapp/routes/route_admin_console.jsx @@ -17,6 +17,7 @@ import GitLabSettings from 'components/admin_console/gitlab_settings.jsx'; import OAuthSettings from 'components/admin_console/oauth_settings.jsx'; import LdapSettings from 'components/admin_console/ldap_settings.jsx'; import SamlSettings from 'components/admin_console/saml_settings.jsx'; +import ClusterSettings from 'components/admin_console/cluster_settings.jsx'; import SignupSettings from 'components/admin_console/signup_settings.jsx'; import PasswordSettings from 'components/admin_console/password_settings.jsx'; import PublicLinkSettings from 'components/admin_console/public_link_settings.jsx'; @@ -191,6 +192,10 @@ export default ( path='developer' component={DeveloperSettings} /> + { break; case ActionTypes.RECEIVED_CONFIG: AdminStore.saveConfig(action.config); + AdminStore.saveClusterId(action.clusterId); AdminStore.emitConfigChange(); break; case ActionTypes.RECEIVED_ALL_TEAMS: diff --git a/webapp/utils/async_client.jsx b/webapp/utils/async_client.jsx index 196ced5d9..babfefb6d 100644 --- a/webapp/utils/async_client.jsx +++ b/webapp/utils/async_client.jsx @@ -453,7 +453,8 @@ export function getConfig(success, error) { AppDispatcher.handleServerAction({ type: ActionTypes.RECEIVED_CONFIG, - config: data + config: data, + clusterId: Client.clusterId }); if (success) { -- cgit v1.2.3-1-g7c22