diff --git a/frontend/app/api_middleware.js b/frontend/app/api_middleware.js index 783ebe8c3..1846a9dbc 100644 --- a/frontend/app/api_middleware.js +++ b/frontend/app/api_middleware.js @@ -2,27 +2,27 @@ import logger from 'App/logger'; import APIClient from './api_client'; import { UPDATE, DELETE } from './duck/jwt'; -export default store => next => (action) => { +export default (store) => (next) => (action) => { const { types, call, ...rest } = action; if (!call) { return next(action); } - const [ REQUEST, SUCCESS, FAILURE ] = types; + const [REQUEST, SUCCESS, FAILURE] = types; next({ ...rest, type: REQUEST }); const client = new APIClient(); return call(client) - .then(async response => { + .then(async (response) => { if (response.status === 403) { next({ type: DELETE }); } if (!response.ok) { - const text = await response.text() + const text = await response.text(); return Promise.reject(text); } - return response.json() + return response.json(); }) - .then(json => json || {}) // TEMP TODO on server: no empty responces + .then((json) => json || {}) // TEMP TODO on server: no empty responces .then(({ jwt, errors, data }) => { if (errors) { next({ type: FAILURE, errors, data }); @@ -34,14 +34,22 @@ export default store => next => (action) => { } }) .catch((e) => { - logger.error("Error during API request. ", e) - return next({ type: FAILURE, errors: JSON.parse(e).errors || [] }); + logger.error('Error during API request. ', e); + return next({ type: FAILURE, errors: parseError(e) }); }); }; +function parseError(e) { + try { + return JSON.parse(e).errors || []; + } catch { + return e; + } +} + function jwtExpired(token) { try { - const base64Url = token.split('.')[ 1 ]; + 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) diff --git a/frontend/app/components/Alerts/Notifications/Notifications.tsx b/frontend/app/components/Alerts/Notifications/Notifications.tsx index d6327d530..1ba2ce49f 100644 --- a/frontend/app/components/Alerts/Notifications/Notifications.tsx +++ b/frontend/app/components/Alerts/Notifications/Notifications.tsx @@ -34,7 +34,7 @@ function Notifications(props: Props) {
{ count }
- + )); diff --git a/frontend/app/components/Header/DefaultMenuView/DefaultMenuView.tsx b/frontend/app/components/Header/DefaultMenuView/DefaultMenuView.tsx new file mode 100644 index 000000000..a8b321c8c --- /dev/null +++ b/frontend/app/components/Header/DefaultMenuView/DefaultMenuView.tsx @@ -0,0 +1,71 @@ +import React from 'react'; +import { NavLink, withRouter } from 'react-router-dom'; +import { + sessions, + metrics, + assist, + client, + dashboard, + withSiteId, + CLIENT_DEFAULT_TAB, +} from 'App/routes'; +import SiteDropdown from '../SiteDropdown'; +import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import styles from '../header.module.css'; + +const DASHBOARD_PATH = dashboard(); +const METRICS_PATH = metrics(); +const SESSIONS_PATH = sessions(); +const ASSIST_PATH = assist(); + +interface Props { + siteId: any; +} +function DefaultMenuView(props: Props) { + const { siteId } = props; + return ( +
+ +
+
+ +
+
+ v{window.env.VERSION} +
+
+
+ + {/*
*/} + + + {'Sessions'} + + + {'Assist'} + + { + return ( + location.pathname.includes(DASHBOARD_PATH) || location.pathname.includes(METRICS_PATH) + ); + }} + > + {'Dashboards'} + +
+ ); +} + +export default DefaultMenuView; diff --git a/frontend/app/components/Header/DefaultMenuView/index.ts b/frontend/app/components/Header/DefaultMenuView/index.ts new file mode 100644 index 000000000..8b21e4cfb --- /dev/null +++ b/frontend/app/components/Header/DefaultMenuView/index.ts @@ -0,0 +1 @@ +export { default } from './DefaultMenuView' \ No newline at end of file diff --git a/frontend/app/components/Header/Header.js b/frontend/app/components/Header/Header.js index 6159197b1..bd89b176d 100644 --- a/frontend/app/components/Header/Header.js +++ b/frontend/app/components/Header/Header.js @@ -16,10 +16,11 @@ import { logout } from 'Duck/user'; import { Icon, Popup } from 'UI'; import SiteDropdown from './SiteDropdown'; import styles from './header.module.css'; -import OnboardingExplore from './OnboardingExplore/OnboardingExplore' +import OnboardingExplore from './OnboardingExplore/OnboardingExplore'; import Announcements from '../Announcements'; import Notifications from '../Alerts/Notifications'; import { init as initSite } from 'Duck/site'; +import { getInitials } from 'App/utils'; import ErrorGenPanel from 'App/dev/components'; import Alerts from '../Alerts/Alerts'; @@ -27,6 +28,10 @@ import AnimatedSVG, { ICONS } from '../shared/AnimatedSVG/AnimatedSVG'; import { fetchListActive as fetchMetadata } from 'Duck/customField'; import { useStore } from 'App/mstore'; import { useObserver } from 'mobx-react-lite'; +import UserMenu from './UserMenu'; +import SettingsMenu from './SettingsMenu'; +import DefaultMenuView from './DefaultMenuView'; +import PreferencesView from './PreferencesView'; const DASHBOARD_PATH = dashboard(); const ALERTS_PATH = alerts(); @@ -37,20 +42,25 @@ const CLIENT_PATH = client(CLIENT_DEFAULT_TAB); const Header = (props) => { const { - sites, location, account, - onLogoutClick, siteId, - boardingCompletion = 100, showAlerts = false, + sites, + location, + account, + onLogoutClick, + siteId, + boardingCompletion = 100, + showAlerts = false, } = props; - const name = account.get('name').split(" ")[0]; - const [hideDiscover, setHideDiscover] = useState(false) + const name = account.get('name'); + const [hideDiscover, setHideDiscover] = useState(false); const { userStore, notificationStore } = useStore(); const initialDataFetched = useObserver(() => userStore.initialDataFetched); let activeSite = null; + const isPreferences = window.location.pathname.includes('/client/'); const onAccountClick = () => { props.history.push(CLIENT_PATH); - } + }; useEffect(() => { if (!account.id || initialDataFetched) return; @@ -67,92 +77,62 @@ const Header = (props) => { }, [account]); useEffect(() => { - activeSite = sites.find(s => s.id == siteId); + activeSite = sites.find((s) => s.id == siteId); props.initSite(activeSite); - }, [siteId]) + }, [siteId]); return ( -
- -
-
- -
-
v{window.env.VERSION}
-
-
- -
- - - { 'Sessions' } - - - { 'Assist' } - - { - return location.pathname.includes(DASHBOARD_PATH) - || location.pathname.includes(METRICS_PATH) - || location.pathname.includes(ALERTS_PATH) - }} - > - { 'Dashboards' } - -
- -
- - { (boardingCompletion < 100 && !hideDiscover) && ( +
+ {!isPreferences && } + {isPreferences && } +
+ {boardingCompletion < 100 && !hideDiscover && ( setHideDiscover(true)} /> -
)} -
- - + +
+ + + + + +
-
-
+
-
{ name }
- +
+ {getInitials(name)} +
-
    -
  • -
  • -
+
+ + {}
- { } + {showAlerts && }
); }; -export default withRouter(connect( - state => ({ - account: state.getIn([ 'user', 'account' ]), - siteId: state.getIn([ 'site', 'siteId' ]), - sites: state.getIn([ 'site', 'list' ]), - showAlerts: state.getIn([ 'dashboard', 'showAlerts' ]), - boardingCompletion: state.getIn([ 'dashboard', 'boardingCompletion' ]) - }), - { onLogoutClick: logout, initSite, fetchMetadata }, -)(Header)); +export default withRouter( + connect( + (state) => ({ + account: state.getIn(['user', 'account']), + siteId: state.getIn(['site', 'siteId']), + sites: state.getIn(['site', 'list']), + showAlerts: state.getIn(['dashboard', 'showAlerts']), + boardingCompletion: state.getIn(['dashboard', 'boardingCompletion']), + }), + { onLogoutClick: logout, initSite, fetchMetadata } + )(Header) +); diff --git a/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx b/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx index 9c438c50f..a0dd30244 100644 --- a/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx +++ b/frontend/app/components/Header/NewProjectButton/NewProjectButton.tsx @@ -24,13 +24,17 @@ function NewProjectButton(props: Props) { }; return ( -
- - Add New Project -
+
  • + + Add Project +
  • + //
    + // + // Add New Project + //
    ); } diff --git a/frontend/app/components/Header/PreferencesView/PreferencesView.tsx b/frontend/app/components/Header/PreferencesView/PreferencesView.tsx new file mode 100644 index 000000000..0ba533350 --- /dev/null +++ b/frontend/app/components/Header/PreferencesView/PreferencesView.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Icon } from 'UI'; +import { withRouter } from 'react-router-dom'; +import ProjectCodeSnippet from 'App/components/Onboarding/components/OnboardingTabs/ProjectCodeSnippet'; + +interface Props { + history: any; +} +function PreferencesView(props: Props) { + const onExit = () => { + props.history.push('/'); + }; + return ( + <> +
    + + Exit Preferences +
    + +
    + + Changes applied at organization level +
    + + ); +} + +export default withRouter(PreferencesView); diff --git a/frontend/app/components/Header/PreferencesView/index.ts b/frontend/app/components/Header/PreferencesView/index.ts new file mode 100644 index 000000000..774801dcc --- /dev/null +++ b/frontend/app/components/Header/PreferencesView/index.ts @@ -0,0 +1 @@ +export { default } from './PreferencesView'; \ No newline at end of file diff --git a/frontend/app/components/Header/SettingsMenu/SettingsMenu.tsx b/frontend/app/components/Header/SettingsMenu/SettingsMenu.tsx new file mode 100644 index 000000000..58bc09b8c --- /dev/null +++ b/frontend/app/components/Header/SettingsMenu/SettingsMenu.tsx @@ -0,0 +1,66 @@ +import React from 'react'; +import cn from 'classnames'; +import { Icon } from 'UI'; +import { CLIENT_TABS, client as clientRoute } from 'App/routes'; +import { withRouter, RouteComponentProps } from 'react-router'; + +interface Props { + history: any; + className: string; + account: any; +} +function SettingsMenu(props: RouteComponentProps) { + const { history, account, className }: any = props; + const isAdmin = account.admin || account.superAdmin; + const navigateTo = (path: any) => { + switch (path) { + case 'projects': + return history.push(clientRoute(CLIENT_TABS.SITES)); + case 'team': + return history.push(clientRoute(CLIENT_TABS.MANAGE_USERS)); + case 'metadata': + return history.push(clientRoute(CLIENT_TABS.CUSTOM_FIELDS)); + case 'webhooks': + return history.push(clientRoute(CLIENT_TABS.WEBHOOKS)); + case 'integrations': + return history.push(clientRoute(CLIENT_TABS.INTEGRATIONS)); + case 'notifications': + return history.push(clientRoute(CLIENT_TABS.NOTIFICATIONS)); + } + }; + return ( +
    + {isAdmin && ( + <> + navigateTo('projects')} label="Projects" icon="folder2" /> + navigateTo('team')} label="Team" icon="users" /> + + )} + navigateTo('metadata')} label="Metadata" icon="tags" /> + navigateTo('webhooks')} label="Webhooks" icon="link-45deg" /> + navigateTo('integrations')} label="Integrations" icon="puzzle" /> + navigateTo('notifications')} + label="Notifications" + icon="bell-slash" + /> +
    + ); +} + +export default withRouter(SettingsMenu); + +function MenuItem({ onClick, label, icon }: any) { + return ( +
    + + +
    + ); +} diff --git a/frontend/app/components/Header/SettingsMenu/index.ts b/frontend/app/components/Header/SettingsMenu/index.ts new file mode 100644 index 000000000..0133de70a --- /dev/null +++ b/frontend/app/components/Header/SettingsMenu/index.ts @@ -0,0 +1 @@ +export { default } from './SettingsMenu'; \ No newline at end of file diff --git a/frontend/app/components/Header/SiteDropdown.js b/frontend/app/components/Header/SiteDropdown.js index 7a0205be3..90d024910 100644 --- a/frontend/app/components/Header/SiteDropdown.js +++ b/frontend/app/components/Header/SiteDropdown.js @@ -3,7 +3,6 @@ import { connect } from 'react-redux'; import { setSiteId } from 'Duck/site'; import { withRouter } from 'react-router-dom'; import { hasSiteId, siteChangeAvaliable } from 'App/routes'; -import { STATUS_COLOR_MAP, GREEN } from 'Types/site'; import { Icon } from 'UI'; import { pushNewSite } from 'Duck/user'; import { init } from 'Duck/site'; @@ -13,7 +12,6 @@ import { clearSearch } from 'Duck/search'; import { clearSearch as clearSearchLive } from 'Duck/liveSearch'; import { fetchListActive as fetchIntegrationVariables } from 'Duck/customField'; import { withStore } from 'App/mstore'; -import AnimatedSVG, { ICONS } from '../shared/AnimatedSVG/AnimatedSVG'; import NewProjectButton from './NewProjectButton'; @withStore @@ -63,37 +61,27 @@ export default class SiteDropdown extends React.PureComponent { account, location: { pathname }, } = this.props; - const { showProductModal } = this.state; const isAdmin = account.admin || account.superAdmin; const activeSite = sites.find((s) => s.id == siteId); const disabled = !siteChangeAvaliable(pathname); const showCurrent = hasSiteId(pathname) || siteChangeAvaliable(pathname); - // const canAddSites = isAdmin && account.limits.projects && account.limits.projects.remaining !== 0; return (
    - {showCurrent ? ( - activeSite && activeSite.status === GREEN ? ( - - ) : ( - - ) - ) : ( - - )}
    {showCurrent && activeSite ? activeSite.host : 'All Projects'}
      - {!showCurrent &&
    • {'Project selection is not applicable.'}
    • } + {isAdmin && ( + + )} {sites.map((site) => (
    • this.switchSite(site.id)}> -
      - {site.host} + + {site.host}
    • ))}
    -
    ); diff --git a/frontend/app/components/Header/UserMenu/UserMenu.tsx b/frontend/app/components/Header/UserMenu/UserMenu.tsx new file mode 100644 index 000000000..c88f7ff1a --- /dev/null +++ b/frontend/app/components/Header/UserMenu/UserMenu.tsx @@ -0,0 +1,57 @@ +import React from 'react'; +import { withRouter, RouteComponentProps } from 'react-router-dom'; +import { connect } from 'react-redux'; +import { logout } from 'Duck/user'; +import { client, CLIENT_DEFAULT_TAB } from 'App/routes'; +import { Icon } from 'UI'; +import cn from 'classnames'; +import { getInitials } from 'App/utils'; + +const CLIENT_PATH = client(CLIENT_DEFAULT_TAB); + +interface Props { + history: any; + onLogoutClick: any; + className: string; + account: any; +} +function UserMenu(props: RouteComponentProps) { + const { account, history, className, onLogoutClick }: any = props; + + const onAccountClick = () => { + history.push(CLIENT_PATH); + }; + return ( +
    +
    +
    + {getInitials(account.name)} +
    +
    +
    {account.name}
    +
    {account.superAdmin ? 'Super Admin' : (account.admin ? 'Admin' : 'Member') } - {account.email}
    +
    +
    +
    + + +
    +
    + + +
    +
    + ); +} + +export default connect( + (state: any) => ({ + account: state.getIn(['user', 'account']), + }), + { onLogoutClick: logout } +)(withRouter(UserMenu)) as React.FunctionComponent>; + +// export default UserMenu; diff --git a/frontend/app/components/Header/UserMenu/index.ts b/frontend/app/components/Header/UserMenu/index.ts new file mode 100644 index 000000000..eeb4a1e9e --- /dev/null +++ b/frontend/app/components/Header/UserMenu/index.ts @@ -0,0 +1 @@ +export { default } from './UserMenu'; \ No newline at end of file diff --git a/frontend/app/components/Header/header.module.css b/frontend/app/components/Header/header.module.css index 9852b7436..5b2bbd46f 100644 --- a/frontend/app/components/Header/header.module.css +++ b/frontend/app/components/Header/header.module.css @@ -4,13 +4,13 @@ $height: 50px; .header { - position: fixed; - width: 100%; - display: flex; - justify-content: space-between; + /* position: fixed; */ + /* width: 100%; */ + /* display: flex; */ + /* justify-content: space-between; */ border-bottom: solid thin $gray-light; /* padding: 0 15px; */ - background: $white; + /* background: $white; */ z-index: $header; } @@ -45,7 +45,7 @@ $height: 50px; } .right { - margin-left: auto; + /* margin-left: auto; */ position: relative; cursor: default; display: flex; @@ -72,11 +72,10 @@ $height: 50px; .userDetails { display: flex; align-items: center; - justify-content: flex-end; + justify-content: center; position: relative; - padding: 0 5px 0 15px; + padding: 0 10px; transition: all 0.2s; - min-width: 100px; &:hover { background-color: $gray-lightest; @@ -102,7 +101,7 @@ $height: 50px; border-top: 1px solid $gray-light; } } - & a, & button { + /* & a, & button { color: $gray-darkest; display: block; cursor: pointer; @@ -113,7 +112,7 @@ $height: 50px; &:hover { background-color: $gray-lightest; } - } + } */ } .userIcon { diff --git a/frontend/app/components/Header/siteDropdown.module.css b/frontend/app/components/Header/siteDropdown.module.css index 886c60016..508eb81c8 100644 --- a/frontend/app/components/Header/siteDropdown.module.css +++ b/frontend/app/components/Header/siteDropdown.module.css @@ -1,15 +1,20 @@ .wrapper { display: flex; align-items: center; - border-left: solid thin $gray-light !important; + /* border-left: solid thin $gray-light !important; */ padding: 10px 10px; min-width: 180px; justify-content: flex-start; position: relative; user-select: none; - + border: solid thin transparent; + height: 30px; + border-radius: 3px; + margin: 10px; + &:hover { - background-color: $gray-lightest; + background-color: $active-blue; + /* border: solid thin $active-blue-border; */ & .drodownIcon { transform: rotate(180deg); transition: all 0.2s; @@ -39,11 +44,12 @@ & .menu { display: none; position: absolute; - top: 50px; + top: 28px; left: -1px; background-color: white; min-width: 200px; z-index: 2; + border-radius: 3px; border: 1px solid $gray-light; } @@ -68,9 +74,13 @@ &:hover { background-color: $gray-lightest; transition: all 0.2s; + color: $teal; + svg { + fill: $teal; + } } &:first-child { - border-top: 1px solid $gray-light; + /* border-top: 1px solid $gray-light; */ } } } diff --git a/frontend/app/components/Session_/Autoscroll.tsx b/frontend/app/components/Session_/Autoscroll.tsx index 051f2024f..ad2e82e01 100644 --- a/frontend/app/components/Session_/Autoscroll.tsx +++ b/frontend/app/components/Session_/Autoscroll.tsx @@ -112,15 +112,15 @@ export default class Autoscroll extends React.PureComponent -
    - {/* */} + {/*
    + {navigation && ( <> )} -
    +
    */}
    ); diff --git a/frontend/app/components/Session_/BottomBlock/infoLine.module.css b/frontend/app/components/Session_/BottomBlock/infoLine.module.css index b6798d1bf..37e47f013 100644 --- a/frontend/app/components/Session_/BottomBlock/infoLine.module.css +++ b/frontend/app/components/Session_/BottomBlock/infoLine.module.css @@ -6,7 +6,7 @@ display: flex; align-items: center; & >.infoPoint { - font-size: 12px; + font-size: 14px; display: flex; align-items: center; &:not(:last-child):after { diff --git a/frontend/app/components/Session_/Console/ConsoleContent.js b/frontend/app/components/Session_/Console/ConsoleContent.js index 54a9745d0..a7482b69e 100644 --- a/frontend/app/components/Session_/Console/ConsoleContent.js +++ b/frontend/app/components/Session_/Console/ConsoleContent.js @@ -7,7 +7,8 @@ import { LEVEL } from 'Types/session/log'; import Autoscroll from '../Autoscroll'; import BottomBlock from '../BottomBlock'; import stl from './console.module.css'; -import { Duration } from 'luxon'; +import ConsoleRow from './ConsoleRow'; +// import { Duration } from 'luxon'; const ALL = 'ALL'; const INFO = 'INFO'; @@ -83,44 +84,34 @@ export default class ConsoleContent extends React.PureComponent {
    - + No Data -
    - } size="small" show={filtered.length === 0}> +
    + } + size="small" + show={filtered.length === 0} + > - {filtered.map((l, index) => ( -
    lastIndex, - 'cursor-pointer': !isResult, - })} - onClick={() => !isResult && jump(l.time)} - > -
    - -
    -
    - {Duration.fromMillis(l.time).toFormat('mm:ss.SSS')} -
    -
    -
    {renderWithNL(l.value)}
    -
    -
    + {filtered.map((l) => ( + ))}
    diff --git a/frontend/app/components/Session_/Console/ConsoleRow/ConsoleRow.tsx b/frontend/app/components/Session_/Console/ConsoleRow/ConsoleRow.tsx new file mode 100644 index 000000000..c87ff3f9c --- /dev/null +++ b/frontend/app/components/Session_/Console/ConsoleRow/ConsoleRow.tsx @@ -0,0 +1,48 @@ +import React, { useState } from 'react'; +import cn from 'classnames'; +import stl from '../console.module.css'; +import { Icon } from 'UI'; +import JumpButton from 'Shared/DevTools/JumpButton'; + +interface Props { + log: any; + iconProps: any; + jump?: any; + renderWithNL?: any; +} +function ConsoleRow(props: Props) { + const { log, iconProps, jump, renderWithNL } = props; + const [expanded, setExpanded] = useState(false); + const lines = log.value.split('\n').filter((l: any) => !!l); + const canExpand = lines.length > 1; + return ( +
    setExpanded(!expanded)} + > +
    + +
    + {/*
    + {Duration.fromMillis(log.time).toFormat('mm:ss.SSS')} +
    */} +
    +
    + {canExpand && ( + + )} + {renderWithNL(lines.pop())} +
    + {canExpand && expanded && lines.map((l: any) =>
    {l}
    )} +
    + jump(log.time)} /> +
    + ); +} + +export default ConsoleRow; diff --git a/frontend/app/components/Session_/Console/ConsoleRow/index.ts b/frontend/app/components/Session_/Console/ConsoleRow/index.ts new file mode 100644 index 000000000..c9140d748 --- /dev/null +++ b/frontend/app/components/Session_/Console/ConsoleRow/index.ts @@ -0,0 +1 @@ +export { default } from './ConsoleRow'; diff --git a/frontend/app/components/Session_/Console/console.module.css b/frontend/app/components/Session_/Console/console.module.css index 6f7079d94..2da78f540 100644 --- a/frontend/app/components/Session_/Console/console.module.css +++ b/frontend/app/components/Session_/Console/console.module.css @@ -15,6 +15,9 @@ display: flex; align-items: flex-start; border-bottom: solid thin $gray-light-shade; + &:hover { + background-coor: $active-blue !important; + } } .timestamp { diff --git a/frontend/app/components/Session_/Exceptions/Exceptions.js b/frontend/app/components/Session_/Exceptions/Exceptions.js index b6c65b5ba..334e57688 100644 --- a/frontend/app/components/Session_/Exceptions/Exceptions.js +++ b/frontend/app/components/Session_/Exceptions/Exceptions.js @@ -22,7 +22,7 @@ import BottomBlock from '../BottomBlock'; @connectPlayer((state) => ({ logs: state.logListNow, exceptions: state.exceptionsList, - exceptionsNow: state.exceptionsListNow, + // exceptionsNow: state.exceptionsListNow, })) @connect( (state) => ({ @@ -55,15 +55,15 @@ export default class Exceptions extends React.PureComponent { const filtered = exceptions.filter((e) => filterRE.test(e.name) || filterRE.test(e.message)); - let lastIndex = -1; - filtered.forEach((item, index) => { - if ( - this.props.exceptionsNow.length > 0 && - item.time <= this.props.exceptionsNow[this.props.exceptionsNow.length - 1].time - ) { - lastIndex = index; - } - }); + // let lastIndex = -1; + // filtered.forEach((item, index) => { + // if ( + // this.props.exceptionsNow.length > 0 && + // item.time <= this.props.exceptionsNow[this.props.exceptionsNow.length - 1].time + // ) { + // lastIndex = index; + // } + // }); return ( <> @@ -113,6 +113,7 @@ export default class Exceptions extends React.PureComponent { iconPosition="left" name="filter" onChange={this.onFilterChange} + height={28} /> - + {filtered.map((e, index) => ( jump(e.time)} error={e} key={e.key} - selected={lastIndex === index} + // selected={lastIndex === index} // inactive={index > lastIndex} onErrorClick={(jsEvent) => { jsEvent.stopPropagation(); diff --git a/frontend/app/components/Session_/Network/NetworkContent.js b/frontend/app/components/Session_/Network/NetworkContent.js index 082c87aa0..e0f91587c 100644 --- a/frontend/app/components/Session_/Network/NetworkContent.js +++ b/frontend/app/components/Session_/Network/NetworkContent.js @@ -1,7 +1,7 @@ import React from 'react'; import cn from 'classnames'; // import { connectPlayer } from 'Player'; -import { QuestionMarkHint, Popup, Tabs, Input, NoContent, Icon, Button } from 'UI'; +import { QuestionMarkHint, Popup, Tabs, Input, NoContent, Icon, Toggler, Button } from 'UI'; import { getRE } from 'App/utils'; import { TYPES } from 'Types/session/resource'; import { formatBytes } from 'App/utils'; @@ -48,22 +48,17 @@ export function renderType(r) { export function renderName(r) { return ( - {r.url}
    } - > -
    {r.name}
    - + {r.url}
    }> +
    {r.name}
    + ); } export function renderStart(r) { return (
    - - {Duration.fromMillis(r.time).toFormat('mm:ss.SSS')} - - -
    - ) + */} +
    + ); } const renderXHRText = () => ( @@ -243,39 +238,45 @@ export default class NetworkContent extends React.PureComponent { iconPosition="left" name="filter" onChange={this.onFilterChange} + height={28} /> - - - 0} - /> - 0} - /> - - - - +
    +
    + {}} label="4xx-5xx Only" /> +
    + + + 0} + /> + 0} + /> + + + + +
    @@ -296,11 +297,11 @@ export default class NetworkContent extends React.PureComponent { activeIndex={lastIndex} > {[ - { - label: 'Start', - width: 120, - render: renderStart, - }, + // { + // label: 'Start', + // width: 120, + // render: renderStart, + // }, { label: 'Status', dataKey: 'status', diff --git a/frontend/app/components/Session_/Performance/Performance.tsx b/frontend/app/components/Session_/Performance/Performance.tsx index 13c135f7b..994401141 100644 --- a/frontend/app/components/Session_/Performance/Performance.tsx +++ b/frontend/app/components/Session_/Performance/Performance.tsx @@ -2,13 +2,13 @@ import React from 'react'; import { connect } from 'react-redux'; import { Controls as PlayerControls, connectPlayer } from 'Player'; import { - AreaChart, + AreaChart, Area, ComposedChart, Line, - XAxis, + XAxis, YAxis, - Tooltip, + Tooltip, ResponsiveContainer, ReferenceLine, CartesianGrid, @@ -23,40 +23,35 @@ import stl from './performance.module.css'; import BottomBlock from '../BottomBlock'; import InfoLine from '../BottomBlock/InfoLine'; - const CPU_VISUAL_OFFSET = 10; - const FPS_COLOR = '#C5E5E7'; -const FPS_STROKE_COLOR = "#92C7CA"; -const FPS_LOW_COLOR = "pink"; -const FPS_VERY_LOW_COLOR = "red"; -const CPU_COLOR = "#A8D1DE"; -const CPU_STROKE_COLOR = "#69A5B8"; +const FPS_STROKE_COLOR = '#92C7CA'; +const FPS_LOW_COLOR = 'pink'; +const FPS_VERY_LOW_COLOR = 'red'; +const CPU_COLOR = '#A8D1DE'; +const CPU_STROKE_COLOR = '#69A5B8'; const USED_HEAP_COLOR = '#A9ABDC'; -const USED_HEAP_STROKE_COLOR = "#8588CF"; +const USED_HEAP_STROKE_COLOR = '#8588CF'; const TOTAL_HEAP_STROKE_COLOR = '#4A4EB7'; -const NODES_COUNT_COLOR = "#C6A9DC"; -const NODES_COUNT_STROKE_COLOR = "#7360AC"; -const HIDDEN_SCREEN_COLOR = "#CCC"; +const NODES_COUNT_COLOR = '#C6A9DC'; +const NODES_COUNT_STROKE_COLOR = '#7360AC'; +const HIDDEN_SCREEN_COLOR = '#CCC'; - -const CURSOR_COLOR = "#394EFF"; +const CURSOR_COLOR = '#394EFF'; const Gradient = ({ color, id }) => ( - - - + + + ); - -const TOTAL_HEAP = "Allocated Heap"; -const USED_HEAP = "JS Heap"; -const FPS = "Framerate"; -const CPU = "CPU Load"; -const NODES_COUNT = "Nodes Сount"; - +const TOTAL_HEAP = 'Allocated Heap'; +const USED_HEAP = 'JS Heap'; +const FPS = 'Framerate'; +const CPU = 'CPU Load'; +const NODES_COUNT = 'Nodes Сount'; const FPSTooltip = ({ active, payload }) => { if (!active || !payload || payload.length < 3) { @@ -64,8 +59,8 @@ const FPSTooltip = ({ active, payload }) => { } if (payload[0].value === null) { return ( -
    - {"Page is not active. User switched the tab or hid the window."} +
    + {'Page is not active. User switched the tab or hid the window.'}
    ); } @@ -79,9 +74,9 @@ const FPSTooltip = ({ active, payload }) => { } return ( -
    - {`${ FPS }: `} - { Math.trunc(payload[0].value) } +
    + {`${FPS}: `} + {Math.trunc(payload[0].value)}
    ); }; @@ -91,47 +86,47 @@ const CPUTooltip = ({ active, payload }) => { return null; } return ( -
    - {`${ CPU }: `} - { payload[0].value - CPU_VISUAL_OFFSET } - {"%"} +
    + {`${CPU}: `} + {payload[0].value - CPU_VISUAL_OFFSET} + {'%'}
    ); }; -const HeapTooltip = ({ active, payload}) => { +const HeapTooltip = ({ active, payload }) => { if (!active || payload.length < 2) return null; return ( -
    +

    - {`${ TOTAL_HEAP }: `} - { formatBytes(payload[0].value)} + {`${TOTAL_HEAP}: `} + {formatBytes(payload[0].value)}

    - {`${ USED_HEAP }: `} - { formatBytes(payload[1].value)} + {`${USED_HEAP}: `} + {formatBytes(payload[1].value)}

    ); -} +}; -const NodesCountTooltip = ({ active, payload} ) => { +const NodesCountTooltip = ({ active, payload }) => { if (!active || !payload || payload.length === 0) return null; return ( -
    +

    - {`${ NODES_COUNT }: `} - { payload[0].value } + {`${NODES_COUNT}: `} + {payload[0].value}

    ); -} +}; const TICKS_COUNT = 10; function generateTicks(data: Array): Array { if (data.length === 0) return []; const minTime = data[0].time; - const maxTime = data[data.length-1].time; + const maxTime = data[data.length - 1].time; const ticks = []; const tickGap = (maxTime - minTime) / (TICKS_COUNT + 1); @@ -159,8 +154,9 @@ function addFpsMetadata(data) { } else if (point.fps < LOW_FPS) { fpsLowMarker = LOW_FPS_MARKER_VALUE; } - } - if (point.fps == null || + } + if ( + point.fps == null || (i > 0 && data[i - 1].fps == null) //|| //(i < data.length-1 && data[i + 1].fps == null) ) { @@ -174,17 +170,17 @@ function addFpsMetadata(data) { fpsLowMarker, fpsVeryLowMarker, hiddenScreenMarker, - } + }; }); } -@connect(state => ({ - userDeviceHeapSize: state.getIn([ "sessions", "current", "userDeviceHeapSize" ]), - userDeviceMemorySize: state.getIn([ "sessions", "current", "userDeviceMemorySize" ]), +@connect((state) => ({ + userDeviceHeapSize: state.getIn(['sessions', 'current', 'userDeviceHeapSize']), + userDeviceMemorySize: state.getIn(['sessions', 'current', 'userDeviceMemorySize']), })) export default class Performance extends React.PureComponent { - _timeTicks = generateTicks(this.props.performanceChartData) - _data = addFpsMetadata(this.props.performanceChartData) + _timeTicks = generateTicks(this.props.performanceChartData); + _data = addFpsMetadata(this.props.performanceChartData); // state = { // totalHeap: false, // usedHeap: true, @@ -197,7 +193,7 @@ export default class Performance extends React.PureComponent { if (!!point) { PlayerControls.jump(point.time); } - } + }; onChartClick = (e) => { if (e === null) return; @@ -206,10 +202,10 @@ export default class Performance extends React.PureComponent { if (!!point) { PlayerControls.jump(point.time); } - } + }; render() { - const { + const { userDeviceHeapSize, userDeviceMemorySize, connType, @@ -218,19 +214,19 @@ export default class Performance extends React.PureComponent { avaliability = {}, } = this.props; const { fps, cpu, heap, nodes } = avaliability; - const avaliableCount = [ fps, cpu, heap, nodes ].reduce((c, av) => av ? c + 1 : c, 0); - const height = avaliableCount === 0 ? "0" : `${100 / avaliableCount}%`; + const avaliableCount = [fps, cpu, heap, nodes].reduce((c, av) => (av ? c + 1 : c), 0); + const height = avaliableCount === 0 ? '0' : `${100 / avaliableCount}%`; return ( -
    - Performance +
    +
    Performance
    {/* */} = 1000 - ? `${ connBandwidth / 1000 } Mbps` - : `${ connBandwidth } Kbps` + value={ + connBandwidth >= 1000 ? `${connBandwidth / 1000} Mbps` : `${connBandwidth} Kbps` } - display={ connBandwidth != null } + display={connBandwidth != null} />
    - { fps && - + {fps && ( + - + {/* */} {/* */} - - - + {/* */} - + style: { cursor: 'pointer' }, + }} + isAnimationActive={false} + /> - {/* */} - + - } - { cpu && - + )} + {cpu && ( + - + {/* */} - ""} - domain={[0, 'dataMax']} - ticks={this._timeTicks} - > - - - - - - - - - } - - { heap && - - - - - - {/* */} - ""} // tick={false} + this._timeTicks to cartesian array + tickFormatter={() => ''} domain={[0, 'dataMax']} ticks={this._timeTicks} > - + + + + + + + + )} + + {heap && ( + + + + + + {/* */} + ''} // tick={false} + this._timeTicks to cartesian array + domain={[0, 'dataMax']} + ticks={this._timeTicks} + > + max*1.2]} + domain={[0, (max) => max * 1.2]} /> - - + - } - { nodes && - + )} + {nodes && ( + - + {/* */} - ""} - domain={[0, 'dataMax']} - ticks={this._timeTicks} - > - - } + )} ); } } -export const ConnectedPerformance = connectPlayer(state => ({ +export const ConnectedPerformance = connectPlayer((state) => ({ performanceChartTime: state.performanceChartTime, performanceChartData: state.performanceChartData, connType: state.connType, diff --git a/frontend/app/components/Session_/Performance/performance.module.css b/frontend/app/components/Session_/Performance/performance.module.css index 4bd075eec..5c2b85578 100644 --- a/frontend/app/components/Session_/Performance/performance.module.css +++ b/frontend/app/components/Session_/Performance/performance.module.css @@ -3,4 +3,5 @@ padding: 2px 5px; border-radius: 3px; border: 1px solid #ccc; + color: $gray-dark !important; } \ No newline at end of file diff --git a/frontend/app/components/Session_/Player/Controls/ControlButton.js b/frontend/app/components/Session_/Player/Controls/ControlButton.js index 31672c301..3c42895b6 100644 --- a/frontend/app/components/Session_/Player/Controls/ControlButton.js +++ b/frontend/app/components/Session_/Player/Controls/ControlButton.js @@ -8,7 +8,7 @@ const ControlButton = ({ icon = '', disabled = false, onClick, - count = 0, + // count = 0, hasErrors = false, active = false, size = 20, @@ -31,7 +31,7 @@ const ControlButton = ({ >
    {hasErrors &&
    } - {count > 0 &&
    {count}
    } + {/* {count > 0 &&
    {count}
    } */}
    {!noIcon && } {!noLabel && ( diff --git a/frontend/app/components/Session_/Player/Controls/Controls.js b/frontend/app/components/Session_/Player/Controls/Controls.js index d696ebadf..0a9b57fb3 100644 --- a/frontend/app/components/Session_/Player/Controls/Controls.js +++ b/frontend/app/components/Session_/Player/Controls/Controls.js @@ -8,7 +8,7 @@ import { selectStorageListNow, } from 'Player/store'; import LiveTag from 'Shared/LiveTag'; -import { toggleTimetravel, jumpToLive } from 'Player'; +import { jumpToLive } from 'Player'; import { Icon } from 'UI'; import { toggleInspectorMode } from 'Player'; @@ -25,8 +25,6 @@ import { PROFILER, PERFORMANCE, GRAPHQL, - FETCH, - EXCEPTIONS, INSPECTOR, } from 'Duck/components/player'; import { AssistDuration } from './Time'; @@ -38,23 +36,6 @@ import styles from './controls.module.css'; import { Tooltip } from 'react-tippy'; import XRayButton from 'Shared/XRayButton'; -function getStorageIconName(type) { - switch (type) { - case STORAGE_TYPES.REDUX: - return 'vendors/redux'; - case STORAGE_TYPES.MOBX: - return 'vendors/mobx'; - case STORAGE_TYPES.VUEX: - return 'vendors/vuex'; - case STORAGE_TYPES.NGRX: - return 'vendors/ngrx'; - case STORAGE_TYPES.ZUSTAND: - return 'vendors/zustand'; - case STORAGE_TYPES.NONE: - return 'store'; - } -} - const SKIP_INTERVALS = { 2: 2e3, 5: 5e3, @@ -95,24 +76,22 @@ function getStorageName(type) { disabled: state.cssLoading || state.messagesLoading || state.inspectorMode || state.markedTargets, inspectorMode: state.inspectorMode, fullscreenDisabled: state.messagesLoading, - logCount: state.logListNow.length, - logRedCount: state.logRedCountNow, - resourceRedCount: state.resourceRedCountNow, - fetchRedCount: state.fetchRedCountNow, + // logCount: state.logList.length, + logRedCount: state.logRedCount, + resourceRedCount: state.resourceRedCount, + fetchRedCount: state.fetchRedCount, showStack: state.stackList.length > 0, - stackCount: state.stackListNow.length, - stackRedCount: state.stackRedCountNow, - profilesCount: state.profilesListNow.length, + stackCount: state.stackList.length, + stackRedCount: state.stackRedCount, + profilesCount: state.profilesList.length, storageCount: selectStorageListNow(state).length, storageType: selectStorageType(state), showStorage: selectStorageType(state) !== STORAGE_TYPES.NONE, showProfiler: state.profilesList.length > 0, showGraphql: state.graphqlList.length > 0, showFetch: state.fetchCount > 0, - fetchCount: state.fetchCountNow, - graphqlCount: state.graphqlListNow.length, - exceptionsCount: state.exceptionsListNow.length, - showExceptions: state.exceptionsList.length > 0, + fetchCount: state.fetchCount, + graphqlCount: state.graphqlList.length, liveTimeTravel: state.liveTimeTravel, })) @connect( @@ -162,7 +141,7 @@ export default class Controls extends React.Component { nextProps.disabled !== this.props.disabled || nextProps.fullscreenDisabled !== this.props.fullscreenDisabled || // nextProps.inspectorMode !== this.props.inspectorMode || - nextProps.logCount !== this.props.logCount || + // nextProps.logCount !== this.props.logCount || nextProps.logRedCount !== this.props.logRedCount || nextProps.resourceRedCount !== this.props.resourceRedCount || nextProps.fetchRedCount !== this.props.fetchRedCount || @@ -178,8 +157,6 @@ export default class Controls extends React.Component { nextProps.showFetch !== this.props.showFetch || nextProps.fetchCount !== this.props.fetchCount || nextProps.graphqlCount !== this.props.graphqlCount || - nextProps.showExceptions !== this.props.showExceptions || - nextProps.exceptionsCount !== this.props.exceptionsCount || nextProps.liveTimeTravel !== this.props.liveTimeTravel || nextProps.skipInterval !== this.props.skipInterval ) @@ -284,24 +261,14 @@ export default class Controls extends React.Component { skip, speed, disabled, - logCount, logRedCount, resourceRedCount, - fetchRedCount, showStack, - stackCount, stackRedCount, - profilesCount, - storageCount, showStorage, storageType, showProfiler, showGraphql, - showFetch, - fetchCount, - graphqlCount, - exceptionsCount, - showExceptions, fullscreen, inspectorMode, closedLive, @@ -378,7 +345,6 @@ export default class Controls extends React.Component { label="CONSOLE" noIcon labelClassName="!text-base font-semibold" - count={logCount} hasErrors={logRedCount > 0} containerClassName="mx-2" /> @@ -405,25 +371,11 @@ export default class Controls extends React.Component { containerClassName="mx-2" /> )} - {showFetch && ( - toggleBottomTools(FETCH)} - active={bottomBlock === FETCH && !inspectorMode} - hasErrors={fetchRedCount > 0} - count={fetchCount} - label="FETCH" - noIcon - labelClassName="!text-base font-semibold" - containerClassName="mx-2" - /> - )} {!live && showGraphql && ( toggleBottomTools(GRAPHQL)} active={bottomBlock === GRAPHQL && !inspectorMode} - count={graphqlCount} label="GRAPHQL" noIcon labelClassName="!text-base font-semibold" @@ -435,26 +387,12 @@ export default class Controls extends React.Component { disabled={disabled && !inspectorMode} onClick={() => toggleBottomTools(STORAGE)} active={bottomBlock === STORAGE && !inspectorMode} - count={storageCount} label={getStorageName(storageType)} noIcon labelClassName="!text-base font-semibold" containerClassName="mx-2" /> )} - {showExceptions && ( - toggleBottomTools(EXCEPTIONS)} - active={bottomBlock === EXCEPTIONS && !inspectorMode} - label="EXCEPTIONS" - noIcon - labelClassName="!text-base font-semibold" - containerClassName="mx-2" - count={exceptionsCount} - hasErrors={exceptionsCount > 0} - /> - )} {!live && showStack && ( 0} /> )} @@ -473,7 +410,6 @@ export default class Controls extends React.Component { disabled={disabled && !inspectorMode} onClick={() => toggleBottomTools(PROFILER)} active={bottomBlock === PROFILER && !inspectorMode} - count={profilesCount} label="PROFILER" noIcon labelClassName="!text-base font-semibold" diff --git a/frontend/app/components/Session_/Player/Player.js b/frontend/app/components/Session_/Player/Player.js index babe4f2b0..48d881c29 100644 --- a/frontend/app/components/Session_/Player/Player.js +++ b/frontend/app/components/Session_/Player/Player.js @@ -14,20 +14,18 @@ import { PROFILER, PERFORMANCE, GRAPHQL, - FETCH, EXCEPTIONS, LONGTASKS, INSPECTOR, OVERVIEW, } from 'Duck/components/player'; -import Network from '../Network'; +import NetworkPanel from 'Shared/DevTools/NetworkPanel'; import Console from '../Console/Console'; import StackEvents from '../StackEvents/StackEvents'; import Storage from '../Storage'; import Profiler from '../Profiler'; import { ConnectedPerformance } from '../Performance'; import GraphQL from '../GraphQL'; -import Fetch from '../Fetch'; import Exceptions from '../Exceptions/Exceptions'; import LongTasks from '../LongTasks'; import Inspector from '../Inspector'; @@ -42,29 +40,38 @@ import Overlay from './Overlay'; import stl from './player.module.css'; import { updateLastPlayedSession } from 'Duck/sessions'; import OverviewPanel from '../OverviewPanel'; +import ConsolePanel from 'Shared/DevTools/ConsolePanel'; +import ProfilerPanel from 'Shared/DevTools/ProfilerPanel'; -@connectPlayer(state => ({ +@connectPlayer((state) => ({ live: state.live, })) -@connect(state => { - const isAssist = window.location.pathname.includes('/assist/'); - return { - fullscreen: state.getIn([ 'components', 'player', 'fullscreen' ]), - nextId: state.getIn([ 'sessions', 'nextId' ]), - sessionId: state.getIn([ 'sessions', 'current', 'sessionId' ]), - closedLive: !!state.getIn([ 'sessions', 'errors' ]) || (isAssist && !state.getIn([ 'sessions', 'current', 'live' ])), +@connect( + (state) => { + const isAssist = window.location.pathname.includes('/assist/'); + return { + fullscreen: state.getIn(['components', 'player', 'fullscreen']), + nextId: state.getIn(['sessions', 'nextId']), + sessionId: state.getIn(['sessions', 'current', 'sessionId']), + closedLive: + !!state.getIn(['sessions', 'errors']) || + (isAssist && !state.getIn(['sessions', 'current', 'live'])), + }; + }, + { + hideTargetDefiner, + fullscreenOff, + updateLastPlayedSession, } -}, { - hideTargetDefiner, - fullscreenOff, - updateLastPlayedSession, -}) +) export default class Player extends React.PureComponent { screenWrapper = React.createRef(); componentDidUpdate(prevProps) { - if ([ prevProps.bottomBlock, this.props.bottomBlock ].includes(NONE) || - prevProps.fullscreen !== this.props.fullscreen) { + if ( + [prevProps.bottomBlock, this.props.bottomBlock].includes(NONE) || + prevProps.fullscreen !== this.props.fullscreen + ) { scalePlayerScreen(); } } @@ -73,7 +80,7 @@ export default class Player extends React.PureComponent { this.props.updateLastPlayedSession(this.props.sessionId); if (this.props.closedLive) return; - const parentElement = findDOMNode(this.screenWrapper.current); //TODO: good architecture + const parentElement = findDOMNode(this.screenWrapper.current); //TODO: good architecture attachPlayer(parentElement); } @@ -86,66 +93,39 @@ export default class Player extends React.PureComponent { nextId, closedLive, bottomBlock, - activeTab + activeTab, } = this.props; - const maxWidth = activeTab ? 'calc(100vw - 270px)' : '100vw' + const maxWidth = activeTab ? 'calc(100vw - 270px)' : '100vw'; return (
    - {fullscreen && } + {fullscreen && }
    -
    +
    - { !fullscreen && !!bottomBlock && + {!fullscreen && !!bottomBlock && (
    - { bottomBlock === OVERVIEW && - - } - { bottomBlock === CONSOLE && - - } - { bottomBlock === NETWORK && - - } - { bottomBlock === STACKEVENTS && - - } - { bottomBlock === STORAGE && - - } - { bottomBlock === PROFILER && - - } - { bottomBlock === PERFORMANCE && - - } - { bottomBlock === GRAPHQL && - - } - { bottomBlock === FETCH && - - } - { bottomBlock === EXCEPTIONS && - - } - { bottomBlock === LONGTASKS && - - } - { bottomBlock === INSPECTOR && - - } + {bottomBlock === OVERVIEW && } + {bottomBlock === CONSOLE && } + {bottomBlock === NETWORK && ( + // + + )} + {bottomBlock === STACKEVENTS && } + {bottomBlock === STORAGE && } + {bottomBlock === PROFILER && } + {bottomBlock === PERFORMANCE && } + {bottomBlock === GRAPHQL && } + {bottomBlock === EXCEPTIONS && } + {bottomBlock === LONGTASKS && } + {bottomBlock === INSPECTOR && }
    - } - + )} +
    ); } diff --git a/frontend/app/components/Session_/Profiler/Profiler.js b/frontend/app/components/Session_/Profiler/Profiler.js index 1d9c8a5a3..398560a3d 100644 --- a/frontend/app/components/Session_/Profiler/Profiler.js +++ b/frontend/app/components/Session_/Profiler/Profiler.js @@ -51,6 +51,7 @@ export default class Profiler extends React.PureComponent { icon="search" name="filter" onChange={ this.onFilterChange } + height={28} /> diff --git a/frontend/app/components/Session_/StackEvents/StackEvents.js b/frontend/app/components/Session_/StackEvents/StackEvents.js index 4ce3ce86c..e03323463 100644 --- a/frontend/app/components/Session_/StackEvents/StackEvents.js +++ b/frontend/app/components/Session_/StackEvents/StackEvents.js @@ -6,6 +6,7 @@ import withEnumToggle from 'HOCs/withEnumToggle'; import { connectPlayer, jump } from 'Player'; import React from 'react'; import { connect } from 'react-redux'; +import StackEventRow from 'Shared/DevTools/StackEventRow'; import { DATADOG, SENTRY, STACKDRIVER, typeList } from 'Types/session/stackEvent'; import { NoContent, SlideModal, Tabs, Link } from 'UI'; import Autoscroll from '../Autoscroll'; @@ -19,7 +20,7 @@ const TABS = [ALL, ...typeList].map((tab) => ({ text: tab, key: tab })); @withEnumToggle('activeTab', 'setActiveTab', ALL) @connectPlayer((state) => ({ stackEvents: state.stackList, - stackEventsNow: state.stackListNow, + // stackEventsNow: state.stackListNow, })) @connect( (state) => ({ @@ -69,20 +70,20 @@ export default class StackEvents extends React.PureComponent { ({ key }) => key === ALL || stackEvents.some(({ source }) => key === source) ); - const filteredStackEvents = stackEvents - // .filter(({ data }) => data.includes(filter)) - .filter(({ source }) => activeTab === ALL || activeTab === source); + const filteredStackEvents = stackEvents.filter( + ({ source }) => activeTab === ALL || activeTab === source + ); - let lastIndex = -1; - // TODO: Need to do filtering in store, or preferably in a selector - filteredStackEvents.forEach((item, index) => { - if ( - this.props.stackEventsNow.length > 0 && - item.time <= this.props.stackEventsNow[this.props.stackEventsNow.length - 1].time - ) { - lastIndex = index; - } - }); + // let lastIndex = -1; + // // TODO: Need to do filtering in store, or preferably in a selector + // filteredStackEvents.forEach((item, index) => { + // if ( + // this.props.stackEventsNow.length > 0 && + // item.time <= this.props.stackEventsNow[this.props.stackEventsNow.length - 1].time + // ) { + // lastIndex = index; + // } + // }); return ( <> @@ -154,16 +155,21 @@ export default class StackEvents extends React.PureComponent { size="small" show={filteredStackEvents.length === 0} > - + {filteredStackEvents.map((userEvent, index) => ( - lastIndex} - selected={lastIndex === index} - userEvent={userEvent} + event={userEvent} onJump={() => jump(userEvent.time)} /> + // lastIndex} + // // selected={lastIndex === index} + // userEvent={userEvent} + // onJump={() => jump(userEvent.time)} + // /> ))} diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js b/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js index 1a46046b1..fc94eb673 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js +++ b/frontend/app/components/Session_/StackEvents/UserEvent/JsonViewer.js @@ -18,7 +18,9 @@ export default class JsonViewer extends React.PureComponent { {isObjectData && } {!isObjectData && Array.isArray(data) && (
    -
    {data[0]}
    +
    + {typeof data[0] === 'string' ? data[0] : JSON.stringify(data[0])} +
    )} diff --git a/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js b/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js index 5890c5e1a..2dfd4d8aa 100644 --- a/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js +++ b/frontend/app/components/Session_/StackEvents/UserEvent/UserEvent.js @@ -1,12 +1,13 @@ import React from 'react'; import cn from 'classnames'; import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; -import { Icon, IconButton } from 'UI'; +import { Icon } from 'UI'; import withToggle from 'HOCs/withToggle'; import Sentry from './Sentry'; import JsonViewer from './JsonViewer'; import stl from './userEvent.module.css'; import { Duration } from 'luxon'; +import JumpButton from 'Shared/DevTools/JumpButton'; // const modalSources = [ SENTRY, DATADOG ]; @@ -34,34 +35,33 @@ export default class UserEvent extends React.PureComponent { render() { const { userEvent, inactive, selected } = this.props; - //const message = this.getEventMessage(); + let message = userEvent.payload[0] || ''; + message = typeof message === 'string' ? message : JSON.stringify(message); return (
    -
    + {/*
    {Duration.fromMillis(userEvent.time).toFormat('mm:ss.SSS')} -
    -
    -
    - - {userEvent.name} +
    */} +
    + +
    +
    {userEvent.name}
    +
    {message}
    -
    - -
    +
    ); } diff --git a/frontend/app/components/Session_/TimeTable/timeTable.module.css b/frontend/app/components/Session_/TimeTable/timeTable.module.css index 17feaf459..c2412ff8d 100644 --- a/frontend/app/components/Session_/TimeTable/timeTable.module.css +++ b/frontend/app/components/Session_/TimeTable/timeTable.module.css @@ -9,7 +9,7 @@ $offset: 10px; .headers { box-shadow: 0 1px 2px 0 $gray-light; background-color: $gray-lightest; - color: $gray-medium; + color: $gray-darkest; font-size: 12px; overflow-x: hidden; white-space: nowrap; @@ -47,6 +47,10 @@ $offset: 10px; .row { display: flex; padding: 0 $offset; + + &:hover { + background-color: $active-blue; + } /*align-items: center; cursor: pointer; */ diff --git a/frontend/app/components/shared/DevTools/BottomBlock/BottomBlock.js b/frontend/app/components/shared/DevTools/BottomBlock/BottomBlock.js new file mode 100644 index 000000000..069757e60 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/BottomBlock.js @@ -0,0 +1,18 @@ +import React from 'react'; +import cn from 'classnames'; +import stl from './bottomBlock.module.css'; + +const BottomBlock = ({ + children = null, + className = '', + additionalHeight = 0, + ...props +}) => ( +
    + { children } +
    +); + +BottomBlock.displayName = 'BottomBlock'; + +export default BottomBlock; diff --git a/frontend/app/components/shared/DevTools/BottomBlock/Content.js b/frontend/app/components/shared/DevTools/BottomBlock/Content.js new file mode 100644 index 000000000..3df383911 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/Content.js @@ -0,0 +1,17 @@ +import React from 'react'; +import cn from 'classnames'; +import stl from './content.module.css'; + +const Content = ({ + children, + className, + ...props +}) => ( +
    + { children } +
    +); + +Content.displayName = 'Content'; + +export default Content; diff --git a/frontend/app/components/shared/DevTools/BottomBlock/Header.js b/frontend/app/components/shared/DevTools/BottomBlock/Header.js new file mode 100644 index 000000000..15dd7a0c9 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/Header.js @@ -0,0 +1,26 @@ +import React from 'react'; +import { connect } from 'react-redux'; +import cn from 'classnames'; +import { closeBottomBlock } from 'Duck/components/player'; +import { Input, CloseButton } from 'UI'; +import stl from './header.module.css'; + +const Header = ({ + children, + className, + closeBottomBlock, + onFilterChange, + showClose = true, + ...props +}) => ( +
    +
    +
    { children }
    + { showClose && } +
    +
    +); + +Header.displayName = 'Header'; + +export default connect(null, { closeBottomBlock })(Header); diff --git a/frontend/app/components/shared/DevTools/BottomBlock/InfoLine.js b/frontend/app/components/shared/DevTools/BottomBlock/InfoLine.js new file mode 100644 index 000000000..3059c70d3 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/InfoLine.js @@ -0,0 +1,20 @@ +import React from 'react'; +import cn from 'classnames'; +import cls from './infoLine.module.css'; + +const InfoLine = ({ children }) => ( +
    + { children } +
    +) + +const Point = ({ label = '', value = '', display=true, color, dotColor }) => display + ?
    + { dotColor != null &&
    } + { `${label}` } { value } +
    + : null; + +InfoLine.Point = Point; + +export default InfoLine; diff --git a/frontend/app/components/shared/DevTools/BottomBlock/bottomBlock.module.css b/frontend/app/components/shared/DevTools/BottomBlock/bottomBlock.module.css new file mode 100644 index 000000000..99bdd42b4 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/bottomBlock.module.css @@ -0,0 +1,9 @@ + +.wrapper { + background: $white; + /* padding-right: 10px; */ + /* border: solid thin $gray-light; */ + height: 300px; + + border-top: thin dashed #cccccc; +} diff --git a/frontend/app/components/shared/DevTools/BottomBlock/content.module.css b/frontend/app/components/shared/DevTools/BottomBlock/content.module.css new file mode 100644 index 000000000..fe8303013 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/content.module.css @@ -0,0 +1,3 @@ +.content { + height: 86%; +} \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/BottomBlock/header.module.css b/frontend/app/components/shared/DevTools/BottomBlock/header.module.css new file mode 100644 index 000000000..99faa61c7 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/header.module.css @@ -0,0 +1,6 @@ + +.header { + padding: 0 10px; + height: 40px; + border-bottom: 1px solid $gray-light; +} \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/BottomBlock/index.js b/frontend/app/components/shared/DevTools/BottomBlock/index.js new file mode 100644 index 000000000..846d7ec6f --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/index.js @@ -0,0 +1,8 @@ +import BottomBlock from './BottomBlock'; +import Header from './Header'; +import Content from './Content'; + +BottomBlock.Header = Header; +BottomBlock.Content = Content; + +export default BottomBlock; \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/BottomBlock/infoLine.module.css b/frontend/app/components/shared/DevTools/BottomBlock/infoLine.module.css new file mode 100644 index 000000000..37e47f013 --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/infoLine.module.css @@ -0,0 +1,31 @@ + + +.info { + padding-left: 10px; + height: 36px; + display: flex; + align-items: center; + & >.infoPoint { + font-size: 14px; + display: flex; + align-items: center; + &:not(:last-child):after { + content: ''; + margin: 0 12px; + height: 30px; + border-right: 1px solid $gray-light-shade; + } + & .label { + font-weight: 500; + margin-right: 6px; + } + } +} + +.dot { + width: 10px; + height: 10px; + border-radius: 5px; + margin-right: 5px; +} + diff --git a/frontend/app/components/shared/DevTools/BottomBlock/tabs.js b/frontend/app/components/shared/DevTools/BottomBlock/tabs.js new file mode 100644 index 000000000..6addd161e --- /dev/null +++ b/frontend/app/components/shared/DevTools/BottomBlock/tabs.js @@ -0,0 +1,9 @@ +// import { NONE, CONSOLE, NETWORK, STACKEVENTS, REDUX_STATE, PROFILER, PERFORMANCE, GRAPHQL } from 'Duck/components/player'; +// +// +// export default { +// [NONE]: { +// Component: null, +// +// } +// } \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx b/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx new file mode 100644 index 000000000..361084221 --- /dev/null +++ b/frontend/app/components/shared/DevTools/ConsolePanel/ConsolePanel.tsx @@ -0,0 +1,139 @@ +import React, { useState } from 'react'; +import { connectPlayer, jump } from 'Player'; +import Log from 'Types/session/log'; +import BottomBlock from '../BottomBlock'; +import { LEVEL } from 'Types/session/log'; +import { Tabs, Input, Icon, NoContent } from 'UI'; +// import Autoscroll from 'App/components/Session_/Autoscroll'; +import cn from 'classnames'; +import ConsoleRow from '../ConsoleRow'; +import { getRE } from 'App/utils'; + +const ALL = 'ALL'; +const INFO = 'INFO'; +const WARNINGS = 'WARNINGS'; +const ERRORS = 'ERRORS'; + +const LEVEL_TAB = { + [LEVEL.INFO]: INFO, + [LEVEL.LOG]: INFO, + [LEVEL.WARNING]: WARNINGS, + [LEVEL.ERROR]: ERRORS, + [LEVEL.EXCEPTION]: ERRORS, +}; + +const TABS = [ALL, ERRORS, WARNINGS, INFO].map((tab) => ({ text: tab, key: tab })); + +function renderWithNL(s = '') { + if (typeof s !== 'string') return ''; + return s.split('\n').map((line, i) =>
    {line}
    ); +} + +const getIconProps = (level: any) => { + switch (level) { + case LEVEL.INFO: + case LEVEL.LOG: + return { + name: 'console/info', + color: 'blue2', + }; + case LEVEL.WARN: + case LEVEL.WARNING: + return { + name: 'console/warning', + color: 'red2', + }; + case LEVEL.ERROR: + return { + name: 'console/error', + color: 'red', + }; + } + return null; +}; + +interface Props { + logs: any; + exceptions: any; +} +function ConsolePanel(props: Props) { + const { logs } = props; + const additionalHeight = 0; + const [activeTab, setActiveTab] = useState(ALL); + const [filter, setFilter] = useState(''); + + let filtered = React.useMemo(() => { + const filterRE = getRE(filter, 'i'); + let list = logs; + + list = list.filter( + ({ value, level }: any) => + (!!filter ? filterRE.test(value) : true) && + (activeTab === ALL || activeTab === LEVEL_TAB[level]) + ); + return list; + }, [filter, activeTab]); + + const onTabClick = (activeTab: any) => setActiveTab(activeTab); + const onFilterChange = ({ target: { value } }: any) => setFilter(value); + + return ( + + +
    + Console + +
    + +
    + + + + No Data +
    + } + size="small" + show={filtered.length === 0} + > + {/* */} + {filtered.map((l: any, index: any) => ( + + ))} + {/* */} + + + + ); +} + +export default connectPlayer((state: any) => { + const logs = state.logList; + const exceptions = state.exceptionsList; // TODO merge + const logExceptions = exceptions.map(({ time, errorId, name, projectId }: any) => + Log({ + level: LEVEL.ERROR, + value: name, + time, + errorId, + }) + ); + return { + logs: logs.concat(logExceptions), + }; +})(ConsolePanel); diff --git a/frontend/app/components/shared/DevTools/ConsolePanel/index.ts b/frontend/app/components/shared/DevTools/ConsolePanel/index.ts new file mode 100644 index 000000000..cbbf61684 --- /dev/null +++ b/frontend/app/components/shared/DevTools/ConsolePanel/index.ts @@ -0,0 +1 @@ +export { default } from './ConsolePanel'; diff --git a/frontend/app/components/shared/DevTools/ConsoleRow/ConsoleRow.tsx b/frontend/app/components/shared/DevTools/ConsoleRow/ConsoleRow.tsx new file mode 100644 index 000000000..8e24e4c7b --- /dev/null +++ b/frontend/app/components/shared/DevTools/ConsoleRow/ConsoleRow.tsx @@ -0,0 +1,59 @@ +import React, { useState } from 'react'; +import cn from 'classnames'; +// import stl from '../console.module.css'; +import { Icon } from 'UI'; +import JumpButton from 'Shared/DevTools/JumpButton'; +import { useModal } from 'App/components/Modal'; +import ErrorDetailsModal from 'App/components/Dashboard/components/Errors/ErrorDetailsModal'; + +interface Props { + log: any; + iconProps: any; + jump?: any; + renderWithNL?: any; +} +function ConsoleRow(props: Props) { + const { log, iconProps, jump, renderWithNL } = props; + const { showModal } = useModal(); + const [expanded, setExpanded] = useState(false); + const lines = log.value.split('\n').filter((l: any) => !!l); + const canExpand = lines.length > 1; + + const clickable = canExpand || !!log.errorId; + + const onErrorClick = () => { + showModal(, { right: true }); + }; + return ( +
    (!!log.errorId ? onErrorClick() : setExpanded(!expanded)) : () => {} + } + > +
    + +
    +
    +
    + {canExpand && ( + + )} + {renderWithNL(lines.pop())} +
    + {canExpand && expanded && lines.map((l: any) =>
    {l}
    )} +
    + jump(log.time)} /> +
    + ); +} + +export default ConsoleRow; diff --git a/frontend/app/components/shared/DevTools/ConsoleRow/index.ts b/frontend/app/components/shared/DevTools/ConsoleRow/index.ts new file mode 100644 index 000000000..c9140d748 --- /dev/null +++ b/frontend/app/components/shared/DevTools/ConsoleRow/index.ts @@ -0,0 +1 @@ +export { default } from './ConsoleRow'; diff --git a/frontend/app/components/shared/DevTools/JumpButton/JumpButton.tsx b/frontend/app/components/shared/DevTools/JumpButton/JumpButton.tsx new file mode 100644 index 000000000..f29551251 --- /dev/null +++ b/frontend/app/components/shared/DevTools/JumpButton/JumpButton.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { Icon, Popup } from 'UI'; + +interface Props { + onClick: any; + tooltip?: string; +} +function JumpButton(props: Props) { + const { tooltip = '' } = props; + return ( + +
    { + e.stopPropagation(); + props.onClick(); + }} + > + + JUMP +
    +
    + ); +} + +export default JumpButton; diff --git a/frontend/app/components/shared/DevTools/JumpButton/index.ts b/frontend/app/components/shared/DevTools/JumpButton/index.ts new file mode 100644 index 000000000..82ff65fc1 --- /dev/null +++ b/frontend/app/components/shared/DevTools/JumpButton/index.ts @@ -0,0 +1 @@ +export { default } from './JumpButton'; diff --git a/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx new file mode 100644 index 000000000..0e634e02c --- /dev/null +++ b/frontend/app/components/shared/DevTools/NetworkPanel/NetworkPanel.tsx @@ -0,0 +1,403 @@ +import React, { useState } from 'react'; +import cn from 'classnames'; +// import { connectPlayer } from 'Player'; +import { QuestionMarkHint, Popup, Tabs, Input, NoContent, Icon, Toggler, Button } from 'UI'; +import { getRE } from 'App/utils'; +import Resource, { TYPES } from 'Types/session/resource'; +import { formatBytes } from 'App/utils'; +import { formatMs } from 'App/date'; + +import TimeTable from '../TimeTable'; +import BottomBlock from '../BottomBlock'; +import InfoLine from '../BottomBlock/InfoLine'; +// import stl from './network.module.css'; +import { Duration } from 'luxon'; +import { connectPlayer, jump, pause } from 'Player'; +import { useModal } from 'App/components/Modal'; +import FetchDetailsModal from 'Shared/FetchDetailsModal'; +import { sort } from 'App/duck/sessions'; + +const ALL = 'ALL'; +const XHR = 'xhr'; +const JS = 'js'; +const CSS = 'css'; +const IMG = 'img'; +const MEDIA = 'media'; +const OTHER = 'other'; + +const TAB_TO_TYPE_MAP: any = { + [XHR]: TYPES.XHR, + [JS]: TYPES.JS, + [CSS]: TYPES.CSS, + [IMG]: TYPES.IMG, + [MEDIA]: TYPES.MEDIA, + [OTHER]: TYPES.OTHER, +}; +const TABS: any = [ALL, XHR, JS, CSS, IMG, MEDIA, OTHER].map((tab) => ({ + text: tab === 'xhr' ? 'XHR (Fetch)' : tab, + key: tab, +})); + +const DOM_LOADED_TIME_COLOR = 'teal'; +const LOAD_TIME_COLOR = 'red'; + +function compare(a: any, b: any, key: string) { + if (a[key] > b[key]) return 1; + if (a[key] < b[key]) return -1; + return 0; +} + +export function renderType(r: any) { + return ( + {r.type}
    }> +
    {r.type}
    + + ); +} + +export function renderName(r: any) { + return ( + {r.url}
    }> +
    {r.name}
    + + ); +} + +export function renderStart(r: any) { + return ( +
    + {Duration.fromMillis(r.time).toFormat('mm:ss.SSS')} +
    + ); +} + +const renderXHRText = () => ( + + {XHR} + + Use our{' '} + + Fetch plugin + + {' to capture HTTP requests and responses, including status codes and bodies.'}
    + We also provide{' '} + + support for GraphQL + + {' for easy debugging of your queries.'} + + } + className="ml-1" + /> +
    +); + +function renderSize(r: any) { + if (r.responseBodySize) return formatBytes(r.responseBodySize); + let triggerText; + let content; + if (r.decodedBodySize == null) { + triggerText = 'x'; + content = 'Not captured'; + } else { + const headerSize = r.headerSize || 0; + const encodedSize = r.encodedBodySize || 0; + const transferred = headerSize + encodedSize; + const showTransferred = r.headerSize != null; + + triggerText = formatBytes(r.decodedBodySize); + content = ( +
      + {showTransferred && ( +
    • {`${formatBytes(r.encodedBodySize + headerSize)} transfered over network`}
    • + )} +
    • {`Resource size: ${formatBytes(r.decodedBodySize)} `}
    • +
    + ); + } + + return ( + +
    {triggerText}
    +
    + ); +} + +export function renderDuration(r: any) { + if (!r.success) return 'x'; + + const text = `${Math.floor(r.duration)}ms`; + if (!r.isRed() && !r.isYellow()) return text; + + let tooltipText; + let className = 'w-full h-full flex items-center '; + if (r.isYellow()) { + tooltipText = 'Slower than average'; + className += 'warn color-orange'; + } else { + tooltipText = 'Much slower than average'; + className += 'error color-red'; + } + + return ( + +
    {text}
    +
    + ); +} + +interface Props { + location: any; + resources: any; + fetchList: any; + domContentLoadedTime: any; + loadTime: any; + playing: boolean; + domBuildingTime: any; + currentIndex: any; + time: any; +} +function NetworkPanel(props: Props) { + const { + resources, + time, + currentIndex, + domContentLoadedTime, + loadTime, + playing, + domBuildingTime, + fetchList, + } = props; + const { showModal, hideModal } = useModal(); + const [activeTab, setActiveTab] = useState(ALL); + const [sortBy, setSortBy] = useState('time'); + const [sortAscending, setSortAscending] = useState(true); + const [filter, setFilter] = useState(''); + const [showOnlyErrors, setShowOnlyErrors] = useState(false); + const onTabClick = (activeTab: any) => setActiveTab(activeTab); + const onFilterChange = ({ target: { value } }: any) => setFilter(value); + const additionalHeight = 0; + const fetchPresented = fetchList.length > 0; + + const resourcesSize = resources.reduce( + (sum: any, { decodedBodySize }: any) => sum + (decodedBodySize || 0), + 0 + ); + + const transferredSize = resources.reduce( + (sum: any, { headerSize, encodedBodySize }: any) => + sum + (headerSize || 0) + (encodedBodySize || 0), + 0 + ); + + const filterRE = getRE(filter, 'i'); + let filtered = React.useMemo(() => { + let list = resources; + fetchList.forEach( + (fetchCall: any) => + (list = list.filter((networkCall: any) => networkCall.url !== fetchCall.url)) + ); + list = list.concat(fetchList); + list = list.sort((a: any, b: any) => { + return compare(a, b, sortBy); + }); + + if (!sortAscending) { + list = list.reverse(); + } + + list = list.filter( + ({ type, name, status }: any) => + (!!filter ? filterRE.test(status) || filterRE.test(name) || filterRE.test(type) : true) && + (activeTab === ALL || type === TAB_TO_TYPE_MAP[activeTab]) && + (showOnlyErrors ? parseInt(status) >= 400 : true) + ); + return list; + }, [filter, sortBy, sortAscending, showOnlyErrors, activeTab]); + + // const lastIndex = currentIndex || filtered.filter((item: any) => item.time <= time).length - 1; + const referenceLines = []; + if (domContentLoadedTime != null) { + referenceLines.push({ + time: domContentLoadedTime.time, + color: DOM_LOADED_TIME_COLOR, + }); + } + if (loadTime != null) { + referenceLines.push({ + time: loadTime.time, + color: LOAD_TIME_COLOR, + }); + } + + const onRowClick = (row: any) => { + showModal(, { + right: true, + }); + }; + + const handleSort = (sortKey: string) => { + if (sortKey === sortBy) { + setSortAscending(!sortAscending); + // setSortBy('time'); + } + setSortBy(sortKey); + }; + + return ( + + + +
    + Network + +
    + +
    + +
    +
    + setShowOnlyErrors(!showOnlyErrors)} + label="4xx-5xx Only" + /> +
    + + + 0} + /> + 0} + /> + + + + +
    + + + No Data +
    + } + size="small" + show={filtered.length === 0} + > + + {[ + // { + // label: 'Start', + // width: 120, + // render: renderStart, + // }, + { + label: 'Status', + dataKey: 'status', + width: 70, + onClick: handleSort, + }, + { + label: 'Type', + dataKey: 'type', + width: 90, + render: renderType, + onClick: handleSort, + }, + { + label: 'Name', + width: 240, + dataKey: 'name', + render: renderName, + onClick: handleSort, + }, + { + label: 'Size', + width: 80, + dataKey: 'decodedBodySize', + render: renderSize, + onClick: handleSort, + }, + { + label: 'Time', + width: 80, + dataKey: 'duration', + render: renderDuration, + onClick: handleSort, + }, + ]} + + + + + + ); +} + +export default connectPlayer((state: any) => ({ + location: state.location, + resources: state.resourceList, + fetchList: state.fetchList.map((i: any) => Resource({ ...i.toJS(), type: TYPES.XHR })), + domContentLoadedTime: state.domContentLoadedTime, + loadTime: state.loadTime, + // time: state.time, + playing: state.playing, + domBuildingTime: state.domBuildingTime, +}))(NetworkPanel); diff --git a/frontend/app/components/shared/DevTools/NetworkPanel/index.ts b/frontend/app/components/shared/DevTools/NetworkPanel/index.ts new file mode 100644 index 000000000..3014d5b0b --- /dev/null +++ b/frontend/app/components/shared/DevTools/NetworkPanel/index.ts @@ -0,0 +1 @@ +export { default } from './NetworkPanel' \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/ProfilerModal/ProfilerModal.tsx b/frontend/app/components/shared/DevTools/ProfilerModal/ProfilerModal.tsx new file mode 100644 index 000000000..a5909ada7 --- /dev/null +++ b/frontend/app/components/shared/DevTools/ProfilerModal/ProfilerModal.tsx @@ -0,0 +1,26 @@ +import React from 'react'; + +interface Props { + profile: any; +} +function ProfilerModal(props: Props) { + const { + profile: { name, args, result }, + } = props; + + return ( +
    +
    {name}
    +
    {'Arguments'}
    +
      + {args.split(',').map((arg: any) => ( +
    • {`${arg}`}
    • + ))} +
    +
    {'Result'}
    +
    {`${result}`}
    +
    + ); +} + +export default ProfilerModal; diff --git a/frontend/app/components/shared/DevTools/ProfilerModal/index.ts b/frontend/app/components/shared/DevTools/ProfilerModal/index.ts new file mode 100644 index 000000000..dbb2c0fa8 --- /dev/null +++ b/frontend/app/components/shared/DevTools/ProfilerModal/index.ts @@ -0,0 +1 @@ +export { default } from './ProfilerModal'; diff --git a/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx b/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx new file mode 100644 index 000000000..f1fa16219 --- /dev/null +++ b/frontend/app/components/shared/DevTools/ProfilerPanel/ProfilerPanel.tsx @@ -0,0 +1,75 @@ +import React, { useState } from 'react'; +import { connectPlayer } from 'Player'; +import { TextEllipsis, Input } from 'UI'; +import { getRE } from 'App/utils'; + +// import ProfileInfo from './ProfileInfo'; +import TimeTable from '../TimeTable'; +import BottomBlock from '../BottomBlock'; +import { useModal } from 'App/components/Modal'; +import ProfilerModal from '../ProfilerModal'; + +const renderDuration = (p: any) => `${p.duration}ms`; +const renderName = (p: any) => ; + +interface Props { + profiles: any; +} +function ProfilerPanel(props: Props) { + const { profiles } = props; + const { showModal } = useModal(); + const [filter, setFilter] = useState(''); + const filtered: any = React.useMemo(() => { + const filterRE = getRE(filter, 'i'); + let list = profiles; + + list = list.filter(({ name }: any) => (!!filter ? filterRE.test(name) : true)); + return list; + }, [filter]); + + const onFilterChange = ({ target: { value } }: any) => setFilter(value); + const onRowClick = (profile: any) => { + showModal(, { right: true }); + }; + return ( + + +
    + Profiler +
    + +
    + + + {[ + { + label: 'Name', + dataKey: 'name', + width: 200, + render: renderName, + }, + { + label: 'Time', + key: 'duration', + width: 80, + render: renderDuration, + }, + ]} + + +
    + ); +} + +export default connectPlayer((state: any) => { + return { + profiles: state.profilesList, + }; +})(ProfilerPanel); diff --git a/frontend/app/components/shared/DevTools/ProfilerPanel/index.ts b/frontend/app/components/shared/DevTools/ProfilerPanel/index.ts new file mode 100644 index 000000000..53bccddef --- /dev/null +++ b/frontend/app/components/shared/DevTools/ProfilerPanel/index.ts @@ -0,0 +1 @@ +export { default } from './ProfilerPanel'; diff --git a/frontend/app/components/shared/DevTools/StackEventModal/StackEventModal.tsx b/frontend/app/components/shared/DevTools/StackEventModal/StackEventModal.tsx new file mode 100644 index 000000000..c68c0ca80 --- /dev/null +++ b/frontend/app/components/shared/DevTools/StackEventModal/StackEventModal.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { DATADOG, SENTRY, STACKDRIVER, typeList } from 'Types/session/stackEvent'; +import JsonViewer from 'Components/Session_/StackEvents/UserEvent/JsonViewer'; +import Sentry from 'Components/Session_/StackEvents/UserEvent/Sentry'; + +interface Props { + event: any; +} +function StackEventModal(props: Props) { + const { event } = props; + const renderPopupContent = () => { + const { source, payload, name } = event; + switch (source) { + case SENTRY: + return ; + case DATADOG: + return ; + case STACKDRIVER: + return ; + default: + return ; + } + }; + return ( +
    +
    Stack Event
    + {renderPopupContent()} +
    + ); +} + +export default StackEventModal; diff --git a/frontend/app/components/shared/DevTools/StackEventModal/index.ts b/frontend/app/components/shared/DevTools/StackEventModal/index.ts new file mode 100644 index 000000000..93a084d28 --- /dev/null +++ b/frontend/app/components/shared/DevTools/StackEventModal/index.ts @@ -0,0 +1 @@ +export { default } from './StackEventModal'; diff --git a/frontend/app/components/shared/DevTools/StackEventRow/StackEventRow.tsx b/frontend/app/components/shared/DevTools/StackEventRow/StackEventRow.tsx new file mode 100644 index 000000000..b6b1a8a6f --- /dev/null +++ b/frontend/app/components/shared/DevTools/StackEventRow/StackEventRow.tsx @@ -0,0 +1,52 @@ +import React from 'react'; +import JumpButton from '../JumpButton'; +import { Icon } from 'UI'; +import cn from 'classnames'; +import { OPENREPLAY, SENTRY, DATADOG, STACKDRIVER } from 'Types/session/stackEvent'; +import { useModal } from 'App/components/Modal'; +import StackEventModal from '../StackEventModal'; + +interface Props { + event: any; + onJump: any; +} +function StackEventRow(props: Props) { + const { event, onJump } = props; + let message = event.payload[0] || ''; + message = typeof message === 'string' ? message : JSON.stringify(message); + const onClickDetails = () => { + showModal(, { right: true }); + }; + const { showModal } = useModal(); + + const iconProps: any = React.useMemo(() => { + const { source } = event; + return { + name: `integrations/${source}`, + size: 18, + marginRight: source === OPENREPLAY ? 11 : 10, + }; + }, [event]); + + return ( +
    +
    + +
    +
    {event.name}
    +
    {message}
    +
    +
    + +
    + ); +} + +export default StackEventRow; diff --git a/frontend/app/components/shared/DevTools/StackEventRow/index.ts b/frontend/app/components/shared/DevTools/StackEventRow/index.ts new file mode 100644 index 000000000..321f9231b --- /dev/null +++ b/frontend/app/components/shared/DevTools/StackEventRow/index.ts @@ -0,0 +1 @@ +export { default } from './StackEventRow'; diff --git a/frontend/app/components/shared/DevTools/TimeTable/BarRow.tsx b/frontend/app/components/shared/DevTools/TimeTable/BarRow.tsx new file mode 100644 index 000000000..9de1a8279 --- /dev/null +++ b/frontend/app/components/shared/DevTools/TimeTable/BarRow.tsx @@ -0,0 +1,96 @@ +import { Popup } from 'UI'; +import { percentOf } from 'App/utils'; +import styles from './barRow.module.css' +import tableStyles from './timeTable.module.css'; +import React from 'react'; + +const formatTime = time => time < 1000 ? `${time.toFixed(2)}ms` : `${time / 1000}s`; + +interface Props { + resource: { + time: number + ttfb?: number + duration?: number + key: string + } + popup?: boolean + timestart: number + timewidth: number +} + +// TODO: If request has no duration, set duration to 0.2s. Enforce existence of duration in the future. +const BarRow = ({ resource: { time, ttfb = 0, duration = 200, key }, popup = false, timestart = 0, timewidth }: Props) => { + const timeOffset = time - timestart; + ttfb = ttfb || 0; + const trigger = ( +
    +
    +
    +
    + ); + if (!popup) return
    {trigger}
    ; + + return ( +
    + + {ttfb != null && +
    +
    {'Waiting (TTFB)'}
    +
    +
    +
    +
    {formatTime(ttfb)}
    +
    + } +
    +
    {'Content Download'}
    +
    +
    +
    +
    {formatTime(duration - ttfb)}
    +
    + + } + size="mini" + position="top center" + /> +
    + ); +} + +BarRow.displayName = "BarRow"; + +export default BarRow; \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/TimeTable/TimeTable.tsx b/frontend/app/components/shared/DevTools/TimeTable/TimeTable.tsx new file mode 100644 index 000000000..d5489ac36 --- /dev/null +++ b/frontend/app/components/shared/DevTools/TimeTable/TimeTable.tsx @@ -0,0 +1,382 @@ +import React from 'react'; +import { List, AutoSizer } from 'react-virtualized'; +import cn from 'classnames'; +import { Duration } from 'luxon'; +import { NoContent, Icon, Button } from 'UI'; +import { percentOf } from 'App/utils'; + +import BarRow from './BarRow'; +import stl from './timeTable.module.css'; + +import autoscrollStl from '../autoscroll.module.css'; //aaa +import JumpButton from '../JumpButton'; + +type Timed = { + time: number; +}; + +type Durationed = { + duration: number; +}; + +type CanBeRed = { + //+isRed: boolean, + isRed: () => boolean; +}; + +interface Row extends Timed, Durationed, CanBeRed { + [key: string]: any; + key: string; +} + +type Line = { + color: string; // Maybe use typescript? + hint?: string; + onClick?: any; +} & Timed; + +type Column = { + label: string; + width: number; + dataKey?: string; + render?: (row: any) => void; + referenceLines?: Array; + style?: React.CSSProperties; + onClick?: void; +} & RenderOrKey; + +// type RenderOrKey = { // Disjoint? +// render: Row => React.Node +// } | { +// dataKey: string, +// } +type RenderOrKey = + | { + render?: (row: Row) => React.ReactNode; + key?: string; + } + | { + dataKey: string; + }; + +type Props = { + className?: string; + rows: Array; + children: Array; + tableHeight?: number; + activeIndex?: number; + renderPopup?: boolean; + navigation?: boolean; + referenceLines?: any[]; + additionalHeight?: number; + hoverable?: boolean; + onRowClick?: (row: any, index: number) => void; + onJump?: (time: any) => void; + sortBy?: string; + sortAscending?: boolean; +}; + +type TimeLineInfo = { + timestart: number; + timewidth: number; +}; + +type State = TimeLineInfo & typeof initialState; + +//const TABLE_HEIGHT = 195; +let _additionalHeight = 0; +const ROW_HEIGHT = 32; +//const VISIBLE_COUNT = Math.ceil(TABLE_HEIGHT/ROW_HEIGHT); + +const TIME_SECTIONS_COUNT = 8; +const ZERO_TIMEWIDTH = 1000; +function formatTime(ms: number) { + if (ms < 0) return ''; + if (ms < 1000) return Duration.fromMillis(ms).toFormat('0.SSS'); + return Duration.fromMillis(ms).toFormat('mm:ss'); +} + +function computeTimeLine( + rows: Array, + firstVisibleRowIndex: number, + visibleCount: number +): TimeLineInfo { + const visibleRows = rows.slice( + firstVisibleRowIndex, + firstVisibleRowIndex + visibleCount + _additionalHeight + ); + let timestart = visibleRows.length > 0 ? Math.min(...visibleRows.map((r) => r.time)) : 0; + // TODO: GraphQL requests do not have a duration, so their timeline is borked. Assume a duration of 0.2s for every GraphQL request + const timeend = + visibleRows.length > 0 ? Math.max(...visibleRows.map((r) => r.time + (r.duration ?? 200))) : 0; + let timewidth = timeend - timestart; + const offset = timewidth / 70; + if (timestart >= offset) { + timestart -= offset; + } + timewidth *= 1.5; // += offset; + if (timewidth === 0) { + timewidth = ZERO_TIMEWIDTH; + } + return { + timestart, + timewidth, + }; +} + +const initialState = { + firstVisibleRowIndex: 0, +}; + +export default class TimeTable extends React.PureComponent { + state = { + ...computeTimeLine(this.props.rows, initialState.firstVisibleRowIndex, this.visibleCount), + ...initialState, + }; + + get tableHeight() { + return this.props.tableHeight || 195; + } + + get visibleCount() { + return Math.ceil(this.tableHeight / ROW_HEIGHT); + } + + scroller = React.createRef(); + autoScroll = true; + + componentDidMount() { + if (this.scroller.current) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } + } + + componentDidUpdate(prevProps: any, prevState: any) { + if ( + prevState.firstVisibleRowIndex !== this.state.firstVisibleRowIndex || + (this.props.rows.length <= this.visibleCount + _additionalHeight && + prevProps.rows.length !== this.props.rows.length) + ) { + this.setState({ + ...computeTimeLine(this.props.rows, this.state.firstVisibleRowIndex, this.visibleCount), + }); + } + if ( + this.props.activeIndex && + this.props.activeIndex >= 0 && + prevProps.activeIndex !== this.props.activeIndex && + this.scroller.current + ) { + this.scroller.current.scrollToRow(this.props.activeIndex); + } + } + + onScroll = ({ + scrollTop, + scrollHeight, + clientHeight, + }: { + scrollTop: number; + scrollHeight: number; + clientHeight: number; + }): void => { + const firstVisibleRowIndex = Math.floor(scrollTop / ROW_HEIGHT + 0.33); + + if (this.state.firstVisibleRowIndex !== firstVisibleRowIndex) { + this.autoScroll = scrollHeight - clientHeight - scrollTop < ROW_HEIGHT / 2; + this.setState({ firstVisibleRowIndex }); + } + }; + + onJump = (index: any) => { + if (this.props.onJump) { + this.props.onJump(this.props.rows[index].time); + } + }; + + renderRow = ({ index, key, style: rowStyle }: any) => { + const { activeIndex } = this.props; + const { children: columns, rows, renderPopup, hoverable, onRowClick } = this.props; + const { timestart, timewidth } = this.state; + const row = rows[index]; + return ( +
    activeIndex, + })} + onClick={typeof onRowClick === 'function' ? () => onRowClick(row, index) : undefined} + id="table-row" + > + {columns.map(({ dataKey, render, width }) => ( +
    + {render + ? render(row) + : row[dataKey || ''] || {'empty'}} +
    + ))} +
    + +
    + this.onJump(index)} /> +
    + ); + }; + + onPrevClick = () => { + let prevRedIndex = -1; + for (let i = this.state.firstVisibleRowIndex - 1; i >= 0; i--) { + if (this.props.rows[i].isRed()) { + prevRedIndex = i; + break; + } + } + if (this.scroller.current != null) { + this.scroller.current.scrollToRow(prevRedIndex); + } + }; + + onNextClick = () => { + let prevRedIndex = -1; + for (let i = this.state.firstVisibleRowIndex + 1; i < this.props.rows.length; i++) { + if (this.props.rows[i].isRed()) { + prevRedIndex = i; + break; + } + } + if (this.scroller.current != null) { + this.scroller.current.scrollToRow(prevRedIndex); + } + }; + + onColumnClick = (dataKey: string, onClick: any) => { + if (typeof onClick === 'function') { + // this.scroller.current.scrollToRow(0); + onClick(dataKey); + this.scroller.current.forceUpdateGrid(); + } + }; + + render() { + const { + className, + rows, + children: columns, + navigation = false, + referenceLines = [], + additionalHeight = 0, + activeIndex, + sortBy = '', + sortAscending = true, + } = this.props; + const { timewidth, timestart } = this.state; + + _additionalHeight = additionalHeight; + + const sectionDuration = Math.round(timewidth / TIME_SECTIONS_COUNT); + const timeColumns: number[] = []; + if (timewidth > 0) { + for (let i = 0; i < TIME_SECTIONS_COUNT; i++) { + timeColumns.push(timestart + i * sectionDuration); + } + } + + const visibleRefLines = referenceLines.filter( + ({ time }) => time > timestart && time < timestart + timewidth + ); + + const columnsSumWidth = columns.reduce((sum, { width }) => sum + width, 0); + + return ( +
    + {navigation && ( +
    +
    + )} +
    +
    + {columns.map(({ label, width, dataKey, onClick = null }) => ( +
    this.onColumnClick(dataKey, onClick)} + > + {label} + {!!sortBy && sortBy === dataKey && } +
    + ))} +
    +
    + {timeColumns.map((time, i) => ( +
    + {formatTime(time)} +
    + ))} +
    +
    + + +
    +
    + {timeColumns.map((_, index) => ( +
    + ))} + {visibleRefLines.map(({ time, color, onClick }) => ( +
    + ))} +
    + + {({ width }: { width: number }) => ( + + )} + +
    + +
    + ); + } +} diff --git a/frontend/app/components/shared/DevTools/TimeTable/barRow.module.css b/frontend/app/components/shared/DevTools/TimeTable/barRow.module.css new file mode 100644 index 000000000..e45d1f7b2 --- /dev/null +++ b/frontend/app/components/shared/DevTools/TimeTable/barRow.module.css @@ -0,0 +1,45 @@ + + + +.barWrapper { + display: flex; + position: absolute; + top: 35%; + bottom: 35%; + border-radius: 3px; + overflow: hidden; +} + +.downloadBar, .ttfbBar { + /* box-shadow: inset 0px 0px 0px 1px $teal; */ + height: 100%; + box-sizing: border-box; + position: relative; +} +.ttfbBar { + background-color: rgba(175, 226, 221, 0.8); +} +.downloadBar { + background-color: rgba(133, 200, 192, 0.8); +} + +.popupRow { + color: $gray-medium; + display: flex; + align-items: center; + padding: 2px 0; + font-size: 12px; +} +.title { + width: 105px; +} +.time { + width: 60px; + padding-left: 10px; +} +.popupBarWrapper { + width: 220px; + height: 15px; + border-radius: 3px; + overflow: hidden; +} \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/TimeTable/index.js b/frontend/app/components/shared/DevTools/TimeTable/index.js new file mode 100644 index 000000000..c3c329b0a --- /dev/null +++ b/frontend/app/components/shared/DevTools/TimeTable/index.js @@ -0,0 +1 @@ +export { default } from './TimeTable'; \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/TimeTable/timeTable.module.css b/frontend/app/components/shared/DevTools/TimeTable/timeTable.module.css new file mode 100644 index 000000000..dbd3a4366 --- /dev/null +++ b/frontend/app/components/shared/DevTools/TimeTable/timeTable.module.css @@ -0,0 +1,113 @@ + + +$offset: 10px; + +.timeCell { + border-left: solid thin rgba(0, 0, 0, 0.05); +} + +.headers { + box-shadow: 0 1px 2px 0 $gray-light; + background-color: $gray-lightest; + color: $gray-darkest; + font-size: 12px; + overflow-x: hidden; + white-space: nowrap; + width: 100%; + display: flex; + padding: 0 $offset; +} +.infoHeaders { + text-transform: uppercase; + display: flex; + & .headerCell { + padding: 4px 2px; + } +} +.waterfallHeaders { + display: flex; + flex: 1; + & .timeCell { + flex: 1; + overflow: hidden; + padding: 4px 0; + } +} + +.list { + /* TODO hide the scrollbar track */ + &::-webkit-scrollbar { + width: 1px; + } + scrollbar-width: thin; + font-size: 12px; + font-family: 'Menlo', 'monaco', 'consolas', monospace; +} + +.row { + display: flex; + padding: 0 $offset; + + &:hover { + background-color: $active-blue; + } + /*align-items: center; + cursor: pointer; + */ + /* &:nth-child(even) { + background-color: $gray-lightest; + } */ + /* & > div:first-child { + padding-left: 5px; + }*/ +} +.cell { + height: 100%; + display: flex; + align-items: center; + overflow: hidden; + padding: 0 2px; + white-space: nowrap; +} +.hoverable { + transition: all 0.3s; + cursor: pointer; + &:hover { + background-color: $active-blue; + transition: all 0.2s; + color: $gray-dark; + } +} +.timeBarWrapper{ + overflow: hidden; +} + +.timePart { + position: absolute; + top: 0; + bottom: 0; + /*left:0;*/ + right: 0; + display: flex; + margin: 0 $offset; + & .timeCell { + height: 100%; + flex: 1; + z-index: 1; + pointer-events: none; + } + & .refLine { + position: absolute; + height: 100%; + width: 1px; + z-index: 1; + } +} + +.activeRow { + background-color: $teal-light; +} + +.inactiveRow { + opacity: 0.5; +} \ No newline at end of file diff --git a/frontend/app/components/shared/DevTools/autoscroll.module.css b/frontend/app/components/shared/DevTools/autoscroll.module.css new file mode 100644 index 000000000..209badfb2 --- /dev/null +++ b/frontend/app/components/shared/DevTools/autoscroll.module.css @@ -0,0 +1,12 @@ +.navButtons { + position: absolute; + + background: rgba(255, 255, 255, 0.5); + padding: 4px; + + right: 24px; + top: 8px; + z-index: 1; +} + + diff --git a/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.js b/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.js index 6f3ad549e..ac4a10159 100644 --- a/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.js +++ b/frontend/app/components/shared/FetchDetailsModal/FetchDetailsModal.js @@ -1,9 +1,11 @@ import React from 'react'; -import { JSONTree, NoContent, Button, Tabs } from 'UI'; +import { JSONTree, NoContent, Button, Tabs, Icon } from 'UI'; import cn from 'classnames'; import stl from './fetchDetails.module.css'; import Headers from './components/Headers'; import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; +import { TYPES } from 'Types/session/resource'; +import { formatBytes } from 'App/utils'; const HEADERS = 'HEADERS'; const REQUEST = 'REQUEST'; @@ -129,43 +131,107 @@ export default class FetchDetailsModal extends React.PureComponent { render() { const { - resource: { method, url, duration }, + resource, + fetchPresented, nextClick, prevClick, first = false, last = false, } = this.props; + const { method, url, duration } = resource; const { activeTab, tabs } = this.state; - - const _duration = parseInt(duration) - console.log('_duration', _duration); + const _duration = parseInt(duration); return (
    -
    {'URL'}
    -
    {url}
    -
    - {method && ( -
    -
    Method
    -
    {method}
    -
    - )} - {!!_duration && ( -
    -
    Duration
    -
    {_duration } ms
    -
    - )} +
    Network Request
    +
    +
    Name
    +
    + {resource.name} +
    -
    -
    - -
    - {this.renderActiveTab(activeTab)} +
    +
    Type
    +
    + {resource.type} +
    +
    + + {!!resource.decodedBodySize && ( +
    +
    Size
    +
    + {formatBytes(resource.decodedBodySize)}
    + )} + + {method && ( +
    +
    Request Method
    +
    + {resource.method} +
    +
    + )} + + {resource.status && ( +
    +
    Status
    +
    + {resource.status === '200' && ( +
    + )} + {resource.status} +
    +
    + )} + + {!!_duration && ( +
    +
    Time
    +
    + {_duration} ms +
    +
    + )} + + {resource.type === TYPES.XHR && !fetchPresented && ( +
    +
    + + Get more out of network requests +
    +
      +
    • + Integrate{' '} + + Fetch plugin + {' '} + to capture fetch payloads. +
    • +
    • + Find a detailed{' '} + + video tutorial + {' '} + to understand practical example of how to use fetch plugin. +
    • +
    +
    + )} + +
    + {resource.type === TYPES.XHR && fetchPresented && ( +
    + +
    + {this.renderActiveTab(activeTab)} +
    +
    + )} {/*