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);
}
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);
}
}
if (globals.enableTelemetry) {
trackReduxAction(action, getState);
}
// 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,29 +7,33 @@ 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 App = ({
ongoingRequest,
percent,
intervalTime,
children,
notifications,
connectionFailed,
dispatch,
metadata,
telemetry,
}) => {
React.useEffect(() => {
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;
React.useEffect(() => {
if (
telemetry.console_opts &&
!telemetry.console_opts.telemetryNotificationShown
@ -37,44 +41,25 @@ class App extends Component {
dispatch(telemetryNotificationShown());
dispatch(showTelemetryNotification());
}
}, [telemetry]);
let connectionFailMsg = null;
if (connectionFailed) {
connectionFailMsg = (
<div
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.
</strong>
</div>
);
}
render() {
const styles = require('./App.scss');
const {
requestError,
error,
ongoingRequest,
percent,
intervalTime,
children,
notifications,
connectionFailed,
dispatch,
metadata,
} = this.props;
if (requestError && error) {
// console.error(requestError, error);
}
let connectionFailMsg = null;
if (connectionFailed) {
connectionFailMsg = (
<div
style={{ marginBottom: '0px' }}
className={styles.alertDanger + ' 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.
</strong>
</div>
);
}
return (
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,12 +253,12 @@ 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,