MM-61991 Show server hostname in about modal (#29413)

This introduces a new entry in the `Main Menu -> About` modal with the hostname of the currently connected websocket. This will be used to aid debugging issues in clustered environments by showing which node in the cluster is servicing requests for a particular websocket.

This information is only visible in self-managed instances. It will not be visible on cloud instances.
This commit is contained in:
David Krauser 2024-12-06 10:39:36 -05:00 committed by GitHub
parent 59b8532a89
commit 3224e0d3a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 121 additions and 3 deletions

View file

@ -11,6 +11,7 @@ import (
"fmt"
"net"
"net/http"
"os"
"slices"
"strconv"
"strings"
@ -776,6 +777,15 @@ func (wc *WebConn) createHelloMessage() *model.WebSocketEvent {
wc.Platform.ClientConfigHash(),
ee))
msg.Add("connection_id", wc.connectionID.Load())
hostname, err := os.Hostname()
if err != nil {
wc.Platform.logger.Error("Could not get hostname", mlog.Err(err))
// return without the hostname in the message
return msg
}
msg.Add("server_hostname", hostname)
return msg
}

View file

@ -1313,6 +1313,7 @@ export function handleStatusChangedEvent(msg) {
function handleHelloEvent(msg) {
dispatch(setServerVersion(msg.data.server_version));
dispatch(setConnectionId(msg.data.connection_id));
dispatch(setServerHostname(msg.data.server_hostname));
}
function handleReactionAddedEvent(msg) {
@ -1333,6 +1334,13 @@ function setConnectionId(connectionId) {
};
}
function setServerHostname(serverHostname) {
return {
type: GeneralTypes.SET_SERVER_HOSTNAME,
payload: {serverHostname},
};
}
function handleAddEmoji(msg) {
const data = JSON.parse(msg.data.emoji);

View file

@ -25,11 +25,19 @@ describe('components/AboutBuildModal', () => {
let config: Partial<ClientConfig> = {};
let license: ClientLicense = {};
let socketStatus = {
connected: false,
serverHostname: '',
};
afterEach(() => {
global.Date = RealDate;
config = {};
license = {};
socketStatus = {
connected: false,
serverHostname: '',
};
});
beforeEach(() => {
@ -51,10 +59,14 @@ describe('components/AboutBuildModal', () => {
IsLicensed: 'true',
Company: 'Mattermost Inc',
};
socketStatus = {
connected: true,
serverHostname: 'mock.localhost',
};
});
test('should match snapshot for enterprise edition', () => {
renderAboutBuildModal({config, license});
renderAboutBuildModal({config, license, socketStatus});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: 3.6.0');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
expect(screen.getByTestId('aboutModalBuildNumber')).toHaveTextContent('Build Number: 123456');
@ -62,6 +74,7 @@ describe('components/AboutBuildModal', () => {
expect(screen.getByText('Modern communication from behind your firewall.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com'})).toHaveAttribute('href', 'https://mattermost.com/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.getByText('EE Build Hash: 0123456789abcdef', {exact: false})).toBeInTheDocument();
expect(screen.queryByText('Hostname: mock.localhost', {exact: false})).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
@ -75,7 +88,7 @@ describe('components/AboutBuildModal', () => {
BuildHashEnterprise: '',
};
renderAboutBuildModal({config: teamConfig, license: {}});
renderAboutBuildModal({config: teamConfig, license: {}, socketStatus: {connected: false}});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: 3.6.0');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
expect(screen.getByTestId('aboutModalBuildNumber')).toHaveTextContent('Build Number: 123456');
@ -83,6 +96,7 @@ describe('components/AboutBuildModal', () => {
expect(screen.getByText('All your team communication in one place, instantly searchable and accessible anywhere.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com/community/'})).toHaveAttribute('href', 'https://mattermost.com/community/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.queryByText('EE Build Hash: 0123456789abcdef')).not.toBeInTheDocument();
expect(screen.queryByText('Hostname: disconnected', {exact: false})).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
@ -123,7 +137,7 @@ describe('components/AboutBuildModal', () => {
BuildNumber: 'dev',
};
renderAboutBuildModal({config: sameBuildConfig, license: {}});
renderAboutBuildModal({config: sameBuildConfig, license: {}, socketStatus: {connected: true}});
expect(screen.getByTestId('aboutModalVersion')).toHaveTextContent('Mattermost Version: dev');
expect(screen.getByTestId('aboutModalDBVersionString')).toHaveTextContent('Database Schema Version: 77');
@ -132,6 +146,7 @@ describe('components/AboutBuildModal', () => {
expect(screen.getByText('All your team communication in one place, instantly searchable and accessible anywhere.')).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'mattermost.com/community/'})).toHaveAttribute('href', 'https://mattermost.com/community/?utm_source=mattermost&utm_medium=in-product&utm_content=about_build_modal&uid=&sid=');
expect(screen.queryByText('EE Build Hash: 0123456789abcdef')).not.toBeInTheDocument();
expect(screen.queryByText('Hostname: server did not provide hostname', {exact: false})).toBeInTheDocument();
expect(screen.getByRole('link', {name: 'server'})).toHaveAttribute('href', 'https://github.com/mattermost/mattermost-server/blob/master/NOTICE.txt');
expect(screen.getByRole('link', {name: 'desktop'})).toHaveAttribute('href', 'https://github.com/mattermost/desktop/blob/master/NOTICE.txt');
@ -158,6 +173,7 @@ describe('components/AboutBuildModal', () => {
<AboutBuildModal
config={config}
license={license}
socketStatus={socketStatus}
onExited={onExited}
/>,
state,
@ -185,6 +201,7 @@ describe('components/AboutBuildModal', () => {
<AboutBuildModal
config={config}
license={license}
socketStatus={socketStatus}
onExited={jest.fn()}
/>,
state,
@ -207,6 +224,7 @@ describe('components/AboutBuildModal', () => {
onExited,
config,
license,
socketStatus,
...props,
};

View file

@ -15,6 +15,11 @@ import {AboutLinks} from 'utils/constants';
import AboutBuildModalCloud from './about_build_modal_cloud/about_build_modal_cloud';
type SocketStatus = {
connected: boolean;
serverHostname: string | undefined;
}
type Props = {
/**
@ -31,6 +36,8 @@ type Props = {
* Global license object
*/
license: ClientLicense;
socketStatus: SocketStatus;
};
type State = {
@ -182,6 +189,48 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
const mmversion: string | undefined = config.BuildNumber === 'dev' ? config.BuildNumber : config.Version;
let serverHostname;
if (!this.props.socketStatus.connected) {
serverHostname = (
<div>
<FormattedMessage
id='about.serverHostname'
defaultMessage='Hostname:'
/>
<Nbsp/>
<FormattedMessage
id='about.serverDisconnected'
defaultMessage='disconnected'
/>
</div>
);
} else if (this.props.socketStatus.serverHostname) {
serverHostname = (
<div>
<FormattedMessage
id='about.serverHostname'
defaultMessage='Hostname:'
/>
<Nbsp/>
{this.props.socketStatus.serverHostname}
</div>
);
} else {
serverHostname = (
<div>
<FormattedMessage
id='about.serverHostname'
defaultMessage='Hostname:'
/>
<Nbsp/>
<FormattedMessage
id='about.serverUnknown'
defaultMessage='server did not provide hostname'
/>
</div>
);
}
return (
<Modal
dialogClassName='a11y__modal about-modal'
@ -246,6 +295,7 @@ export default class AboutBuildModal extends React.PureComponent<Props, State> {
/>
{'\u00a0' + config.SQLDriverName}
</div>
{serverHostname}
</div>
{licensee}
</div>

View file

@ -5,6 +5,8 @@ import {connect} from 'react-redux';
import {getConfig, getLicense} from 'mattermost-redux/selectors/entities/general';
import {getSocketStatus} from 'selectors/views/websocket';
import type {GlobalState} from 'types/store';
import AboutBuildModal from './about_build_modal';
@ -13,6 +15,7 @@ function mapStateToProps(state: GlobalState) {
return {
config: getConfig(state),
license: getLicense(state),
socketStatus: getSocketStatus(state),
};
}

View file

@ -116,6 +116,7 @@ exports[`PostBodyAdditionalContent with a normal link Should render the plugin c
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverHostname": "",
"serverSequence": 0,
}
}
@ -163,6 +164,7 @@ exports[`PostBodyAdditionalContent with a normal link Should render the plugin c
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverHostname": "",
"serverSequence": 0,
}
}

View file

@ -41,6 +41,7 @@ describe('ProductNoticesModal', () => {
connectionId: '',
lastConnectAt: 1599760193593,
lastDisconnectAt: 0,
serverHostname: '',
},
actions: {
getInProductNotices: jest.fn().mockResolvedValue({data: noticesData}),

View file

@ -14,6 +14,9 @@
"about.licensed": "Licensed to:",
"about.notice": "Mattermost is made possible by the open source software used in our <linkServer>server</linkServer>, <linkDesktop>desktop</linkDesktop> and <linkMobile>mobile</linkMobile> apps.",
"about.privacy": "Privacy Policy",
"about.serverDisconnected": "disconnected",
"about.serverHostname": "Hostname:",
"about.serverUnknown": "server did not provide hostname",
"about.teamEditionLearn": "Join the Mattermost community at ",
"about.teamEditionSt": "All your team communication in one place, instantly searchable and accessible anywhere.",
"about.teamEditiont0": "Team Edition",

View file

@ -21,6 +21,7 @@ export default keyMirror({
WEBSOCKET_FAILURE: null,
WEBSOCKET_CLOSED: null,
SET_CONNECTION_ID: null,
SET_SERVER_HOSTNAME: null,
SET_CONFIG_AND_LICENSE: null,

View file

@ -11,6 +11,7 @@ function getInitialState() {
lastConnectAt: 0,
lastDisconnectAt: 0,
connectionId: '',
serverHostname: '',
};
}
@ -26,6 +27,7 @@ export default function reducer(state = getInitialState(), action: AnyAction) {
...state,
connected: false,
lastDisconnectAt: action.timestamp,
serverHostname: '',
};
}
@ -44,5 +46,12 @@ export default function reducer(state = getInitialState(), action: AnyAction) {
};
}
if (action.type === GeneralTypes.SET_SERVER_HOSTNAME) {
return {
...state,
serverHostname: action.payload.serverHostname,
};
}
return state;
}

View file

@ -318,6 +318,7 @@ const state: GlobalState = {
lastConnectAt: 0,
lastDisconnectAt: 0,
connectionId: '',
serverHostname: '',
},
};
export default state;

View file

@ -124,6 +124,7 @@ exports[`plugins/Pluggable should match snapshot with extended component 1`] = `
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverHostname": "",
"serverSequence": 0,
}
}
@ -262,6 +263,7 @@ exports[`plugins/Pluggable should match snapshot with extended component with pl
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverHostname": "",
"serverSequence": 0,
}
}
@ -534,6 +536,7 @@ exports[`plugins/Pluggable should match snapshot with null pluggableId 1`] = `
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverHostname": "",
"serverSequence": 0,
}
}
@ -673,6 +676,7 @@ exports[`plugins/Pluggable should match snapshot with valid pluggableId 1`] = `
"reconnectListeners": Set {},
"responseCallbacks": Object {},
"responseSequence": 1,
"serverHostname": "",
"serverSequence": 0,
}
}

View file

@ -1903,6 +1903,7 @@
.about-modal__content {
display: block;
overflow-wrap: anywhere;
}
.about-modal__hash {

View file

@ -57,6 +57,7 @@
display: flex;
flex-direction: row;
padding: 1em 0 3em;
overflow-wrap: anywhere;
}
.about-modal__copyright {

View file

@ -68,6 +68,7 @@ export default class WebSocketClient {
private closeListeners = new Set<CloseListener>();
private connectionId: string | null;
private serverHostname: string | null;
private postedAck: boolean;
constructor() {
@ -78,6 +79,7 @@ export default class WebSocketClient {
this.connectFailCount = 0;
this.responseCallbacks = {};
this.connectionId = '';
this.serverHostname = '';
this.postedAck = false;
}
@ -210,6 +212,9 @@ export default class WebSocketClient {
// If it's a fresh connection, we have to set the connectionId regardless.
// And if it's an existing connection, setting it again is harmless, and keeps the code simple.
this.connectionId = msg.data.connection_id;
// Also update the server hostname
this.serverHostname = msg.data.server_hostname;
}
// Now we check for sequence number, and if it does not match,

View file

@ -94,5 +94,6 @@ export type GlobalState = {
lastConnectAt: number;
lastDisconnectAt: number;
connectionId: string;
serverHostname: string;
};
};