openreplay frontend
9
frontend/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
*.log
|
||||
node_modules/
|
||||
public/
|
||||
.idea
|
||||
drafts
|
||||
yarn.lock
|
||||
app/components/ui/SVG.js
|
||||
*.DS_Store
|
||||
.env
|
||||
33
frontend/.storybook/config.js
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { configure, addDecorator } from '@storybook/react';
|
||||
import { Provider } from 'react-redux';
|
||||
import store from '../app/store';
|
||||
import { MemoryRouter } from "react-router"
|
||||
|
||||
const withProvider = (story) => (
|
||||
<Provider store={store}>
|
||||
{ story() }
|
||||
</Provider>
|
||||
)
|
||||
|
||||
// const req = require.context('../app/components/ui', true, /\.stories\.js$/);
|
||||
// const issues = require.context('../app/components/Session/Issues', true, /\.stories\.js$/);
|
||||
// const bugFinder = require.context('../app/components/BugFinder', true, /\.stories\.js$/);
|
||||
|
||||
addDecorator(withProvider);
|
||||
addDecorator(story => <MemoryRouter initialEntries={['/']}>{story()}</MemoryRouter>);
|
||||
|
||||
// function loadStories() {
|
||||
// req.keys().forEach(filename => req(filename));
|
||||
// bugFinder.keys().forEach(filename => bugFinder(filename));
|
||||
// }
|
||||
|
||||
// configure(loadStories, module);
|
||||
|
||||
|
||||
configure(
|
||||
[
|
||||
// require.context('../app', true, /\.stories\.mdx$/),
|
||||
require.context('../app', true, /\.stories\.js$/),
|
||||
],
|
||||
module
|
||||
);
|
||||
2
frontend/.storybook/preview-head.html
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
|
||||
<script>//alert('test')</script>
|
||||
14
frontend/.storybook/webpack.config.js
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
const pathAlias = require('../path-alias');
|
||||
const mainConfig = require('../webpack.config.js');
|
||||
|
||||
module.exports = async ({ config }) => {
|
||||
var conf = mainConfig();
|
||||
config.resolve.alias = Object.assign(pathAlias, config.resolve.alias); // Path Alias
|
||||
config.module.rules = conf.module.rules;
|
||||
config.module.rules[0].use[0] = 'style-loader'; // instead of separated css
|
||||
config.module.rules[1].use[0] = 'style-loader';
|
||||
config.plugins.push(conf.plugins[0]); // global React
|
||||
config.plugins.push(conf.plugins[5]);
|
||||
config.entry = config.entry.concat(conf.entry.slice(2)) // CSS entries
|
||||
return config;
|
||||
};
|
||||
18
frontend/README.md
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# openreplay-ui
|
||||
Openreplay prototype UI
|
||||
|
||||
On new icon addition:
|
||||
`npm run generate:icons`
|
||||
|
||||
|
||||
## Documentation
|
||||
|
||||
* [Redux](https://redux.js.org/)
|
||||
* [Immutable](https://facebook.github.io/immutable-js/)
|
||||
* [Ducks](https://github.com/erikras/ducks-modular-redux)
|
||||
* [CSS Modules](https://github.com/css-modules/css-modules)
|
||||
|
||||
|
||||
Labels in comments:
|
||||
TEMP = temporary code
|
||||
TODO = things to implement
|
||||
151
frontend/app/Router.js
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { Switch, Route, Redirect } from 'react-router';
|
||||
import { BrowserRouter, withRouter } from 'react-router-dom';
|
||||
import { connect } from 'react-redux';
|
||||
import { Notification } from 'UI';
|
||||
import { Loader } from 'UI';
|
||||
import { fetchUserInfo } from 'Duck/user';
|
||||
import withSiteIdUpdater from 'HOCs/withSiteIdUpdater';
|
||||
import Login from 'Components/Login/Login';
|
||||
import ForgotPassword from 'Components/ForgotPassword/ForgotPassword';
|
||||
import UpdatePassword from 'Components/UpdatePassword/UpdatePassword';
|
||||
import ClientPure from 'Components/Client/Client';
|
||||
import OnboardingPure from 'Components/Onboarding/Onboarding';
|
||||
import SessionPure from 'Components/Session/Session';
|
||||
import BugFinderPure from 'Components/BugFinder/BugFinder';
|
||||
import DashboardPure from 'Components/Dashboard/Dashboard';
|
||||
import ErrorsPure from 'Components/Errors/Errors';
|
||||
import Header from 'Components/Header/Header';
|
||||
// import ResultsModal from 'Shared/Results/ResultsModal';
|
||||
import FunnelDetails from 'Components/Funnels/FunnelDetails';
|
||||
import FunnelIssueDetails from 'Components/Funnels/FunnelIssueDetails';
|
||||
|
||||
import APIClient from './api_client';
|
||||
import * as routes from './routes';
|
||||
import Signup from './components/Signup/Signup';
|
||||
|
||||
const BugFinder = withSiteIdUpdater(BugFinderPure);
|
||||
const Dashboard = withSiteIdUpdater(DashboardPure);
|
||||
const Session = withSiteIdUpdater(SessionPure);
|
||||
const Client = withSiteIdUpdater(ClientPure);
|
||||
const Onboarding = withSiteIdUpdater(OnboardingPure);
|
||||
const Errors = withSiteIdUpdater(ErrorsPure);
|
||||
const Funnels = withSiteIdUpdater(FunnelDetails);
|
||||
const FunnelIssue = withSiteIdUpdater(FunnelIssueDetails);
|
||||
const withSiteId = routes.withSiteId;
|
||||
const withObTab = routes.withObTab;
|
||||
|
||||
const DASHBOARD_PATH = routes.dashboard();
|
||||
const SESSIONS_PATH = routes.sessions();
|
||||
const ERRORS_PATH = routes.errors();
|
||||
const ERROR_PATH = routes.error();
|
||||
const FUNNEL_PATH = routes.funnel();
|
||||
const FUNNEL_ISSUE_PATH = routes.funnelIssue();
|
||||
const SESSION_PATH = routes.session();
|
||||
const LIVE_SESSION_PATH = routes.liveSession();
|
||||
const LOGIN_PATH = routes.login();
|
||||
const SIGNUP_PATH = routes.signup();
|
||||
const FORGOT_PASSWORD = routes.forgotPassword();
|
||||
const CLIENT_PATH = routes.client();
|
||||
const ONBOARDING_PATH = routes.onboarding();
|
||||
|
||||
@withRouter
|
||||
@connect((state) => {
|
||||
const siteId = state.getIn([ 'user', 'siteId' ]);
|
||||
const jwt = state.get('jwt');
|
||||
const changePassword = state.getIn([ 'user', 'account', 'changePassword' ]);
|
||||
const userInfoLoading = state.getIn([ 'user', 'fetchUserInfoRequest', 'loading' ]);
|
||||
return {
|
||||
jwt,
|
||||
siteId,
|
||||
changePassword,
|
||||
sites: state.getIn([ 'user', 'client', 'sites' ]),
|
||||
isLoggedIn: jwt !== null && !changePassword,
|
||||
loading: siteId === null || userInfoLoading,
|
||||
email: state.getIn([ 'user', 'account', 'email' ]),
|
||||
account: state.getIn([ 'user', 'account' ]),
|
||||
organisation: state.getIn([ 'user', 'client', 'name' ]),
|
||||
tenantId: state.getIn([ 'user', 'client', 'tenantId' ]),
|
||||
};
|
||||
}, {
|
||||
fetchUserInfo,
|
||||
})
|
||||
class Router extends React.Component {
|
||||
constructor(props) {
|
||||
super(props);
|
||||
if (props.isLoggedIn) {
|
||||
Promise.all([props.fetchUserInfo()])
|
||||
.then(() => this.onLoginLogout());
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (prevProps.email !== this.props.email) {
|
||||
this.onLoginLogout();
|
||||
}
|
||||
}
|
||||
|
||||
onLoginLogout() {
|
||||
const { email, account, organisation } = this.props;
|
||||
}
|
||||
|
||||
render() {
|
||||
const { isLoggedIn, jwt, siteId, sites, loading, changePassword, location } = this.props;
|
||||
const siteIdList = sites.map(({ id }) => id).toJS();
|
||||
const hideHeader = location.pathname && location.pathname.includes('/session/');
|
||||
|
||||
return isLoggedIn ?
|
||||
<Loader loading={ loading } className="flex-1" >
|
||||
{!hideHeader && <Header key="header"/>}
|
||||
<Notification />
|
||||
|
||||
<Switch key="content" >
|
||||
<Route path={ CLIENT_PATH } component={ Client } />
|
||||
<Route path={ withSiteId(ONBOARDING_PATH, siteIdList)} component={ Onboarding } />
|
||||
<Route
|
||||
path="/integrations/"
|
||||
render={
|
||||
({ location }) => {
|
||||
const client = new APIClient(jwt);
|
||||
switch (location.pathname) {
|
||||
case '/integrations/slack':
|
||||
client.post('integrations/slack/add', {
|
||||
code: location.search.split('=')[ 1 ],
|
||||
state: tenantId,
|
||||
});
|
||||
break;
|
||||
}
|
||||
return <Redirect to={ CLIENT_PATH } />;
|
||||
}
|
||||
}
|
||||
/>
|
||||
{ siteIdList.length === 0 &&
|
||||
<Redirect to={ routes.client(routes.CLIENT_TABS.SITES) } />
|
||||
}
|
||||
<Route exact strict path={ withSiteId(DASHBOARD_PATH, siteIdList) } component={ Dashboard } />
|
||||
<Route exact strict path={ withSiteId(ERRORS_PATH, siteIdList) } component={ Errors } />
|
||||
<Route exact strict path={ withSiteId(ERROR_PATH, siteIdList) } component={ Errors } />
|
||||
<Route exact strict path={ withSiteId(FUNNEL_PATH, siteIdList) } component={ Funnels } />
|
||||
<Route exact strict path={ withSiteId(FUNNEL_ISSUE_PATH, siteIdList) } component={ FunnelIssue } />
|
||||
<Route exact strict path={ withSiteId(SESSIONS_PATH, siteIdList) } component={ BugFinder } />
|
||||
<Route exact strict path={ withSiteId(SESSION_PATH, siteIdList) } component={ Session } />
|
||||
<Route exact strict path={ withSiteId(LIVE_SESSION_PATH, siteIdList) } render={ (props) => <Session { ...props } live /> } />
|
||||
{ routes.redirects.map(([ fr, to ]) => (
|
||||
<Redirect key={ fr } exact strict from={ fr } to={ to } />
|
||||
)) }
|
||||
<Redirect to={ withSiteId(SESSIONS_PATH, siteId) } />
|
||||
</Switch>
|
||||
</Loader> :
|
||||
<Switch>
|
||||
<Route exact strict path={ FORGOT_PASSWORD } component={ ForgotPassword } />
|
||||
<Route exact strict path={ LOGIN_PATH } component={ changePassword ? UpdatePassword : Login } />
|
||||
<Route exact strict path={ SIGNUP_PATH } component={ Signup } />
|
||||
<Redirect to={ LOGIN_PATH } />
|
||||
</Switch>;
|
||||
}
|
||||
}
|
||||
|
||||
export default () => (
|
||||
<BrowserRouter>
|
||||
<Router />
|
||||
</BrowserRouter>
|
||||
);
|
||||
108
frontend/app/api_client.js
Normal file
|
|
@ -0,0 +1,108 @@
|
|||
import store from 'App/store';
|
||||
|
||||
import { queried } from './routes';
|
||||
|
||||
const siteIdRequiredPaths = [
|
||||
'/dashboard',
|
||||
'/sessions',
|
||||
'/events',
|
||||
'/filters',
|
||||
'/alerts',
|
||||
'/targets',
|
||||
'/metadata',
|
||||
'/integrations/sentry/events',
|
||||
'/integrations/slack/notify',
|
||||
'/assignments',
|
||||
'/integration/sources',
|
||||
'/issue_types',
|
||||
'/sample_rate',
|
||||
'/flows',
|
||||
'/rehydrations',
|
||||
'/sourcemaps',
|
||||
'/errors',
|
||||
'/funnels'
|
||||
];
|
||||
|
||||
const noStoringFetchPathStarts = [
|
||||
'/account/password',
|
||||
'/password',
|
||||
'/login'
|
||||
];
|
||||
|
||||
// null?
|
||||
export const clean = (obj, forbidenValues = [ undefined, '' ]) => {
|
||||
const keys = Array.isArray(obj)
|
||||
? new Array(obj.length).fill().map((_, i) => i)
|
||||
: Object.keys(obj);
|
||||
const retObj = Array.isArray(obj) ? [] : {};
|
||||
keys.map(key => {
|
||||
const value = obj[key];
|
||||
if (typeof value === 'object' && value !== null) {
|
||||
retObj[key] = clean(value);
|
||||
} else if (!forbidenValues.includes(value)) {
|
||||
retObj[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return retObj;
|
||||
}
|
||||
|
||||
|
||||
export default class APIClient {
|
||||
constructor() {
|
||||
const jwt = store.getState().get('jwt');
|
||||
const siteId = store.getState().getIn([ 'user', 'siteId' ]);
|
||||
this.init = {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
};
|
||||
if (jwt !== null) {
|
||||
this.init.headers.Authorization = `Bearer ${ jwt }`;
|
||||
}
|
||||
this.siteId = siteId;
|
||||
}
|
||||
|
||||
fetch(path, params, options = { clean: true }) {
|
||||
if (params !== undefined) {
|
||||
const cleanedParams = options.clean ? clean(params) : params;
|
||||
this.init.body = JSON.stringify(cleanedParams);
|
||||
}
|
||||
|
||||
|
||||
let fetch = window.fetch;
|
||||
|
||||
let edp = window.ENV.API_EDP;
|
||||
if (
|
||||
path !== '/targets_temp' &&
|
||||
!path.includes('/metadata/session_search') &&
|
||||
!path.includes('/watchdogs/rules') &&
|
||||
!!this.siteId &&
|
||||
siteIdRequiredPaths.some(sidPath => path.startsWith(sidPath))
|
||||
) {
|
||||
edp = `${ edp }/${ this.siteId }`
|
||||
}
|
||||
return fetch(edp + path, this.init);
|
||||
}
|
||||
|
||||
get(path, params, options) {
|
||||
this.init.method = 'GET';
|
||||
return this.fetch(queried(path, params, options));
|
||||
}
|
||||
|
||||
post(path, params, options) {
|
||||
this.init.method = 'POST';
|
||||
return this.fetch(path, params);
|
||||
}
|
||||
|
||||
put(path, params, options) {
|
||||
this.init.method = 'PUT';
|
||||
return this.fetch(path, params);
|
||||
}
|
||||
|
||||
delete(path, params, options) {
|
||||
this.init.method = 'DELETE';
|
||||
return this.fetch(path, params);
|
||||
}
|
||||
}
|
||||
45
frontend/app/api_middleware.js
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import APIClient from './api_client';
|
||||
import { UPDATE, DELETE } from './duck/jwt';
|
||||
|
||||
export default store => next => (action) => {
|
||||
const { types, call, ...rest } = action;
|
||||
if (!call) {
|
||||
return next(action);
|
||||
}
|
||||
const [ REQUEST, SUCCESS, FAILURE ] = types;
|
||||
next({ ...rest, type: REQUEST });
|
||||
const client = new APIClient();
|
||||
|
||||
return call(client)
|
||||
.then(response => {
|
||||
if (response.status === 403) {
|
||||
next({ type: DELETE });
|
||||
}
|
||||
return response.json()
|
||||
})
|
||||
.then(json => json || {}) // TEMP TODO on server: no empty responces
|
||||
.then(({ jwt, errors, data }) => {
|
||||
if (errors) {
|
||||
next({ type: FAILURE, errors, data });
|
||||
} else {
|
||||
next({ type: SUCCESS, data, ...rest });
|
||||
}
|
||||
if (jwt) {
|
||||
next({ type: UPDATE, data: jwt });
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
return next({ type: FAILURE, errors: [ 'Connection error' ] });
|
||||
});
|
||||
};
|
||||
|
||||
function jwtExpired(token) {
|
||||
try {
|
||||
const base64Url = token.split('.')[ 1 ];
|
||||
const base64 = base64Url.replace('-', '+').replace('_', '/');
|
||||
const tokenObj = JSON.parse(window.atob(base64));
|
||||
return tokenObj.exp * 1000 < Date.now(); // exp in Unix time (sec)
|
||||
} catch (e) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
BIN
frontend/app/assets/favicon@1x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
frontend/app/assets/favicon@2x.png
Normal file
|
After Width: | Height: | Size: 5.7 KiB |
BIN
frontend/app/assets/favicon@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/app/assets/favicon@4x.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/app/assets/favicon@5x.png
Normal file
|
After Width: | Height: | Size: 24 KiB |
BIN
frontend/app/assets/favicon@6x.png
Normal file
|
After Width: | Height: | Size: 31 KiB |
BIN
frontend/app/assets/img/chrome-plugin.png
Normal file
|
After Width: | Height: | Size: 112 KiB |
BIN
frontend/app/assets/img/flags.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/app/assets/img/flags_responsive.png
Executable file
|
After Width: | Height: | Size: 54 KiB |
BIN
frontend/app/assets/img/funnel_intro.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
frontend/app/assets/img/img-newsletter.png
Normal file
|
After Width: | Height: | Size: 70 KiB |
BIN
frontend/app/assets/img/ios/iPad-5th.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
frontend/app/assets/img/ios/iPad-5th@2x.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/app/assets/img/ios/iPad-7th.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
frontend/app/assets/img/ios/iPad-7th@2x.png
Normal file
|
After Width: | Height: | Size: 204 KiB |
BIN
frontend/app/assets/img/ios/iPad-Air-2.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
frontend/app/assets/img/ios/iPad-Air-2@2x.png
Normal file
|
After Width: | Height: | Size: 233 KiB |
BIN
frontend/app/assets/img/ios/iPad-Air.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
frontend/app/assets/img/ios/iPad-Air@2x.png
Normal file
|
After Width: | Height: | Size: 251 KiB |
BIN
frontend/app/assets/img/ios/iPad-Mini-2.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/app/assets/img/ios/iPad-Mini-2@2x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/app/assets/img/ios/iPad-Mini-3.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
frontend/app/assets/img/ios/iPad-Mini-3@2x.png
Normal file
|
After Width: | Height: | Size: 241 KiB |
BIN
frontend/app/assets/img/ios/iPad-Mini-4.png
Normal file
|
After Width: | Height: | Size: 36 KiB |
BIN
frontend/app/assets/img/ios/iPad-Mini-4@2x.png
Normal file
|
After Width: | Height: | Size: 97 KiB |
BIN
frontend/app/assets/img/ios/iPad-air-4.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
frontend/app/assets/img/ios/iPad-air-4@2x.png
Normal file
|
After Width: | Height: | Size: 434 KiB |
BIN
frontend/app/assets/img/ios/iPad-pro-11-2020.png
Normal file
|
After Width: | Height: | Size: 173 KiB |
BIN
frontend/app/assets/img/ios/iPad-pro-11-2020@2x.png
Normal file
|
After Width: | Height: | Size: 668 KiB |
BIN
frontend/app/assets/img/ios/iPad-pro-12.9-2020.png
Normal file
|
After Width: | Height: | Size: 43 KiB |
BIN
frontend/app/assets/img/ios/iPad-pro-12.9-2020@2x.png
Normal file
|
After Width: | Height: | Size: 93 KiB |
BIN
frontend/app/assets/img/ios/iPhone-11-Pro-Max.png
Normal file
|
After Width: | Height: | Size: 78 KiB |
BIN
frontend/app/assets/img/ios/iPhone-11-Pro-Max@2x.png
Normal file
|
After Width: | Height: | Size: 174 KiB |
BIN
frontend/app/assets/img/ios/iPhone-11-Pro.png
Normal file
|
After Width: | Height: | Size: 76 KiB |
BIN
frontend/app/assets/img/ios/iPhone-11-Pro@2x.png
Normal file
|
After Width: | Height: | Size: 172 KiB |
BIN
frontend/app/assets/img/ios/iPhone-11.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/app/assets/img/ios/iPhone-11@2x.png
Normal file
|
After Width: | Height: | Size: 119 KiB |
BIN
frontend/app/assets/img/ios/iPhone-12.png
Normal file
|
After Width: | Height: | Size: 257 KiB |
BIN
frontend/app/assets/img/ios/iPhone-12@2x.png
Normal file
|
After Width: | Height: | Size: 702 KiB |
BIN
frontend/app/assets/img/ios/iPhone-5S.png
Normal file
|
After Width: | Height: | Size: 34 KiB |
BIN
frontend/app/assets/img/ios/iPhone-5S@2x.png
Normal file
|
After Width: | Height: | Size: 84 KiB |
BIN
frontend/app/assets/img/ios/iPhone-6.png
Normal file
|
After Width: | Height: | Size: 42 KiB |
BIN
frontend/app/assets/img/ios/iPhone-6@2x.png
Normal file
|
After Width: | Height: | Size: 146 KiB |
BIN
frontend/app/assets/img/ios/iPhone-6S.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
frontend/app/assets/img/ios/iPhone-6S@2x.png
Normal file
|
After Width: | Height: | Size: 131 KiB |
BIN
frontend/app/assets/img/ios/iPhone-6s-plus.png
Normal file
|
After Width: | Height: | Size: 44 KiB |
BIN
frontend/app/assets/img/ios/iPhone-6s-plus@2x.png
Normal file
|
After Width: | Height: | Size: 118 KiB |
BIN
frontend/app/assets/img/ios/iPhone-7.png
Normal file
|
After Width: | Height: | Size: 29 KiB |
BIN
frontend/app/assets/img/ios/iPhone-7@2x.png
Normal file
|
After Width: | Height: | Size: 79 KiB |
BIN
frontend/app/assets/img/ios/iPhone-8-plus.png
Normal file
|
After Width: | Height: | Size: 106 KiB |
BIN
frontend/app/assets/img/ios/iPhone-8-plus@2x.png
Normal file
|
After Width: | Height: | Size: 378 KiB |
BIN
frontend/app/assets/img/ios/iPhone-8.png
Normal file
|
After Width: | Height: | Size: 28 KiB |
BIN
frontend/app/assets/img/ios/iPhone-8@2x.png
Normal file
|
After Width: | Height: | Size: 65 KiB |
BIN
frontend/app/assets/img/ios/iPhone-SE.png
Normal file
|
After Width: | Height: | Size: 101 KiB |
BIN
frontend/app/assets/img/ios/iPhone-SE@2x.png
Normal file
|
After Width: | Height: | Size: 326 KiB |
BIN
frontend/app/assets/img/ios/iPhone-X.png
Normal file
|
After Width: | Height: | Size: 58 KiB |
BIN
frontend/app/assets/img/ios/iPhone-X@2x.png
Normal file
|
After Width: | Height: | Size: 136 KiB |
BIN
frontend/app/assets/img/ios/iPhone-XR.png
Normal file
|
After Width: | Height: | Size: 41 KiB |
BIN
frontend/app/assets/img/ios/iPhone-XR@2x.png
Normal file
|
After Width: | Height: | Size: 98 KiB |
BIN
frontend/app/assets/img/ios/iPhone-XS-max.png
Normal file
|
After Width: | Height: | Size: 21 KiB |
BIN
frontend/app/assets/img/ios/iPhone-XS-max@2x.png
Normal file
|
After Width: | Height: | Size: 193 KiB |
BIN
frontend/app/assets/img/ios/iPhone-XS.png
Normal file
|
After Width: | Height: | Size: 67 KiB |
BIN
frontend/app/assets/img/ios/iPhone-XS@2x.png
Normal file
|
After Width: | Height: | Size: 192 KiB |
BIN
frontend/app/assets/img/ios/iPhone-se-2.png
Normal file
|
After Width: | Height: | Size: 73 KiB |
BIN
frontend/app/assets/img/ios/iphone-se-2@2x.png
Normal file
|
After Width: | Height: | Size: 179 KiB |
BIN
frontend/app/assets/img/widgets/application_activity.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
BIN
frontend/app/assets/img/widgets/errors.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/app/assets/img/widgets/missing_resources.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
frontend/app/assets/img/widgets/most_Impactful_errors.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
frontend/app/assets/img/widgets/na.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
BIN
frontend/app/assets/img/widgets/negative_feedback.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
frontend/app/assets/img/widgets/page_metrics.png
Normal file
|
After Width: | Height: | Size: 7.2 KiB |
BIN
frontend/app/assets/img/widgets/performance.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
BIN
frontend/app/assets/img/widgets/processed_sessions.png
Normal file
|
After Width: | Height: | Size: 8.5 KiB |
BIN
frontend/app/assets/img/widgets/recent_frustrations.png
Normal file
|
After Width: | Height: | Size: 6.2 KiB |
BIN
frontend/app/assets/img/widgets/user_activity.png
Normal file
|
After Width: | Height: | Size: 8.1 KiB |
21
frontend/app/assets/index.html
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>OpenReplay</title>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<meta name="slack-app-id" content="AA5LEB34M">
|
||||
<link rel="icon" type="image/png" href="/favicon@1x.png" sizes="32x32">
|
||||
<link rel="icon" type="image/png" href="/favicon@2x.png" sizes="64x64">
|
||||
<link rel="icon" type="image/png" href="/favicon@3x.png" sizes="96x96">
|
||||
<link rel="icon" type="image/png" href="/favicon@4x.png" sizes="128x128">
|
||||
<link rel="icon" type="image/png" href="/favicon@5x.png" sizes="160x160">
|
||||
<link rel="icon" type="image/png" href="/favicon@6x.png" sizes="192x192">
|
||||
<link rel="stylesheet" href="//cdn.jsdelivr.net/npm/semantic-ui@2.4.2/dist/semantic.min.css" />
|
||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700" rel="stylesheet">
|
||||
<link rel="stylesheet" href="/path/to/styles/theme-name.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
</body>
|
||||
</html>
|
||||
9
frontend/app/assets/logo-white.svg
Normal file
|
After Width: | Height: | Size: 14 KiB |
1
frontend/app/assets/marvel-device.css
Normal file
332
frontend/app/components/Alerts/AlertForm.js
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
import React from 'react'
|
||||
import { Button, Dropdown, Form, Input, SegmentSelection, Checkbox, Message, Link, Icon } from 'UI';
|
||||
import { alertMetrics as metrics } from 'App/constants';
|
||||
import { alertConditions as conditions } from 'App/constants';
|
||||
import { client, CLIENT_TABS } from 'App/routes';
|
||||
import { connect } from 'react-redux';
|
||||
import stl from './alertForm.css';
|
||||
import DropdownChips from './DropdownChips';
|
||||
import { validateEmail } from 'App/validate';
|
||||
import cn from 'classnames';
|
||||
|
||||
const thresholdOptions = [
|
||||
{ text: '15 minutes', value: 15 },
|
||||
{ text: '30 minutes', value: 30 },
|
||||
{ text: '1 hour', value: 60 },
|
||||
{ text: '2 hours', value: 120 },
|
||||
{ text: '4 hours', value: 240 },
|
||||
{ text: '1 day', value: 1440 },
|
||||
];
|
||||
|
||||
const changeOptions = [
|
||||
{ text: 'change', value: 'change' },
|
||||
{ text: '% change', value: 'percent' },
|
||||
];
|
||||
|
||||
const Circle = ({ text }) => (
|
||||
<div className="circle mr-4 w-6 h-6 rounded-full bg-gray-light flex items-center justify-center">{text}</div>
|
||||
)
|
||||
|
||||
const Section = ({ index, title, description, content }) => (
|
||||
<div className="w-full">
|
||||
<div className="flex items-start">
|
||||
<Circle text={index} />
|
||||
<div>
|
||||
<span className="font-medium">{title}</span>
|
||||
{ description && <div className="text-sm color-gray-medium">{description}</div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="ml-10">
|
||||
{content}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
const integrationsRoute = client(CLIENT_TABS.INTEGRATIONS);
|
||||
|
||||
const AlertForm = props => {
|
||||
const { instance, slackChannels, webhooks, loading, onDelete, deleting } = props;
|
||||
const write = ({ target: { value, name } }) => props.edit({ [ name ]: value })
|
||||
const writeOption = (e, { name, value }) => props.edit({ [ name ]: value });
|
||||
const onChangeOption = (e, { checked, name }) => props.edit({ [ name ]: checked })
|
||||
|
||||
const writeQueryOption = (e, { name, value }) => {
|
||||
const { query } = instance;
|
||||
props.edit({ query: { ...query, [name] : value } });
|
||||
}
|
||||
|
||||
const writeQuery = ({ target: { value, name } }) => {
|
||||
const { query } = instance;
|
||||
props.edit({ query: { ...query, [name] : value } });
|
||||
}
|
||||
|
||||
const metric = (instance && instance.query.left) ? metrics.find(i => i.value === instance.query.left) : null;
|
||||
const unit = metric ? metric.unit : '';
|
||||
const isThreshold = instance.detectionMethod === 'threshold';
|
||||
|
||||
|
||||
return (
|
||||
<Form className={ cn("p-6", stl.wrapper)} style={{ width: '580px' }} onSubmit={() => props.onSubmit(instance)} id="alert-form">
|
||||
<div className={cn(stl.content, '-mx-6 px-6 pb-12')}>
|
||||
<input
|
||||
autoFocus={ true }
|
||||
className="text-lg"
|
||||
name="name"
|
||||
style={{ fontSize: '18px', padding: '10px', fontWeight: '600'}}
|
||||
value={ instance && instance.name }
|
||||
onChange={ write }
|
||||
placeholder="New Alert"
|
||||
id="name-field"
|
||||
/>
|
||||
<div className="mb-8" />
|
||||
<Section
|
||||
index="1"
|
||||
title={'What kind of alert do you want to set?'}
|
||||
content={
|
||||
<div>
|
||||
<SegmentSelection
|
||||
primary
|
||||
name="detectionMethod"
|
||||
className="my-3"
|
||||
onSelect={ writeOption }
|
||||
value={{ value: instance.detectionMethod }}
|
||||
list={ [
|
||||
{ name: 'Threshold', value: 'threshold' },
|
||||
{ name: 'Change', value: 'change' },
|
||||
]}
|
||||
/>
|
||||
<div className="text-sm color-gray-medium">
|
||||
{isThreshold && 'Eg. Alert me if memory.avg is greater than 500mb over the past 4 hours.'}
|
||||
{!isThreshold && 'Eg. Alert me if % change of memory.avg is greater than 10% over the past 4 hours compared to the previous 4 hours.'}
|
||||
</div>
|
||||
<div className="my-4" />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<Section
|
||||
index="2"
|
||||
title="Condition"
|
||||
content={
|
||||
<div>
|
||||
{!isThreshold && (
|
||||
<div className="flex items-center my-3">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal">{'Trigger when'}</label>
|
||||
<Dropdown
|
||||
className="w-4/6"
|
||||
placeholder="change"
|
||||
selection
|
||||
options={ changeOptions }
|
||||
name="change"
|
||||
value={ instance.change }
|
||||
onChange={ writeOption }
|
||||
id="change-dropdown"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center my-3">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal">{isThreshold ? 'Trigger when' : 'of'}</label>
|
||||
<Dropdown
|
||||
className="w-4/6"
|
||||
placeholder="Select Metric"
|
||||
selection
|
||||
search
|
||||
options={ metrics }
|
||||
name="left"
|
||||
value={ instance.query.left }
|
||||
onChange={ writeQueryOption }
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center my-3">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal">{'is'}</label>
|
||||
<div className="w-4/6 flex items-center">
|
||||
<Dropdown
|
||||
className="px-4"
|
||||
placeholder="Select Condition"
|
||||
selection
|
||||
options={ conditions }
|
||||
name="operator"
|
||||
value={ instance.query.operator }
|
||||
onChange={ writeQueryOption }
|
||||
/>
|
||||
{ unit && (
|
||||
<Input
|
||||
className="px-4"
|
||||
style={{ marginRight: '31px'}}
|
||||
label={{ basic: true, content: unit }}
|
||||
labelPosition='right'
|
||||
name="right"
|
||||
value={ instance.query.right }
|
||||
onChange={ writeQuery }
|
||||
placeholder="E.g. 3"
|
||||
/>
|
||||
)}
|
||||
{ !unit && (
|
||||
<Input
|
||||
className="pl-4"
|
||||
name="right"
|
||||
value={ instance.query.right }
|
||||
onChange={ writeQuery }
|
||||
placeholder="Specify Value"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center my-3">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal">{'over the past'}</label>
|
||||
<Dropdown
|
||||
className="w-2/6"
|
||||
placeholder="Select timeframe"
|
||||
selection
|
||||
options={ thresholdOptions }
|
||||
name="currentPeriod"
|
||||
value={ instance.currentPeriod }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</div>
|
||||
{!isThreshold && (
|
||||
<div className="flex items-center my-3">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal">{'compared to previous'}</label>
|
||||
<Dropdown
|
||||
className="w-2/6"
|
||||
placeholder="Select timeframe"
|
||||
selection
|
||||
options={ thresholdOptions }
|
||||
name="previousPeriod"
|
||||
value={ instance.previousPeriod }
|
||||
onChange={ writeOption }
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<hr className="my-8" />
|
||||
|
||||
<Section
|
||||
index="3"
|
||||
title="Notify Through"
|
||||
description="You'll be noticed in app notifications. Additionally opt in to receive alerts on:"
|
||||
content={
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center my-4">
|
||||
<Checkbox
|
||||
name="slack"
|
||||
className="font-medium"
|
||||
type="checkbox"
|
||||
checked={ instance.slack }
|
||||
onClick={ onChangeOption }
|
||||
className="mr-8"
|
||||
label="Slack"
|
||||
/>
|
||||
<Checkbox
|
||||
name="email"
|
||||
type="checkbox"
|
||||
checked={ instance.email }
|
||||
onClick={ onChangeOption }
|
||||
className="mr-8"
|
||||
label="Email"
|
||||
/>
|
||||
<Checkbox
|
||||
name="webhook"
|
||||
type="checkbox"
|
||||
checked={ instance.webhook }
|
||||
onClick={ onChangeOption }
|
||||
label="Webhook"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{ instance.slack && (
|
||||
<div className="flex items-start my-4">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'Slack'}</label>
|
||||
<div className="w-4/6">
|
||||
<DropdownChips
|
||||
fluid
|
||||
selected={instance.slackInput}
|
||||
options={slackChannels}
|
||||
placeholder="Select Channel"
|
||||
onChange={(selected) => props.edit({ 'slackInput': selected })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{instance.email && (
|
||||
<div className="flex items-start my-4">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'Email'}</label>
|
||||
<div className="w-4/6">
|
||||
<DropdownChips
|
||||
textFiled
|
||||
validate={validateEmail}
|
||||
selected={instance.emailInput}
|
||||
placeholder="Type and press Enter key"
|
||||
onChange={(selected) => props.edit({ 'emailInput': selected })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{instance.webhook && (
|
||||
<div className="flex items-start my-4">
|
||||
<label className="w-2/6 flex-shrink-0 font-normal pt-2">{'Webhook'}</label>
|
||||
<DropdownChips
|
||||
fluid
|
||||
selected={instance.webhookInput}
|
||||
options={webhooks}
|
||||
placeholder="Select Webhook"
|
||||
onChange={(selected) => props.edit({ 'webhookInput': selected })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center justify-between absolute bottom-0 left-0 right-0 p-6 border-t">
|
||||
<div className="flex items-center">
|
||||
<Button
|
||||
loading={loading}
|
||||
primary
|
||||
type="submit"
|
||||
disabled={loading || !instance.validate()}
|
||||
id="submit-button"
|
||||
>
|
||||
{instance.exists() ? 'Update' : 'Create'}
|
||||
</Button>
|
||||
<div className="mx-1" />
|
||||
<Button basic onClick={props.onClose}>Cancel</Button>
|
||||
</div>
|
||||
<div>
|
||||
{instance.exists() && (
|
||||
<Button
|
||||
hover
|
||||
loading={deleting}
|
||||
type="button"
|
||||
outline plain
|
||||
onClick={() => onDelete(instance)}
|
||||
id="trash-button"
|
||||
>
|
||||
<Icon name="trash" color="gray-medium" size="18" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
instance: state.getIn(['alerts', 'instance']),
|
||||
loading: state.getIn(['alerts', 'saveRequest', 'loading']),
|
||||
deleting: state.getIn(['alerts', 'removeRequest', 'loading'])
|
||||
}))(AlertForm)
|
||||
55
frontend/app/components/Alerts/AlertItem.js
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import React from 'react'
|
||||
import cn from 'classnames';
|
||||
import stl from './alertItem.css';
|
||||
import AlertTypeLabel from './AlertTypeLabel';
|
||||
|
||||
const AlertItem = props => {
|
||||
const { alert, onEdit, active } = props;
|
||||
|
||||
const getThreshold = threshold => {
|
||||
if (threshold === 15) return '15 Minutes';
|
||||
if (threshold === 30) return '30 Minutes';
|
||||
if (threshold === 60) return '1 Hour';
|
||||
if (threshold === 120) return '2 Hours';
|
||||
if (threshold === 240) return '4 Hours';
|
||||
if (threshold === 1440) return '1 Day';
|
||||
}
|
||||
|
||||
const getNotifyChannel = alert => {
|
||||
let str = '';
|
||||
if (alert.slack)
|
||||
str = 'Slack';
|
||||
if (alert.email)
|
||||
str += (str === '' ? '' : ' and ')+ 'Email';
|
||||
if (alert.webhool)
|
||||
str += (str === '' ? '' : ' and ')+ 'Webhook';
|
||||
if (str === '')
|
||||
return 'OpenReplay';
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
const isThreshold = alert.detectionMethod === 'threshold';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(stl.wrapper, 'p-4 py-6 relative group cursor-pointer', { [stl.active]: active })}
|
||||
onClick={onEdit}
|
||||
id="alert-item"
|
||||
>
|
||||
<AlertTypeLabel type={alert.detectionMethod} />
|
||||
<div className="capitalize font-medium">{alert.name}</div>
|
||||
<div className="mt-2 text-sm color-gray-medium">
|
||||
{alert.detectionMethod === 'threshold' && (
|
||||
<div>When <span className="italic font-medium">{alert.metric.text}</span> is {alert.condition.text} <span className="italic font-medium">{alert.query.right} {alert.metric.unit}</span> over the past <span className="italic font-medium">{getThreshold(alert.currentPeriod)}</span>, notify me on <span>{getNotifyChannel(alert)}</span>.</div>
|
||||
)}
|
||||
|
||||
{alert.detectionMethod === 'change' && (
|
||||
<div>When the <span className="italic font-medium">{alert.options.change}</span> of <span className="italic font-medium">{alert.metric.text}</span> is {alert.condition.text} <span className="italic font-medium">{alert.query.right} {alert.metric.unit}</span> over the past <span className="italic font-medium">{getThreshold(alert.currentPeriod)}</span> compared to the previous <span className="italic font-medium">{getThreshold(alert.previousPeriod)}</span>, notify me on {getNotifyChannel(alert)}.</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertItem
|
||||
13
frontend/app/components/Alerts/AlertTypeLabel.js
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import React from 'react'
|
||||
import cn from 'classnames'
|
||||
import stl from './alertTypeLabel.css'
|
||||
|
||||
function AlertTypeLabel({ filterKey, type = '' }) {
|
||||
return (
|
||||
<div className={ cn("rounded-full px-2 text-xs mb-2 fit-content uppercase color-gray-darkest", stl.wrapper, { [stl.alert] : filterKey === 'alert', }) }>
|
||||
{ type }
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default AlertTypeLabel
|
||||
100
frontend/app/components/Alerts/Alerts.js
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import AlertsList from './AlertsList'
|
||||
import { SlideModal, IconButton } from 'UI';
|
||||
import { init, edit, save, remove } from 'Duck/alerts';
|
||||
import { fetchList as fetchWebhooks } from 'Duck/webhook';
|
||||
import AlertForm from './AlertForm';
|
||||
import { connect } from 'react-redux';
|
||||
import { setShowAlerts } from 'Duck/dashboard';
|
||||
import { EMAIL, SLACK, WEBHOOK } from 'App/constants/schedule';
|
||||
import { confirm } from 'UI/Confirmation';
|
||||
|
||||
const Alerts = props => {
|
||||
const { webhooks, setShowAlerts } = props;
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchWebhooks();
|
||||
}, [])
|
||||
|
||||
const slackChannels = webhooks.filter(hook => hook.type === SLACK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS();
|
||||
const hooks = webhooks.filter(hook => hook.type === WEBHOOK).map(({ webhookId, name }) => ({ value: webhookId, text: name })).toJS();
|
||||
|
||||
const saveAlert = instance => {
|
||||
const wasUpdating = instance.exists();
|
||||
props.save(instance).then(() => {
|
||||
if (!wasUpdating) {
|
||||
toggleForm(null, false);
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const onDelete = async (instance) => {
|
||||
if (await confirm({
|
||||
header: 'Confirm',
|
||||
confirmButton: 'Yes, Delete',
|
||||
confirmation: `Are you sure you want to permanently delete this alert?`
|
||||
})) {
|
||||
props.remove(instance.alertId).then(() => {
|
||||
toggleForm(null, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const toggleForm = (instance, state) => {
|
||||
if (instance) {
|
||||
props.init(instance)
|
||||
}
|
||||
return setShowForm(state ? state : !showForm);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<SlideModal
|
||||
title={
|
||||
<div className="flex items-center">
|
||||
<span className="mr-3">{ 'Alerts' }</span>
|
||||
<IconButton
|
||||
circle
|
||||
size="small"
|
||||
icon="plus"
|
||||
outline
|
||||
id="add-button"
|
||||
onClick={ () => toggleForm({}, true) }
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
isDisplayed={ true }
|
||||
onClose={ () => {
|
||||
toggleForm({}, false);
|
||||
setShowAlerts(false);
|
||||
} }
|
||||
size="small"
|
||||
content={
|
||||
<AlertsList
|
||||
onEdit={alert => {
|
||||
toggleForm(alert, true)
|
||||
}}
|
||||
/>
|
||||
}
|
||||
detailContent={
|
||||
showForm && (
|
||||
<AlertForm
|
||||
edit={props.edit}
|
||||
slackChannels={slackChannels}
|
||||
webhooks={hooks}
|
||||
onSubmit={saveAlert}
|
||||
onClose={ () => toggleForm({}, false) }
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
webhooks: state.getIn(['webhooks', 'list']),
|
||||
instance: state.getIn(['alerts', 'instance']),
|
||||
}), { init, edit, save, remove, fetchWebhooks, setShowAlerts })(Alerts)
|
||||
56
frontend/app/components/Alerts/AlertsList.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { Loader, NoContent, Input } from 'UI';
|
||||
import AlertItem from './AlertItem'
|
||||
import { fetchList, init } from 'Duck/alerts';
|
||||
import { connect } from 'react-redux';
|
||||
import { getRE } from 'App/utils';
|
||||
|
||||
const AlertsList = props => {
|
||||
const { loading, list, instance, onEdit } = props;
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
props.fetchList()
|
||||
}, [])
|
||||
|
||||
const filterRE = getRE(query, 'i');
|
||||
const _filteredList = list.filter(({ name, query: { left } }) => filterRE.test(name) || filterRE.test(left));
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-3 w-full px-3">
|
||||
<Input
|
||||
name="searchQuery"
|
||||
fluid
|
||||
placeholder="Search by Name or Metric"
|
||||
onChange={({ target: { value } }) => setQuery(value)}
|
||||
/>
|
||||
</div>
|
||||
<Loader loading={ loading }>
|
||||
<NoContent
|
||||
title="No data available."
|
||||
size="small"
|
||||
show={ list.size === 0 }
|
||||
>
|
||||
<div className="bg-white">
|
||||
{_filteredList.map(a => (
|
||||
<div className="border-b" key={a.key}>
|
||||
<AlertItem
|
||||
active={instance.alertId === a.alertId}
|
||||
alert={a}
|
||||
onEdit={() => onEdit(a.toData())}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</NoContent>
|
||||
</Loader>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
list: state.getIn(['alerts', 'list']).sort((a, b ) => b.createdAt - a.createdAt),
|
||||
instance: state.getIn(['alerts', 'instance']),
|
||||
loading: state.getIn(['alerts', 'loading'])
|
||||
}), { fetchList, init })(AlertsList)
|
||||
|
|
@ -0,0 +1,79 @@
|
|||
import React from 'react'
|
||||
import { Dropdown, TagBadge } from 'UI';
|
||||
|
||||
const DropdownChips = ({
|
||||
textFiled = false,
|
||||
validate = null,
|
||||
placeholder = '',
|
||||
selected = [],
|
||||
options = [],
|
||||
badgeClassName = 'lowercase',
|
||||
onChange = () => null,
|
||||
...props
|
||||
}) => {
|
||||
const onRemove = id => {
|
||||
onChange(selected.filter(i => i !== id))
|
||||
}
|
||||
|
||||
const onSelect = (e, { name, value }) => {
|
||||
const newSlected = selected.concat(value);
|
||||
onChange(newSlected)
|
||||
};
|
||||
|
||||
const onKeyPress = e => {
|
||||
const val = e.target.value;
|
||||
if (e.key !== 'Enter' || selected.includes(val)) return;
|
||||
if (validate && !validate(val)) return;
|
||||
|
||||
const newSlected = selected.concat(val);
|
||||
e.target.value = '';
|
||||
onChange(newSlected);
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}
|
||||
|
||||
const _options = options.filter(item => !selected.includes(item.value))
|
||||
|
||||
const renderBadge = item => {
|
||||
const val = typeof item === 'string' ? item : item.value;
|
||||
const text = typeof item === 'string' ? item : item.text;
|
||||
return (
|
||||
<TagBadge
|
||||
className={badgeClassName}
|
||||
key={ text }
|
||||
text={ text }
|
||||
hashed={false}
|
||||
onRemove={ () => onRemove(val) }
|
||||
outline={ true }
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full">
|
||||
{textFiled ? (
|
||||
<input type="text" onKeyPress={onKeyPress} placeholder={placeholder} />
|
||||
) : (
|
||||
<Dropdown
|
||||
placeholder={placeholder}
|
||||
search
|
||||
selection
|
||||
options={ _options }
|
||||
name="webhookInput"
|
||||
value={ '' }
|
||||
onChange={ onSelect }
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
<div className="flex flex-wrap mt-3">
|
||||
{
|
||||
textFiled ?
|
||||
selected.map(renderBadge) :
|
||||
options.filter(i => selected.includes(i.value)).map(renderBadge)
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default DropdownChips
|
||||
1
frontend/app/components/Alerts/DropdownChips/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './DropdownChips'
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import React from 'react';
|
||||
import { Button } from 'UI';
|
||||
import stl from './listItem.css';
|
||||
import cn from 'classnames';
|
||||
import AlertTypeLabel from '../../AlertTypeLabel';
|
||||
|
||||
const ListItem = ({ alert, onClear, loading, onNavigate }) => {
|
||||
return (
|
||||
<div className={cn(stl.wrapper, 'group', { [stl.viewed] : alert.viewed })}>
|
||||
<div className="flex justify-between items-center">
|
||||
<div className="text-sm">{alert.createdAt && alert.createdAt.toFormat('LLL dd, yyyy, hh:mm a')}</div>
|
||||
<div className={ cn("invisible", { 'group-hover:visible' : !alert.viewed})} >
|
||||
<Button plain simple loading={loading} noPadding>
|
||||
<span className={ cn("text-sm color-gray-medium", { 'invisible' : loading })} onClick={onClear}>{'IGNORE'}</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<AlertTypeLabel
|
||||
type={alert.options.sourceMeta}
|
||||
filterKey={alert.filterKey}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<h2 className="mb-2 flex items-center">
|
||||
{alert.title}
|
||||
</h2>
|
||||
<div className="mb-2 text-sm text-justify break-all">{alert.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default ListItem
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { default } from './ListItem';
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
.wrapper {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.viewed {
|
||||
background-color: $gray-lightest;
|
||||
}
|
||||
144
frontend/app/components/Alerts/Notifications/Notifications.js
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import React from 'react';
|
||||
import stl from './notifications.css';
|
||||
import ListItem from './ListItem';
|
||||
import { connect } from 'react-redux';
|
||||
import { Button, SlideModal, Icon, Popup, NoContent, SegmentSelection } from 'UI';
|
||||
import { fetchList, setViewed, setLastRead, clearAll } from 'Duck/notifications';
|
||||
import withToggle from 'Components/hocs/withToggle';
|
||||
import { withRouter } from 'react-router-dom';
|
||||
import { fetchList as fetchAlerts, init as initAlert } from 'Duck/alerts';
|
||||
import cn from 'classnames';
|
||||
|
||||
const AUTOREFRESH_INTERVAL = 5 * 60 * 1000;
|
||||
|
||||
@withToggle('visible', 'toggleVisisble')
|
||||
@withRouter
|
||||
class Notifications extends React.Component {
|
||||
state = { alertType: '' };
|
||||
|
||||
constructor(props) {
|
||||
super(props);
|
||||
props.fetchList();
|
||||
setInterval(() => {
|
||||
props.fetchList();
|
||||
}, AUTOREFRESH_INTERVAL);
|
||||
}
|
||||
|
||||
writeOption = (e, { name, value }) => this.setState({ [ name ]: value });
|
||||
|
||||
navigateToUrl = notification => { // TODO should be able to open the alert edit form
|
||||
if (notification.options.source === 'ALERT') {
|
||||
const { initAlert } = this.props;
|
||||
this.props.fetchAlerts().then(function() {
|
||||
const { alerts } = this.props;
|
||||
const alert = alerts.find(i => i.alertId === notification.options.sourceId)
|
||||
initAlert(alert.toJS());
|
||||
}.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
onClearAll = () => {
|
||||
const { notifications } = this.props;
|
||||
const firstItem = notifications.first();
|
||||
this.props.clearAll({ endTimestamp: firstItem.createdAt.ts });
|
||||
}
|
||||
|
||||
onClear = notification => {
|
||||
this.props.setViewed(notification.notificationId)
|
||||
}
|
||||
|
||||
toggleModal = () => {
|
||||
this.props.toggleVisisble(!this.props.visible);
|
||||
}
|
||||
|
||||
render() {
|
||||
const { notifications, visible, loading, clearing, clearingAll } = this.props;
|
||||
const { alertType } = this.state;
|
||||
const unReadNotificationsCount = notifications.filter(({viewed}) => !viewed).size
|
||||
|
||||
const filteredList = alertType === '' ?
|
||||
notifications :
|
||||
notifications.filter(i => i.filterKey === alertType);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Popup
|
||||
trigger={
|
||||
<div className={ stl.button } onClick={ this.toggleModal } data-active={ visible }>
|
||||
<div className={ stl.counter } data-hidden={ unReadNotificationsCount === 0 }>
|
||||
{ unReadNotificationsCount }
|
||||
</div>
|
||||
<Icon name="bell" size="18" />
|
||||
</div>
|
||||
}
|
||||
content={ `Alerts` }
|
||||
size="tiny"
|
||||
inverted
|
||||
position="top center"
|
||||
/>
|
||||
<SlideModal
|
||||
title={
|
||||
<div className="flex items-center justify-between">
|
||||
<div>Alerts</div>
|
||||
{ unReadNotificationsCount > 0 && (
|
||||
<div className="">
|
||||
<Button
|
||||
loading={clearingAll}
|
||||
plain
|
||||
simple
|
||||
onClick={this.props.setLastRead}
|
||||
disabled={unReadNotificationsCount === 0}
|
||||
noPadding
|
||||
>
|
||||
<span
|
||||
className={ cn("text-sm color-gray-medium", { 'invisible' : clearingAll })}
|
||||
onClick={this.onClearAll}>
|
||||
IGNORE ALL
|
||||
</span>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
right
|
||||
isDisplayed={ visible }
|
||||
onClose={ visible && this.toggleModal }
|
||||
bgColor="white"
|
||||
size="small"
|
||||
content={
|
||||
<div className="">
|
||||
<NoContent
|
||||
title="No Alerts!"
|
||||
subtext="There are no alerts."
|
||||
icon="exclamation-circle"
|
||||
show={ !loading && notifications.size === 0 }
|
||||
size="small"
|
||||
>
|
||||
{
|
||||
filteredList.map(item => (
|
||||
<div className="border-b" key={item.key}>
|
||||
<ListItem
|
||||
key={item.key}
|
||||
alert={item}
|
||||
onClear={() => this.onClear(item)}
|
||||
loading={clearing}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
}
|
||||
</NoContent>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export default connect(state => ({
|
||||
notifications: state.getIn(['notifications', 'list']),
|
||||
loading: state.getIn(['notifications', 'fetchRequest', 'loading']),
|
||||
clearing: state.getIn(['notifications', 'setViewed', 'loading']),
|
||||
clearingAll: state.getIn(['notifications', 'clearAll', 'loading']),
|
||||
alerts: state.getIn(['alerts', 'list']),
|
||||
}), { fetchList, setLastRead, setViewed, clearAll, fetchAlerts, initAlert })(Notifications);
|
||||
1
frontend/app/components/Alerts/Notifications/index.js
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { default } from './Notifications';
|
||||
|
|
@ -0,0 +1,39 @@
|
|||
.wrapper {
|
||||
position: relative;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
.button {
|
||||
position: relative;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 15px;
|
||||
height: 50px;
|
||||
transition: all 0.3s;
|
||||
|
||||
&:hover {
|
||||
background-color: $gray-lightest;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
&[data-active=true] {
|
||||
background-color: $gray-lightest;
|
||||
}
|
||||
}
|
||||
|
||||
.counter {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 24px;
|
||||
background-color: $red;
|
||||
color: white;
|
||||
font-size: 9px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3px;
|
||||
}
|
||||