console: require async globals before render; track runtime errors (#4449)

This commit is contained in:
Rishichandra Wawhal 2020-05-29 15:15:33 +05:30 committed by GitHub
parent d2aac3732a
commit ea2f1679eb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 326 additions and 171 deletions

View File

@ -42,6 +42,9 @@ const globals = {
featuresCompatibility: window.__env.serverVersion
? getFeaturesCompatibility(window.__env.serverVersion)
: null,
cliUUID: window.__env.cliUUID,
hasuraUUID: '',
telemetryNotificationShown: '',
isProduction,
};

View File

@ -18,43 +18,7 @@ import getRoutes from './routes';
import reducer from './reducer';
import globals from './Globals';
import Endpoints from './Endpoints';
import { filterEventsBlockList, sanitiseUrl } from './telemetryFilter';
import { RUN_TIME_ERROR } from './components/Main/Actions';
/** telemetry **/
let analyticsConnection;
const analyticsUrl = Endpoints.telemetryServer;
const { consoleMode, enableTelemetry, cliUUID } = window.__env;
const telemetryEnabled =
enableTelemetry !== undefined && enableTelemetry === true;
if (telemetryEnabled) {
try {
analyticsConnection = new WebSocket(analyticsUrl);
} catch (error) {
console.error(error);
}
}
const onError = error => {
console.error('WebSocket Error for Events' + error);
};
// eslint-disable-next-line no-unused-vars
const onClose = () => {
try {
analyticsConnection = new WebSocket(analyticsUrl);
} catch (error) {
console.error(error);
}
analyticsConnection.onclose = onClose();
analyticsConnection.onerror = onError();
};
import { trackReduxAction } from './telemetry';
function analyticsLogger({ getState }) {
return next => action => {
@ -62,53 +26,9 @@ function analyticsLogger({ getState }) {
const returnValue = next(action);
// check if analytics tracking is enabled
if (telemetryEnabled) {
const actionType = action.type;
// filter events
if (!filterEventsBlockList.includes(actionType)) {
// When the connection is open, send data to the server
if (
analyticsConnection &&
analyticsConnection.readyState === analyticsConnection.OPEN
) {
const serverVersion = getState().main.serverVersion;
const url = sanitiseUrl(window.location.pathname);
const reqBody = {
server_version: serverVersion,
event_type: actionType,
url,
console_mode: consoleMode,
cli_uuid: cliUUID,
server_uuid: getState().telemetry.hasura_uuid,
};
const isLocationType = actionType === '@@router/LOCATION_CHANGE';
if (isLocationType) {
// capture page views
const payload = action.payload;
reqBody.url = sanitiseUrl(payload.pathname);
if (globals.enableTelemetry) {
trackReduxAction(action, getState);
}
const isErrorType = actionType === RUN_TIME_ERROR;
if (isErrorType) {
reqBody.data = action.data;
}
// Send the data
analyticsConnection.send(
JSON.stringify({ data: reqBody, topic: globals.telemetryTopic })
);
// check for possible error events and store more data?
} else {
// retry websocket connection
// analyticsConnection = new WebSocket(analyticsUrl);
}
}
}
// This will likely be the action itself, unless
// a middleware further in chain changed it.
return returnValue;

View File

@ -1,5 +1,7 @@
import defaultState from './State';
import Notifications from 'react-notification-system-redux';
import { loadConsoleOpts } from '../../telemetry/Actions';
import { fetchServerConfig } from '../Main/Actions';
const LOAD_REQUEST = 'App/ONGOING_REQUEST';
const DONE_REQUEST = 'App/DONE_REQUEST';
@ -46,6 +48,15 @@ const showNotification = ({
};
};
export const requireAsyncGlobals = ({ dispatch }) => {
return (nextState, finalState, callback) => {
Promise.all([
dispatch(loadConsoleOpts()),
dispatch(fetchServerConfig()),
]).finally(callback);
};
};
const progressBarReducer = (state = defaultState, action) => {
switch (action.type) {
case LOAD_REQUEST:
@ -93,6 +104,7 @@ const progressBarReducer = (state = defaultState, action) => {
...state,
modalOpen: true,
error: true,
ongoingRequest: false,
connectionFailed: true,
};
default:

View File

@ -1,5 +1,5 @@
import PropTypes from 'prop-types';
import React, { Component } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import ProgressBar from 'react-progress-bar-plus';
import Notifications from 'react-notification-system-redux';
@ -7,43 +7,16 @@ import { hot } from 'react-hot-loader';
import { ThemeProvider } from 'styled-components';
import ErrorBoundary from '../Error/ErrorBoundary';
import {
loadConsoleOpts,
telemetryNotificationShown,
} from '../../telemetry/Actions';
import { telemetryNotificationShown } from '../../telemetry/Actions';
import { showTelemetryNotification } from '../../telemetry/Notifications';
import globals from '../../Globals';
import styles from './App.scss';
import { theme } from '../UIKit/theme';
class App extends Component {
componentDidMount() {
const { dispatch } = this.props;
export const GlobalContext = React.createContext(globals);
// Hide the loader once the react component is ready.
// NOTE: This will execute only once (since this is the parent component for all other components).
const className = document.getElementById('content').className;
document.getElementById('content').className = className + ' show';
document.getElementById('loading').style.display = 'none';
dispatch(loadConsoleOpts());
}
componentDidUpdate() {
const { telemetry, dispatch } = this.props;
if (
telemetry.console_opts &&
!telemetry.console_opts.telemetryNotificationShown
) {
dispatch(telemetryNotificationShown());
dispatch(showTelemetryNotification());
}
}
render() {
const styles = require('./App.scss');
const {
requestError,
error,
const App = ({
ongoingRequest,
percent,
intervalTime,
@ -52,29 +25,41 @@ class App extends Component {
connectionFailed,
dispatch,
metadata,
} = this.props;
telemetry,
}) => {
React.useEffect(() => {
const className = document.getElementById('content').className;
document.getElementById('content').className = className + ' show';
document.getElementById('loading').style.display = 'none';
}, []);
if (requestError && error) {
// console.error(requestError, error);
React.useEffect(() => {
if (
telemetry.console_opts &&
!telemetry.console_opts.telemetryNotificationShown
) {
dispatch(telemetryNotificationShown());
dispatch(showTelemetryNotification());
}
}, [telemetry]);
let connectionFailMsg = null;
if (connectionFailed) {
connectionFailMsg = (
<div
style={{ marginBottom: '0px' }}
className={styles.alertDanger + ' alert alert-danger'}
className={`${styles.alertDanger} ${styles.remove_margin_bottom} alert alert-danger `}
>
<strong>
Hasura console is not able to reach your Hasura GraphQL engine
instance. Please ensure that your instance is running and the
endpoint is configured correctly.
instance. Please ensure that your instance is running and the endpoint
is configured correctly.
</strong>
</div>
);
}
return (
<GlobalContext.Provider value={globals}>
<ThemeProvider theme={theme}>
<ErrorBoundary metadata={metadata} dispatch={dispatch}>
<div>
@ -92,18 +77,16 @@ class App extends Component {
</div>
</ErrorBoundary>
</ThemeProvider>
</GlobalContext.Provider>
);
}
}
};
App.propTypes = {
reqURL: PropTypes.string,
reqData: PropTypes.object,
statusCode: PropTypes.number,
error: PropTypes.object,
ongoingRequest: PropTypes.bool,
requestError: PropTypes.bool,
connectionFailed: PropTypes.bool,
intervalTime: PropTypes.number,

View File

@ -2,6 +2,9 @@ import React from 'react';
import styles from '../Common.scss';
import { GlobalContext } from '../../App/App';
import { trackRuntimeError } from '../../../telemetry';
/*
This is a Button HOC that takes all the props supported by <button>
- color(default: white): color of the button; currently supports yellow, red, green, gray and white
@ -16,7 +19,7 @@ export interface ButtonProps extends React.ComponentProps<'button'> {
}
const Button: React.FC<ButtonProps> = props => {
const { children, size, color, className, type = 'button' } = props;
const { children, onClick, size, color, className, type = 'button' } = props;
let extendedClassName = `${className || ''} btn ${
size ? `btn-${size} ` : 'button '
}`;
@ -37,8 +40,29 @@ const Button: React.FC<ButtonProps> = props => {
extendedClassName += 'btn-default';
break;
}
const globals = React.useContext(GlobalContext);
const trackedOnClick = (
e: React.MouseEvent<HTMLButtonElement, MouseEvent>
) => {
try {
if (onClick) {
onClick(e);
}
} catch (error) {
console.error(error);
trackRuntimeError(globals, error);
}
};
return (
<button {...props} className={extendedClassName} type={type}>
<button
{...props}
className={extendedClassName}
type={type}
onClick={onClick ? trackedOnClick : undefined}
>
{children}
</button>
);

View File

@ -1,7 +1,5 @@
// TODO: make functions from this file available without imports
import { showErrorNotification } from '../../Services/Common/Notification';
/* TYPE utils */
export const isNotDefined = value => {
@ -233,8 +231,9 @@ export const getConfirmation = (
export const uploadFile = (
fileHandler,
fileFormat = null,
invalidFileHandler = null
) => dispatch => {
invalidFileHandler = null,
errorCallback = null
) => {
const fileInputElement = document.createElement('div');
fileInputElement.innerHTML = '<input style="display:none" type="file">';
const fileInput = fileInputElement.firstChild;
@ -254,13 +253,13 @@ export const uploadFile = (
if (invalidFileHandler) {
invalidFileHandler(fileName);
} else {
dispatch(
showErrorNotification(
if (errorCallback) {
errorCallback(
'Invalid file format',
`Expected a ${expectedFileSuffix} file`
)
);
}
}
fileInputElement.remove();
}

View File

@ -315,10 +315,20 @@ export const getFetchManualTriggersQuery = tableName => ({
},
});
export const getConsoleOptsQuery = () =>
generateSelectQuery(
'select',
{ name: 'hdb_version', schema: 'hdb_catalog' },
{
columns: ['hasura_uuid', 'console_state'],
}
);
export const getSaveRemoteRelQuery = (args, isNew) => ({
type: `${isNew ? 'create' : 'update'}_remote_relationship`,
args,
});
export const getDropRemoteRelQuery = (name, table) => ({
type: 'delete_remote_relationship',
args: {

View File

@ -1,4 +1,5 @@
import defaultState from './State';
import globals from '../../Globals';
import requestAction from '../../utils/requestAction';
import requestActionPlain from '../../utils/requestActionPlain';
import Endpoints, { globalCookiePolicy } from '../../Endpoints';
@ -121,16 +122,19 @@ const fetchServerConfig = () => (dispatch, getState) => {
});
return dispatch(requestAction(url, options)).then(
data => {
return dispatch({
dispatch({
type: SERVER_CONFIG_FETCH_SUCCESS,
data: data,
});
globals.serverConfig = data;
return Promise.resolve();
},
error => {
return dispatch({
dispatch({
type: SERVER_CONFIG_FETCH_FAIL,
data: error,
});
return Promise.reject();
}
);
};

View File

@ -33,8 +33,6 @@ class ImportMetadata extends Component {
render() {
const styles = require('../Settings.scss');
const { dispatch } = this.props;
const { isImporting } = this.state;
const handleImport = e => {
@ -42,7 +40,7 @@ class ImportMetadata extends Component {
this.setState({ isImporting: true });
dispatch(uploadFile(this.importMetadata, 'json'));
uploadFile(this.importMetadata, 'json');
};
return (

View File

@ -2,3 +2,4 @@ export const SERVER_CONSOLE_MODE = 'server';
export const CLI_CONSOLE_MODE = 'cli';
export const ADMIN_SECRET_HEADER_KEY = 'x-hasura-admin-secret';
export const REDUX_LOCATION_CHANGE_ACTION_TYPE = '@@router/LOCATION_CHANGE';

View File

@ -3,12 +3,14 @@ import { Route, IndexRoute, IndexRedirect } from 'react-router';
import { connect } from 'react-redux';
import { App, Main, PageNotFound } from 'components';
import globals from './Globals';
import { App, Main, PageNotFound } from 'components';
import validateLogin from './utils/validateLogin';
import { requireAsyncGlobals } from './components/App/Actions';
import { composeOnEnterHooks } from 'utils/router';
import { loadMigrationStatus } from './components/Main/Actions';
@ -103,7 +105,14 @@ const routes = store => {
);
return (
<Route path="/" component={App} onEnter={validateLogin(store)}>
<Route
path="/"
component={App}
onEnter={composeOnEnterHooks([
validateLogin(store),
requireAsyncGlobals(store),
])}
>
<Route path="login" component={generatedLoginConnector(connect)} />
<Route
path=""

View File

@ -2,11 +2,15 @@ import Endpoints, { globalCookiePolicy } from '../Endpoints';
import requestAction from '../utils/requestAction';
import dataHeaders from '../components/Services/Data/Common/Headers';
import defaultTelemetryState from './State';
import { getRunSqlQuery } from '../components/Common/utils/v1QueryUtils';
import {
getRunSqlQuery,
getConsoleOptsQuery,
} from '../components/Common/utils/v1QueryUtils';
import {
showErrorNotification,
showSuccessNotification,
} from '../components/Services/Common/Notification';
import globals from '../Globals';
const SET_CONSOLE_OPTS = 'Telemetry/SET_CONSOLE_OPTS';
const SET_NOTIFICATION_SHOWN = 'Telemetry/SET_NOTIFICATION_SHOWN';
@ -98,23 +102,14 @@ const setPreReleaseNotificationOptOutInDB = () => dispatch => {
return dispatch(setConsoleOptsInDB(opts, successCb, errorCb));
};
const loadConsoleOpts = () => {
export const loadConsoleOpts = () => {
return (dispatch, getState) => {
const url = Endpoints.getSchema;
const options = {
credentials: globalCookiePolicy,
method: 'POST',
headers: dataHeaders(getState),
body: JSON.stringify({
type: 'select',
args: {
table: {
name: 'hdb_version',
schema: 'hdb_catalog',
},
columns: ['hasura_uuid', 'console_state'],
},
}),
body: JSON.stringify(getConsoleOptsQuery()),
};
return dispatch(requestAction(url, options)).then(
@ -124,21 +119,34 @@ const loadConsoleOpts = () => {
type: SET_HASURA_UUID,
data: data[0].hasura_uuid,
});
globals.hasuraUUID = data[0].hasura_uuid;
dispatch({
type: SET_CONSOLE_OPTS,
data: data[0].console_state,
});
globals.telemetryNotificationShown =
data[0].console_state.telemetryNotificationShown;
}
return Promise.resolve();
},
error => {
console.error(
'Failed to load console options: ' + JSON.stringify(error)
);
return Promise.reject();
}
);
};
};
export const requireConsoleOpts = ({ dispatch }) => (
nextState,
replaceState,
callback
) => {
dispatch(loadConsoleOpts()).finally(callback);
};
const telemetryReducer = (state = defaultTelemetryState, action) => {
switch (action.type) {
case SET_CONSOLE_OPTS:
@ -168,7 +176,6 @@ const telemetryReducer = (state = defaultTelemetryState, action) => {
export default telemetryReducer;
export {
loadConsoleOpts,
telemetryNotificationShown,
setPreReleaseNotificationOptOutInDB,
setTelemetryNotificationShownInDB,

View File

@ -0,0 +1,70 @@
import globals from '../Globals';
const filterEventsBlockList = [
'App/ONGOING_REQUEST',
'App/DONE_REQUEST',
'App/FAILED_REQUEST',
'App/ERROR_REQUEST',
'RNS_SHOW_NOTIFICATION',
'RNS_HIDE_NOTIFICATION',
'RNS_REMOVE_ALL_NOTIFICATIONS',
];
const DATA_PATH = '/data';
const API_EXPLORER_PATH = '/api-explorer';
const REMOTE_SCHEMAS_PATH = '/remote-schemas';
const EVENTS_PATH = '/events';
const dataHandler = (path: string) => {
return (
DATA_PATH +
path
.replace(/\/schema\/([^/]*)(\/)?/, '/schema/SCHEMA_NAME$2')
.replace(
/(\/schema\/.*)\/tables\/([^/]*)(\/.*)?/,
'$1/tables/TABLE_NAME$3'
)
.replace(/(\/schema\/.*)\/views\/([^/]*)(\/.*)?/, '$1/views/VIEW_NAME$3')
.replace(
/(\/schema\/.*)\/functions\/([^/]*)(\/.*)?/,
'$1/functions/FUNCTION_NAME$3'
)
);
};
const apiExplorerHandler = () => {
return API_EXPLORER_PATH;
};
const remoteSchemasHandler = (path: string) => {
return (
REMOTE_SCHEMAS_PATH +
path.replace(/(\/manage\/)[^/]*(\/\w+.*)$/, '$1REMOTE_SCHEMA_NAME$2')
);
};
const eventsHandler = (path: string) => {
return (
EVENTS_PATH +
path.replace(/(\/manage\/triggers\/)[^/]*(\/\w+.*)$/, '$1TRIGGER_NAME$2')
);
};
const sanitiseUrl = (rawPath: string) => {
const path = rawPath.replace(new RegExp(globals.urlPrefix, 'g'), '');
if (path.indexOf(DATA_PATH) === 0) {
return dataHandler(path.slice(DATA_PATH.length));
}
if (path.indexOf(API_EXPLORER_PATH) === 0) {
return apiExplorerHandler();
}
if (path.indexOf(REMOTE_SCHEMAS_PATH) === 0) {
return remoteSchemasHandler(path.slice(REMOTE_SCHEMAS_PATH.length));
}
if (path.indexOf(EVENTS_PATH) === 0) {
return eventsHandler(path.slice(EVENTS_PATH.length));
}
return '/';
};
export { filterEventsBlockList, sanitiseUrl };

View File

@ -0,0 +1,105 @@
import endpoints from '../Endpoints';
import globals from '../Globals';
import { filterEventsBlockList, sanitiseUrl } from './filters';
import { RUN_TIME_ERROR } from '../components/Main/Actions';
import { REDUX_LOCATION_CHANGE_ACTION_TYPE } from '../constants';
import { GetReduxState, ReduxAction } from '../types';
interface TelemetryGlobals {
serverVersion: string;
consoleMode: string;
cliUUID: string;
hasuraUUID: string;
}
const createClient = () => {
if (globals.enableTelemetry) {
try {
const client = new WebSocket(endpoints.telemetryServer);
client.onerror = e => {
console.error(`WebSocket Error for Events${e}`);
};
return client;
} catch (e) {
console.error('Unable to initialise telemetry client', e);
return null;
}
}
return null;
};
let client = createClient();
if (client) {
const onClose = () => {
client = createClient();
if (client) {
client.onclose = onClose;
}
};
client.onclose = onClose;
}
const isTelemetryConnectionReady = () => {
return !!(client && client.readyState === client.OPEN);
};
const sendEvent = (payload: any) => {
if (client && isTelemetryConnectionReady()) {
client.send(
JSON.stringify({ data: payload, topic: globals.telemetryTopic })
);
}
};
export const trackReduxAction = (
action: ReduxAction,
getState: GetReduxState
) => {
const actionType = action.type;
// filter events
if (!filterEventsBlockList.includes(actionType)) {
const serverVersion = getState().main.serverVersion;
const url = sanitiseUrl(window.location.pathname);
const reqBody = {
server_version: serverVersion,
event_type: actionType,
url,
console_mode: globals.consoleMode,
cli_uuid: globals.cliUUID,
server_uuid: getState().telemetry.hasura_uuid,
data: null,
};
const isLocationType = actionType === REDUX_LOCATION_CHANGE_ACTION_TYPE;
if (isLocationType) {
// capture page views
const payload = action.payload;
reqBody.url = sanitiseUrl(payload.pathname);
}
const isErrorType = actionType === RUN_TIME_ERROR;
if (isErrorType) {
reqBody.data = action.data;
}
// Send the data
sendEvent(reqBody);
}
};
export const trackRuntimeError = (
telemeteryGlobals: TelemetryGlobals,
error: Error
) => {
const reqBody = {
server_version: telemeteryGlobals.serverVersion,
event_type: RUN_TIME_ERROR,
url: sanitiseUrl(window.location.pathname),
console_mode: telemeteryGlobals.consoleMode,
cli_uuid: telemeteryGlobals.cliUUID,
server_uuid: telemeteryGlobals.hasuraUUID,
data: { message: error.message, stack: error.stack },
};
sendEvent(reqBody);
};

9
console/src/types.ts Normal file
View File

@ -0,0 +1,9 @@
export type ReduxAction = {
type: string;
payload: {
pathname: string;
};
data: any;
};
export type GetReduxState = () => Record<string, any>;

View File

@ -7,6 +7,7 @@ import {
DONE_REQUEST,
FAILED_REQUEST,
ERROR_REQUEST,
CONNECTION_FAILED,
} from '../components/App/Actions';
import { globalCookiePolicy } from '../Endpoints';
@ -73,7 +74,7 @@ const requestAction = (
},
error => {
console.error('Request error: ', error);
dispatch({ type: FAILED_REQUEST });
dispatch({ type: CONNECTION_FAILED });
if (ERROR) {
dispatch({
type: ERROR,