diff --git a/frontend/app/components/Dashboard/components/Errors/ErrorsList/ErrorsList.tsx b/frontend/app/components/Dashboard/components/Errors/ErrorsList/ErrorsList.tsx deleted file mode 100644 index a6a9bca4c..000000000 --- a/frontend/app/components/Dashboard/components/Errors/ErrorsList/ErrorsList.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React, { useEffect } from 'react'; -import ErrorListItem from '../ErrorListItem'; -import { useStore } from 'App/mstore'; -import { useObserver } from 'mobx-react-lite'; - -function ErrorsList(props) { - const { errorStore, metricStore } = useStore(); - const metric = useObserver(() => metricStore.instance); - - useEffect(() => { - errorStore.fetchErrors(); - }, []); - return ( -
- Errors List - -
- ); -} - -export default ErrorsList; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/Errors/ErrorsList/index.ts b/frontend/app/components/Dashboard/components/Errors/ErrorsList/index.ts deleted file mode 100644 index 7dd28915c..000000000 --- a/frontend/app/components/Dashboard/components/Errors/ErrorsList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ErrorsList'; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/Errors/ErrorsWidget/ErrorsWidget.tsx b/frontend/app/components/Dashboard/components/Errors/ErrorsWidget/ErrorsWidget.tsx deleted file mode 100644 index b2862d040..000000000 --- a/frontend/app/components/Dashboard/components/Errors/ErrorsWidget/ErrorsWidget.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import React from 'react'; -import ErrorsList from '../ErrorsList'; - -function ErrorsWidget(props) { - return ( -
- -
- ); -} - -export default ErrorsWidget; \ No newline at end of file diff --git a/frontend/app/components/Dashboard/components/Errors/ErrorsWidget/index.ts b/frontend/app/components/Dashboard/components/Errors/ErrorsWidget/index.ts deleted file mode 100644 index 8f62f38a4..000000000 --- a/frontend/app/components/Dashboard/components/Errors/ErrorsWidget/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { default } from './ErrorsWidget'; \ No newline at end of file diff --git a/frontend/app/components/Errors/Error/ErrorInfo.js b/frontend/app/components/Errors/Error/ErrorInfo.js index ff15d4589..1785bb4cb 100644 --- a/frontend/app/components/Errors/Error/ErrorInfo.js +++ b/frontend/app/components/Errors/Error/ErrorInfo.js @@ -1,89 +1,48 @@ +import { observer } from 'mobx-react-lite'; import React from 'react'; -import { connect } from 'react-redux'; -import withSiteIdRouter from 'HOCs/withSiteIdRouter'; -import { error as errorRoute } from 'App/routes'; -import { NoContent, Loader } from 'UI'; -import { fetch, fetchTrace } from 'Duck/errors'; -import MainSection from './MainSection'; -import SideSection from './SideSection'; + +import { useStore } from 'App/mstore'; +import { Loader, NoContent } from 'UI'; + import AnimatedSVG, { ICONS } from 'Shared/AnimatedSVG/AnimatedSVG'; -@connect( - (state) => ({ - errorIdInStore: state.getIn(['errors', 'instance']).errorId, - list: state.getIn(['errors', 'instanceTrace']), - loading: - state.getIn(['errors', 'fetch', 'loading']) || - state.getIn(['errors', 'fetchTrace', 'loading']), - errorOnFetch: - state.getIn(['errors', 'fetch', 'errors']) || state.getIn(['errors', 'fetchTrace', 'errors']), - }), - { - fetch, - fetchTrace, - } -) -@withSiteIdRouter -export default class ErrorInfo extends React.PureComponent { - ensureInstance() { - const { errorId, loading, errorOnFetch } = this.props; - if (!loading && this.props.errorIdInStore !== errorId && errorId != null) { - this.props.fetch(errorId); - this.props.fetchTrace(errorId); - } - } - componentDidMount() { - this.ensureInstance(); - } - componentDidUpdate(prevProps) { - if (prevProps.errorId !== this.props.errorId || prevProps.errorIdInStore !== this.props.errorIdInStore) { - this.ensureInstance(); - } - } - next = () => { - const { list, errorId } = this.props; - const curIndex = list.findIndex((e) => e.errorId === errorId); - const next = list.get(curIndex + 1); - if (next != null) { - this.props.history.push(errorRoute(next.errorId)); - } - }; - prev = () => { - const { list, errorId } = this.props; - const curIndex = list.findIndex((e) => e.errorId === errorId); - const prev = list.get(curIndex - 1); - if (prev != null) { - this.props.history.push(errorRoute(prev.errorId)); - } - }; - render() { - const { loading, errorIdInStore, list, errorId } = this.props; +import MainSection from './MainSection'; +import SideSection from './SideSection'; - let nextDisabled = true, - prevDisabled = true; - if (list.size > 0) { - nextDisabled = loading || list.last().errorId === errorId; - prevDisabled = loading || list.first().errorId === errorId; - } +function ErrorInfo(props) { + const { errorStore } = useStore(); - return ( - - -
No Error Found!
- - } - subtext="Please try to find existing one." - show={!loading && errorIdInStore == null} - > -
- - - - + const ensureInstance = () => { + if (errorStore.isLoading) return; + errorStore.fetch(props.errorId); + errorStore.fetchTrace(props.errorId); + }; + + useEffect(() => { + ensureInstance(); + }, [props.errorId]); + + const errorIdInStore = errorStore.instance?.errorId; + const loading = errorStore.isLoading; + return ( + + +
No Error Found!
-
- ); - } + } + subtext="Please try to find existing one." + show={!loading && errorIdInStore == null} + > +
+ + + + +
+ + ); } + +export default observer(ErrorInfo); diff --git a/frontend/app/components/Errors/Error/MainSection.js b/frontend/app/components/Errors/Error/MainSection.js index a74755408..86d3d26d7 100644 --- a/frontend/app/components/Errors/Error/MainSection.js +++ b/frontend/app/components/Errors/Error/MainSection.js @@ -1,154 +1,134 @@ +import { RESOLVED } from 'Types/errorInfo'; +import { FilterKey } from 'Types/filter/filterType'; +import cn from 'classnames'; +import { observer } from 'mobx-react-lite'; import React from 'react'; import { connect } from 'react-redux'; -import cn from 'classnames'; -import withSiteIdRouter from 'HOCs/withSiteIdRouter'; -import { ErrorDetails, Icon, Loader, Button } from 'UI'; -import { sessions as sessionsRoute } from 'App/routes'; -import { RESOLVED } from 'Types/errorInfo'; -import { addFilterByKeyAndValue } from 'Duck/search'; -import { resolve, unresolve, ignore, toggleFavorite } from 'Duck/errors'; +import { withRouter } from 'react-router-dom'; + import { resentOrDate } from 'App/date'; +import { useStore } from 'App/mstore'; +import { sessions as sessionsRoute } from 'App/routes'; import Divider from 'Components/Errors/ui/Divider'; import ErrorName from 'Components/Errors/ui/ErrorName'; import Label from 'Components/Errors/ui/Label'; -import { FilterKey } from 'Types/filter/filterType'; +import { addFilterByKeyAndValue } from 'Duck/search'; +import { Button, ErrorDetails, Icon, Loader } from 'UI'; import SessionBar from './SessionBar'; -@withSiteIdRouter -@connect( - (state) => ({ - error: state.getIn(['errors', 'instance']), - trace: state.getIn(['errors', 'instanceTrace']), - sourcemapUploaded: state.getIn(['errors', 'sourcemapUploaded']), - resolveToggleLoading: - state.getIn(['errors', 'resolve', 'loading']) || - state.getIn(['errors', 'unresolve', 'loading']), - ignoreLoading: state.getIn(['errors', 'ignore', 'loading']), - toggleFavoriteLoading: state.getIn(['errors', 'toggleFavorite', 'loading']), - traceLoading: state.getIn(['errors', 'fetchTrace', 'loading']), - }), - { - resolve, - unresolve, - ignore, - toggleFavorite, - addFilterByKeyAndValue, - } -) -export default class MainSection extends React.PureComponent { - resolve = () => { - const { error } = this.props; - this.props.resolve(error.errorId); - }; +function MainSection(props) { + const { errorStore } = useStore(); + const error = errorStore.instance; + const trace = errorStore.instanceTrace; + const sourcemapUploaded = errorStore.sourcemapUploaded; + const loading = errorStore.isLoading; + const addFilterByKeyAndValue = props.addFilterByKeyAndValue; + const className = props.className; - unresolve = () => { - const { error } = this.props; - this.props.unresolve(error.errorId); - }; - - ignore = () => { - const { error } = this.props; - this.props.ignore(error.errorId); - }; - bookmark = () => { - const { error } = this.props; - this.props.toggleFavorite(error.errorId); - }; - - findSessions = () => { - this.props.addFilterByKeyAndValue(FilterKey.ERROR, this.props.error.message); + const findSessions = () => { + addFilterByKeyAndValue(FilterKey.ERROR, error.message); this.props.history.push(sessionsRoute()); }; - - render() { - const { - error, - trace, - sourcemapUploaded, - ignoreLoading, - resolveToggleLoading, - toggleFavoriteLoading, - className, - traceLoading, - } = this.props; - const isPlayer = window.location.pathname.includes('/session/'); - - return ( -
-
- -
-
- {error.message} + return ( +
+
+ +
+
+ {error.message} +
+
+
+
-
-
-
-
Over the past 30 days
+
+ Over the past 30 days
- - -
-
-

Last session with this error

- {resentOrDate(error.lastOccurrence)} - -
- - {error.customTags.length > 0 ? ( -
-
- More Info (most recent call) -
-
- {error.customTags.map((tag) => ( -
-
{Object.entries(tag)[0][0]}
{Object.entries(tag)[0][1]}
-
- ))} -
-
- ) : null} -
- -
- - - -
- ); - } + + +
+
+

+ Last session with this error +

+ + {resentOrDate(error.lastOccurrence)} + + +
+ + {error.customTags.length > 0 ? ( +
+
+ More Info{' '} + (most recent call) +
+
+ {error.customTags.map((tag) => ( +
+
+ {Object.entries(tag)[0][0]} +
{' '} +
+ {Object.entries(tag)[0][1]} +
+
+ ))} +
+
+ ) : null} +
+ +
+ + + +
+
+ ); } + +export default withRouter( + connect(null, { addFilterByKeyAndValue })(observer(MainSection)) +); diff --git a/frontend/app/components/Errors/Errors.js b/frontend/app/components/Errors/Errors.js deleted file mode 100644 index b9ec178b5..000000000 --- a/frontend/app/components/Errors/Errors.js +++ /dev/null @@ -1,154 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import withSiteIdRouter from 'HOCs/withSiteIdRouter'; -import withPermissions from 'HOCs/withPermissions' -import { UNRESOLVED, RESOLVED, IGNORED, BOOKMARK } from "Types/errorInfo"; -import { fetchBookmarks, editOptions } from "Duck/errors"; -import { applyFilter } from 'Duck/search'; -import { errors as errorsRoute, isRoute } from "App/routes"; -import withPageTitle from 'HOCs/withPageTitle'; -import cn from 'classnames'; -import SelectDateRange from 'Shared/SelectDateRange'; -import Period from 'Types/app/period'; - -import List from './List/List'; -import ErrorInfo from './Error/ErrorInfo'; -import Header from './Header'; -import SideMenuSection from './SideMenu/SideMenuSection'; -import SideMenuDividedItem from './SideMenu/SideMenuDividedItem'; - -const ERRORS_ROUTE = errorsRoute(); - -function getStatusLabel(status) { - switch(status) { - case UNRESOLVED: - return "Unresolved"; - case RESOLVED: - return "Resolved"; - case IGNORED: - return "Ignored"; - default: - return ""; - } -} - -@withPermissions(['ERRORS'], 'page-margin container-90') -@withSiteIdRouter -@connect(state => ({ - list: state.getIn([ "errors", "list" ]), - status: state.getIn([ "errors", "options", "status" ]), - filter: state.getIn([ 'search', 'instance' ]), -}), { - fetchBookmarks, - applyFilter, - editOptions, -}) -@withPageTitle("Errors - OpenReplay") -export default class Errors extends React.PureComponent { - constructor(props) { - super(props) - this.state = { - filter: '', - } - } - - ensureErrorsPage() { - const { history } = this.props; - if (!isRoute(ERRORS_ROUTE, history.location.pathname)) { - history.push(ERRORS_ROUTE); - } - } - - onStatusItemClick = ({ key }) => { - this.props.editOptions({ status: key }); - } - - onBookmarksClick = () => { - this.props.editOptions({ status: BOOKMARK }); - } - - onDateChange = (e) => { - const dateValues = e.toJSON(); - this.props.applyFilter(dateValues); - }; - - render() { - const { - count, - match: { - params: { errorId } - }, - status, - list, - history, - filter, - } = this.props; - - const { startDate, endDate, rangeValue } = filter; - const period = new Period({ start: startDate, end: endDate, rangeName: rangeValue }); - - return ( -
-
- - -
- -
- { errorId == null ? - <> -
-
-
- Seen in - -
-
- - - : - - } -
-
- ); - } -} \ No newline at end of file diff --git a/frontend/app/components/Errors/Header.js b/frontend/app/components/Errors/Header.js deleted file mode 100644 index 283eb599b..000000000 --- a/frontend/app/components/Errors/Header.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -function Header({ text, count }) { - return ( -

- { text } - { count != null && { count } } -

- ); -} - -Header.displayName = "Header"; - -export default Header; - \ No newline at end of file diff --git a/frontend/app/components/Errors/List/List.js b/frontend/app/components/Errors/List/List.js deleted file mode 100644 index a51a3c2b0..000000000 --- a/frontend/app/components/Errors/List/List.js +++ /dev/null @@ -1,259 +0,0 @@ -import React from 'react'; -import { connect } from 'react-redux'; -import { Set } from "immutable"; -import { NoContent, Loader, Checkbox, IconButton, Input, Pagination } from 'UI'; -import { merge, resolve, unresolve, ignore, updateCurrentPage, editOptions } from "Duck/errors"; -import { applyFilter } from 'Duck/filters'; -import { IGNORED, UNRESOLVED } from 'Types/errorInfo'; -import Divider from 'Components/Errors/ui/Divider'; -import ListItem from './ListItem/ListItem'; -import { debounce } from 'App/utils'; -import Select from 'Shared/Select'; -import EmptyStateSvg from '../../../svg/no-results.svg'; - -const sortOptionsMap = { - 'occurrence-desc': 'Last Occurrence', - 'occurrence-desc': 'First Occurrence', - 'sessions-asc': 'Sessions Ascending', - 'sessions-desc': 'Sessions Descending', - 'users-asc': 'Users Ascending', - 'users-desc': 'Users Descending', -}; -const sortOptions = Object.entries(sortOptionsMap) - .map(([ value, label ]) => ({ value, label })); - -@connect(state => ({ - loading: state.getIn([ "errors", "loading" ]), - resolveToggleLoading: state.getIn(["errors", "resolve", "loading"]) || - state.getIn(["errors", "unresolve", "loading"]), - ignoreLoading: state.getIn([ "errors", "ignore", "loading" ]), - mergeLoading: state.getIn([ "errors", "merge", "loading" ]), - currentPage: state.getIn(["errors", "currentPage"]), - limit: state.getIn(["errors", "limit"]), - total: state.getIn([ 'errors', 'totalCount' ]), - sort: state.getIn([ 'errors', 'options', 'sort' ]), - order: state.getIn([ 'errors', 'options', 'order' ]), - query: state.getIn([ "errors", "options", "query" ]), -}), { - merge, - resolve, - unresolve, - ignore, - applyFilter, - updateCurrentPage, - editOptions, -}) -export default class List extends React.PureComponent { - constructor(props) { - super(props) - this.state = { - checkedAll: false, - checkedIds: Set(), - query: props.query, - } - this.debounceFetch = debounce(this.props.editOptions, 1000); - } - - componentDidMount() { - this.props.applyFilter({ }); - } - - check = ({ errorId }) => { - const { checkedIds } = this.state; - const newCheckedIds = checkedIds.contains(errorId) - ? checkedIds.remove(errorId) - : checkedIds.add(errorId); - this.setState({ - checkedAll: newCheckedIds.size === this.props.list.size, - checkedIds: newCheckedIds - }); - } - - checkAll = () => { - if (this.state.checkedAll) { - this.setState({ - checkedAll: false, - checkedIds: Set(), - }); - } else { - this.setState({ - checkedAll: true, - checkedIds: this.props.list.map(({ errorId }) => errorId).toSet(), - }); - } - } - - resetChecked = () => { - this.setState({ - checkedAll: false, - checkedIds: Set(), - }); - } - - currentCheckedIds() { - return this.state.checkedIds - .intersect(this.props.list.map(({ errorId }) => errorId).toSet()); - } - - merge = () => { - this.props.merge(currentCheckedIds().toJS()).then(this.resetChecked); - } - - applyToAllChecked(f) { - return Promise.all(this.currentCheckedIds().map(f).toJS()).then(this.resetChecked); - } - - resolve = () => { - this.applyToAllChecked(this.props.resolve); - } - - unresolve = () => { - this.applyToAllChecked(this.props.unresolve); - } - - ignore = () => { - this.applyToAllChecked(this.props.ignore); - } - - addPage = () => this.props.updateCurrentPage(this.props.currentPage + 1) - - writeOption = ({ name, value }) => { - const [ sort, order ] = value.split('-'); - if (name === 'sort') { - this.props.editOptions({ sort, order }); - } - } - - // onQueryChange = ({ target: { value, name } }) => props.edit({ [ name ]: value }) - - onQueryChange = ({ target: { value, name } }) => { - this.setState({ query: value }); - this.debounceFetch({ query: value }); - } - - render() { - const { - list, - status, - loading, - ignoreLoading, - resolveToggleLoading, - mergeLoading, - currentPage, - total, - sort, - order, - limit, - } = this.props; - const { - checkedAll, - checkedIds, - query, - } = this.state; - const someLoading = loading || ignoreLoading || resolveToggleLoading || mergeLoading; - const currentCheckedIds = this.currentCheckedIds(); - - return ( -
-
-
- - { status === UNRESOLVED - ? - : - } - { status !== IGNORED && - - } -
-
- Sort By - -
-
- - - - No Errors Found! - - } - subtext="Please try to change your search parameters." - // animatedIcon="empty-state" - show={ !loading && list.size === 0} - > - - { list.map(e => -
- - -
- )} -
- this.props.updateCurrentPage(page)} - limit={limit} - debounceRequest={500} - /> -
-
- - - ); - } -} diff --git a/frontend/app/components/Errors/List/ListItem/ListItem.js b/frontend/app/components/Errors/List/ListItem/ListItem.js deleted file mode 100644 index 0e4474567..000000000 --- a/frontend/app/components/Errors/List/ListItem/ListItem.js +++ /dev/null @@ -1,87 +0,0 @@ -import React from 'react'; -import { BarChart, Bar, YAxis, Tooltip, XAxis } from 'recharts'; -import cn from 'classnames'; -import { DateTime } from 'luxon' -import { diffFromNowString } from 'App/date'; -import { error as errorRoute } from 'App/routes'; -import { IGNORED, RESOLVED } from 'Types/errorInfo'; -import { Checkbox, Link } from 'UI'; -import ErrorName from 'Components/Errors/ui/ErrorName'; -import Label from 'Components/Errors/ui/Label'; -import stl from './listItem.module.css'; -import { Styles } from '../../../Dashboard/Widgets/common'; - -const CustomTooltip = ({ active, payload, label }) => { - if (active) { - const p = payload[0].payload; - const dateStr = p.timestamp ? DateTime.fromMillis(p.timestamp).toFormat('l') : '' - return ( -
-

{dateStr}

-

Sessions: {p.count}

-
- ); - } - - return null; -}; - -function ListItem({ className, onCheck, checked, error, disabled }) { - - const getDateFormat = val => { - const d = new Date(val); - return (d.getMonth()+ 1) + '/' + d.getDate() - } - - return ( -
- onCheck(error) } - /> - -
- - -
- { error.message } -
- -
- - - - } /> - - -
- ); -} - - -ListItem.displayName = "ListItem"; -export default ListItem; \ No newline at end of file diff --git a/frontend/app/components/Errors/List/ListItem/listItem.module.css b/frontend/app/components/Errors/List/ListItem/listItem.module.css deleted file mode 100644 index c9f7589b9..000000000 --- a/frontend/app/components/Errors/List/ListItem/listItem.module.css +++ /dev/null @@ -1,16 +0,0 @@ -.name { - min-width: 55%; -} - -.sessions { - width: 6%; -} - -.users { - width: 5%; -} - -.occurrence { - width: 15%; - min-width: 152px; -} diff --git a/frontend/app/components/Errors/SideMenu/SideMenuDividedItem.js b/frontend/app/components/Errors/SideMenu/SideMenuDividedItem.js deleted file mode 100644 index 9efa63ed4..000000000 --- a/frontend/app/components/Errors/SideMenu/SideMenuDividedItem.js +++ /dev/null @@ -1,20 +0,0 @@ -import React from 'react'; -import { SideMenuitem } from "UI"; -import Divider from 'Components/Errors/ui/Divider'; -function SideMenuDividedItem({ className, noTopDivider = false, noBottomDivider = false, ...props }) { - return ( -
- { !noTopDivider && } - - { !noBottomDivider && } -
- ); -} - -SideMenuDividedItem.displayName = "SideMenuDividedItem"; - -export default SideMenuDividedItem; - diff --git a/frontend/app/components/Errors/SideMenu/SideMenuHeader.js b/frontend/app/components/Errors/SideMenu/SideMenuHeader.js deleted file mode 100644 index 32f1f7fc6..000000000 --- a/frontend/app/components/Errors/SideMenu/SideMenuHeader.js +++ /dev/null @@ -1,14 +0,0 @@ -import React from 'react'; -import cn from 'classnames'; -import stl from './sideMenuHeader.module.css'; - -function SideMenuHeader({ text, className }) { - return ( -
- { text } -
- ) -} - -SideMenuHeader.displayName = "SideMenuHeader"; -export default SideMenuHeader; diff --git a/frontend/app/components/Errors/SideMenu/SideMenuSection.js b/frontend/app/components/Errors/SideMenu/SideMenuSection.js deleted file mode 100644 index f2d6732f2..000000000 --- a/frontend/app/components/Errors/SideMenu/SideMenuSection.js +++ /dev/null @@ -1,24 +0,0 @@ -import React from 'react'; -import { SideMenuitem } from 'UI'; -import SideMenuHeader from './SideMenuHeader'; - -function SideMenuSection({ title, items, onItemClick }) { - return ( - <> - - { items.map(item => - onItemClick(item)} - /> - )} - - ); -} - -SideMenuSection.displayName = "SideMenuSection"; - -export default SideMenuSection; \ No newline at end of file diff --git a/frontend/app/components/Errors/SideMenu/sideMenuHeader.module.css b/frontend/app/components/Errors/SideMenu/sideMenuHeader.module.css deleted file mode 100644 index 5dce4e250..000000000 --- a/frontend/app/components/Errors/SideMenu/sideMenuHeader.module.css +++ /dev/null @@ -1,4 +0,0 @@ -.label { - letter-spacing: 0.2em; - color: gray; -} \ No newline at end of file diff --git a/frontend/app/components/shared/ErrorsBadge/ErrorsBadge.js b/frontend/app/components/shared/ErrorsBadge/ErrorsBadge.js index bd84e0f49..d8d4ed323 100644 --- a/frontend/app/components/shared/ErrorsBadge/ErrorsBadge.js +++ b/frontend/app/components/shared/ErrorsBadge/ErrorsBadge.js @@ -1,34 +1,40 @@ -import React, { useEffect } from 'react' -import { fetchNewErrorsCount } from 'Duck/errors' -import { connect } from 'react-redux' -import stl from './errorsBadge.module.css' -import { - getDateRangeFromValue, - DATE_RANGE_VALUES, -} from 'App/dateRange'; +import { observer } from 'mobx-react-lite'; +import React, { useEffect } from 'react'; +import { connect } from 'react-redux'; + +import { DATE_RANGE_VALUES, getDateRangeFromValue } from 'App/dateRange'; +import { useStore } from 'App/mstore'; + +import stl from './errorsBadge.module.css'; const AUTOREFRESH_INTERVAL = 5 * 60 * 1000; const weekRange = getDateRangeFromValue(DATE_RANGE_VALUES.LAST_7_DAYS); -let intervalId = null +let intervalId = null; -function ErrorsBadge({ errorsStats = {}, fetchNewErrorsCount, projects }) { +function ErrorsBadge({ projects }) { + const { errorsStore } = useStore(); + const errorsStats = errorsStore.stats; useEffect(() => { if (projects.size === 0 || !!intervalId) return; - - const params = { startTimestamp: weekRange.start.ts, endTimestamp: weekRange.end.ts }; - fetchNewErrorsCount(params) + + const params = { + startTimestamp: weekRange.start.ts, + endTimestamp: weekRange.end.ts, + }; + errorsStore.fetchNewErrorsCount(params); intervalId = setInterval(() => { - fetchNewErrorsCount(params); + errorsStore.fetchNewErrorsCount(params); }, AUTOREFRESH_INTERVAL); - }, [projects]) - + }, [projects]); + return errorsStats.unresolvedAndUnviewed > 0 ? ( -
{
}
- ) : '' +
{
}
+ ) : ( + '' + ); } -export default connect(state => ({ - errorsStats: state.getIn([ 'errors', 'stats' ]), - projects: state.getIn([ 'site', 'list' ]), -}), { fetchNewErrorsCount })(ErrorsBadge) +export default connect((state) => ({ + projects: state.getIn(['site', 'list']), +}))(observer(ErrorsBadge)); diff --git a/frontend/app/duck/errors.js b/frontend/app/duck/errors.js deleted file mode 100644 index 80d731bee..000000000 --- a/frontend/app/duck/errors.js +++ /dev/null @@ -1,239 +0,0 @@ -import { List, Map } from 'immutable'; -import { clean as cleanParams } from 'App/api_client'; -import ErrorInfo, { RESOLVED, UNRESOLVED, IGNORED, BOOKMARK } from 'Types/errorInfo'; -import { fetchListType, fetchType } from './funcTools/crud'; -import { createRequestReducer, ROOT_KEY } from './funcTools/request'; -import { array, success, failure, createListUpdater, mergeReducers } from './funcTools/tools'; -import { reduceThenFetchResource } from './search' - -const name = "error"; -const idKey = "errorId"; -const PER_PAGE = 10; -const DEFAULT_SORT = 'occurrence'; -const DEFAULT_ORDER = 'desc'; - -const EDIT_OPTIONS = `${name}/EDIT_OPTIONS`; -const FETCH_LIST = fetchListType(name); -const FETCH = fetchType(name); -const FETCH_NEW_ERRORS_COUNT = fetchType('errors/FETCH_NEW_ERRORS_COUNT'); -const RESOLVE = "errors/RESOLVE"; -const UNRESOLVE = "errors/UNRESOLVE"; -const IGNORE = "errors/IGNORE"; -const MERGE = "errors/MERGE"; -const TOGGLE_FAVORITE = "errors/TOGGLE_FAVORITE"; -const FETCH_TRACE = "errors/FETCH_TRACE"; -const UPDATE_CURRENT_PAGE = "errors/UPDATE_CURRENT_PAGE"; -const UPDATE_KEY = `${name}/UPDATE_KEY`; - -function chartWrapper(chart = []) { - return chart.map(point => ({ ...point, count: Math.max(point.count, 0) })); -} - -const updateItemInList = createListUpdater(idKey); -const updateInstance = (state, instance) => state.getIn([ "instance", idKey ]) === instance[ idKey ] - ? state.mergeIn([ "instance" ], instance) - : state; - -const initialState = Map({ - totalCount: 0, - list: List(), - instance: ErrorInfo(), - instanceTrace: List(), - stats: Map(), - sourcemapUploaded: true, - currentPage: 1, - limit: PER_PAGE, - options: Map({ - sort: DEFAULT_SORT, - order: DEFAULT_ORDER, - status: UNRESOLVED, - query: '', - }), - // sort: DEFAULT_SORT, - // order: DEFAULT_ORDER, -}); - - -function reducer(state = initialState, action = {}) { - let updError; - switch (action.type) { - case EDIT_OPTIONS: - return state.mergeIn(["options"], action.instance).set('currentPage', 1); - case success(FETCH): - if (state.get("list").find(e => e.get("errorId") === action.id)) { - return updateItemInList(state, { errorId: action.data.errorId, viewed: true }) - .set("instance", ErrorInfo(action.data)); - } else { - return state.set("instance", ErrorInfo(action.data)); - } - case failure(FETCH): - return state.set("instance", ErrorInfo()); - case success(FETCH_TRACE): - return state.set("instanceTrace", List(action.data.trace)).set('sourcemapUploaded', action.data.sourcemapUploaded); - case success(FETCH_LIST): - const { data } = action; - return state - .set("totalCount", data ? data.total : 0) - .set("list", List(data && data.errors).map(ErrorInfo) - .filter(e => e.parentErrorId == null) - .map(e => e.update("chart", chartWrapper))) - case success(RESOLVE): - updError = { errorId: action.id, status: RESOLVED, disabled: true }; - return updateItemInList(updateInstance(state, updError), updError); - case success(UNRESOLVE): - updError = { errorId: action.id, status: UNRESOLVED, disabled: true }; - return updateItemInList(updateInstance(state, updError), updError); - case success(IGNORE): - updError = { errorId: action.id, status: IGNORED, disabled: true }; - return updateItemInList(updateInstance(state, updError), updError); - case success(TOGGLE_FAVORITE): - return state.mergeIn([ "instance" ], { favorite: !state.getIn([ "instance", "favorite" ]) }) - case success(MERGE): - const ids = action.ids.slice(1); - return state.update("list", list => list.filter(e => !ids.includes(e.errorId))); - case success(FETCH_NEW_ERRORS_COUNT): - return state.set('stats', action.data); - case UPDATE_KEY: - return state.set(action.key, action.value); - case UPDATE_CURRENT_PAGE: - return state.set('currentPage', action.page); - } - return state; -} - -export default mergeReducers( - reducer, - createRequestReducer({ - [ ROOT_KEY ]: FETCH_LIST, - fetch: FETCH, - fetchTrace: FETCH_TRACE, - resolve: RESOLVE, - unresolve: UNRESOLVE, - ignore: IGNORE, - merge: MERGE, - toggleFavorite: TOGGLE_FAVORITE, - }), -); - - -export function fetch(id) { - return { - id, - types: array(FETCH), - call: c => c.get(`/errors/${id}`), - } -} - -export function fetchTrace(id) { - return { - id, - types: array(FETCH_TRACE), - call: c => c.get(`/errors/${id}/sourcemaps`), - } -} - -export const fetchList = (params = {}, clear = false) => (dispatch, getState) => { - params.page = getState().getIn(['errors', 'currentPage']); - params.limit = PER_PAGE; - - const options = getState().getIn(['errors', 'options']).toJS(); - if (options.status === BOOKMARK) { - options.bookmarked = true; - options.status = 'all'; - } - - return dispatch({ - types: array(FETCH_LIST), - call: client => client.post('/errors/search', { ...params, ...options }), - clear, - params: cleanParams(params), - }); -}; - -// export function fetchList(params = {}, clear = false) { -// return { -// types: array(FETCH_LIST), -// call: client => client.post('/errors/search', params), -// clear, -// params: cleanParams(params), -// }; -// } - -export function fetchBookmarks() { - return { - types: array(FETCH_LIST), - call: client => client.post('/errors/search?favorite', {}) - } -} - -export const resolve = (id) => (dispatch, getState) => { - const list = getState().getIn(['errors', 'list']); - const index = list.findIndex(e => e.get('errorId') === id); - const error = list.get(index); - if (error.get('status') === RESOLVED) return; - - return dispatch({ - types: array(RESOLVE), - id, - call: client => client.get(`/errors/${ id }/solve`), - }) -} - -export const unresolve = (id) => (dispatch, getState) => { - const list = getState().getIn(['errors', 'list']); - const index = list.findIndex(e => e.get('errorId') === id); - const error = list.get(index); - if (error.get('status') === UNRESOLVED) return; - - return dispatch({ - types: array(UNRESOLVE), - id, - call: client => client.get(`/errors/${ id }/unsolve`), - }) -} - -export const ignore = (id) => (dispatch, getState) => { - const list = getState().getIn(['errors', 'list']); - const index = list.findIndex(e => e.get('errorId') === id); - const error = list.get(index); - if (error.get('status') === IGNORED) return; - - return dispatch({ - types: array(IGNORE), - id, - call: client => client.get(`/errors/${ id }/ignore`), - }) -} - -export function merge(ids) { - return { - types: array(MERGE), - ids, - call: client => client.post(`/errors/merge`, { errors: ids }), - } -} - -export function toggleFavorite(id) { - return { - types: array(TOGGLE_FAVORITE), - id, - call: client => client.get(`/errors/${ id }/favorite`), - } -} - -export function fetchNewErrorsCount(params = {}) { - return { - types: array(FETCH_NEW_ERRORS_COUNT), - call: client => client.get(`/errors/stats`, params), - } -} - -export const updateCurrentPage = reduceThenFetchResource((page) => ({ - type: UPDATE_CURRENT_PAGE, - page, -})); - -export const editOptions = reduceThenFetchResource((instance) => ({ - type: EDIT_OPTIONS, - instance -})); \ No newline at end of file diff --git a/frontend/app/duck/filters.js b/frontend/app/duck/filters.js index 34b5f9db9..146671df6 100644 --- a/frontend/app/duck/filters.js +++ b/frontend/app/duck/filters.js @@ -6,7 +6,6 @@ import Event from 'Types/filter/event'; import CustomFilter, { KEYS } from 'Types/filter/customFilter'; import withRequestState, { RequestTypes } from './requestStateCreator'; import { fetchList as fetchSessionList } from './sessions'; -import { fetchList as fetchErrorsList } from './errors'; const ERRORS_ROUTE = errorsRoute(); @@ -85,7 +84,7 @@ const reducer = (state = initialState, action = {}) => { case FETCH_LIST.SUCCESS: const flows = List(action.data).map(SavedFilter) let _state = state.set('list', flows) - + if (!hasFilterOptions) { const tmp = {} flows.forEach(i => { @@ -119,8 +118,8 @@ const reducer = (state = initialState, action = {}) => { case SET_ACTIVE_KEY: return state.set('activeFilterKey', action.filterKey); case APPLY: - return action.fromUrl - ? state.set('appliedFilter', + return action.fromUrl + ? state.set('appliedFilter', Filter(action.filter) .set('events', state.getIn([ 'appliedFilter', 'events' ])) ) @@ -148,7 +147,7 @@ const reducer = (state = initialState, action = {}) => { if (action.index >= 0) // replacing an event return state.setIn([ 'appliedFilter', 'events', action.index ], event) else - return state.updateIn([ 'appliedFilter', 'events' ], list => action.single + return state.updateIn([ 'appliedFilter', 'events' ], list => action.single ? List([ event ]) : list.push(event)); case REMOVE_EVENT: @@ -166,7 +165,7 @@ const reducer = (state = initialState, action = {}) => { return state.setIn([ 'appliedFilter', 'events' ], List()) .setIn([ 'appliedFilter', 'filters' ], List()) .set('searchQuery', ''); - + case ADD_ATTRIBUTE: const filter = CustomFilter(action.filter); @@ -174,7 +173,7 @@ const reducer = (state = initialState, action = {}) => { return state.setIn([ 'appliedFilter', 'filters', action.index], filter); else return state.updateIn([ 'appliedFilter', 'filters'], filters => filters.push(filter)); - + case EDIT_ATTRIBUTE: return state.setIn([ 'appliedFilter', 'filters', action.index, action.key ], action.value ); case REMOVE_ATTRIBUTE: @@ -209,7 +208,7 @@ const reduceThenFetchResource = actionCreator => (...args) => (dispatch, getStat // Hello AGILE! return isRoute(ERRORS_ROUTE, window.location.pathname) - ? dispatch(fetchErrorsList(filter)) + ? null : dispatch(fetchSessionList(filter)); } @@ -386,7 +385,7 @@ export const edit = instance => { export const updateValue = (filterType, index, value) => { return { type: UPDATE_VALUE, - filterType, + filterType, index, value } diff --git a/frontend/app/duck/index.ts b/frontend/app/duck/index.ts index b4a497de3..2a14ce94e 100644 --- a/frontend/app/duck/index.ts +++ b/frontend/app/duck/index.ts @@ -9,7 +9,6 @@ import sources from './sources'; import site from './site'; import customFields from './customField'; import integrations from './integrations'; -import errors from './errors'; import funnels from './funnels'; import customMetrics from './customMetrics'; import search from './search'; @@ -22,7 +21,6 @@ const rootReducer = combineReducers({ funnelFilters, site, customFields, - errors, funnels, customMetrics, search, diff --git a/frontend/app/duck/search.js b/frontend/app/duck/search.js index 274b1030c..965f5673b 100644 --- a/frontend/app/duck/search.js +++ b/frontend/app/duck/search.js @@ -14,7 +14,6 @@ import { List, Map } from 'immutable'; import { DURATION_FILTER } from 'App/constants/storageKeys'; import { errors as errorsRoute, isRoute } from 'App/routes'; -import { fetchList as fetchErrorsList } from './errors'; import { editType, fetchListType, @@ -252,7 +251,7 @@ export const reduceThenFetchResource = dispatch(updateLatestRequestTime()); return isRoute(ERRORS_ROUTE, window.location.pathname) - ? dispatch(fetchErrorsList(filter)) + ? null : dispatch(fetchSessionList(filter, forceFetch)); }; @@ -470,7 +469,7 @@ export const refreshFilterOptions = () => (dispatch, getState) => { const currentProject = getState().getIn(['site', 'instance']); return dispatch({ type: REFRESH_FILTER_OPTIONS, - isMobile: currentProject?.platform === 'ios' + isMobile: currentProject?.platform === 'ios', }); }; diff --git a/frontend/app/mstore/errorStore.ts b/frontend/app/mstore/errorStore.ts index b7aaed549..909dfcff6 100644 --- a/frontend/app/mstore/errorStore.ts +++ b/frontend/app/mstore/errorStore.ts @@ -1,55 +1,91 @@ -import { makeAutoObservable } from "mobx" -import { errorService } from "App/services" -import Error from "./types/error" +import { makeAutoObservable } from 'mobx'; + +import apiClient from 'App/api_client'; +import { errorService } from 'App/services'; + +import { ErrorInfo } from './types/error'; export default class ErrorStore { - isLoading: boolean = false - isSaving: boolean = false + instance: ErrorInfo | null = null; + instanceTrace: Record = []; + stats: Record = {}; + sourcemapUploaded = false; + isLoading = false; + errorStates: Record = {}; - errors: any[] = [] - instance: Error | null = null + constructor() { + makeAutoObservable(this); + } - constructor() { - makeAutoObservable(this, { - - }) + setLoadingState(value: boolean) { + this.isLoading = value; + } + + setErrorState(actionKey: string, error: any) { + this.errorStates[actionKey] = error; + } + + setInstance(errorData: ErrorInfo | null) { + this.instance = errorData ? new ErrorInfo(errorData) : null; + } + + setInstanceTrace(trace: any) { + this.instanceTrace = trace || []; + } + + setSourcemapUploaded(value: boolean) { + this.sourcemapUploaded = value; + } + + setStats(stats: any) { + this.stats = stats; + } + + async fetchError(id: string) { + const actionKey = 'fetchError'; + this.setLoadingState(true); + this.setErrorState(actionKey, null); + + try { + const response = await errorService.fetchError(id); + const errorData = response.data; + this.setInstance(errorData); + } catch (error) { + this.setInstance(null); + this.setErrorState(actionKey, error); + } finally { + this.setLoadingState(false); } + } - updateKey(key: string, value: any) { - this[key] = value - } + async fetchErrorTrace(id: string) { + const actionKey = 'fetchErrorTrace'; + this.setLoadingState(true); + this.setErrorState(actionKey, null); - fetchErrors(): Promise { - this.isLoading = true - return new Promise((resolve, reject) => { - errorService.all() - .then(response => { - const errors = response.map(e => new Error().fromJSON(e)); - this.errors = errors - resolve(errors) - }).catch(error => { - reject(error) - }).finally(() => { - this.isLoading = false - } - ) - }) + try { + const response = await errorService.fetchErrorTrace(id); + this.setInstanceTrace(response.data.trace); + this.setSourcemapUploaded(response.data.sourcemapUploaded); + } catch (error) { + this.setErrorState(actionKey, error); + } finally { + this.setLoadingState(false); } + } - fetchError(errorId: string): Promise { - this.isLoading = true - return new Promise((resolve, reject) => { - errorService.one(errorId) - .then(response => { - const error = new Error().fromJSON(response); - this.instance = error - resolve(error) - }).catch(error => { - reject(error) - }).finally(() => { - this.isLoading = false - } - ) - }) + async fetchNewErrorsCount(params: any) { + const actionKey = 'fetchNewErrorsCount'; + this.setLoadingState(true); + this.setErrorState(actionKey, null); + + try { + const response = await errorService.fetchNewErrorsCount(params); + this.setStats(response.data); + } catch (error) { + this.setErrorState(actionKey, error); + } finally { + this.setLoadingState(false); } -} \ No newline at end of file + } +} diff --git a/frontend/app/mstore/types/error.ts b/frontend/app/mstore/types/error.ts index 9682f61ea..08ff80b01 100644 --- a/frontend/app/mstore/types/error.ts +++ b/frontend/app/mstore/types/error.ts @@ -1,56 +1,102 @@ +import Session from './session'; + export default class Error { - sessionId: string = '' - messageId: string = '' - errorId: string = '' - projectId: string = '' - source: string = '' - name: string = '' - message: string = '' - time: string = '' - function: string = '?' - stack0InfoString: string = '' - status: string = '' - - chart: any = [] - sessions: number = 0 - users: number = 0 - firstOccurrence: string = '' - lastOccurrence: string = '' - timestamp: string = '' + sessionId: string = ''; + messageId: string = ''; + errorId: string = ''; + projectId: string = ''; + source: string = ''; + name: string = ''; + message: string = ''; + time: string = ''; + function: string = '?'; + stack0InfoString: string = ''; + status: string = ''; - constructor() { - } + chart: any = []; + sessions: number = 0; + users: number = 0; + firstOccurrence: string = ''; + lastOccurrence: string = ''; + timestamp: string = ''; - fromJSON(json: any) { - this.sessionId = json.sessionId - this.messageId = json.messageId - this.errorId = json.errorId - this.projectId = json.projectId - this.source = json.source - this.name = json.name - this.message = json.message - this.time = json.time - this.function = json.function - this.stack0InfoString = getStck0InfoString(json.stack || []) - this.status = json.status - - this.chart = json.chart - this.sessions = json.sessions - this.users = json.users - this.firstOccurrence = json.firstOccurrence - this.lastOccurrence = json.lastOccurrence - this.timestamp = json.timestamp - - return this - } + constructor() {} } function getStck0InfoString(stack: any) { - const stack0 = stack[0]; - if (!stack0) return ""; - let s = stack0.function || ""; - if (stack0.url) { - s += ` (${stack0.url})`; + const stack0 = stack[0]; + if (!stack0) return ''; + let s = stack0.function || ''; + if (stack0.url) { + s += ` (${stack0.url})`; + } + return s; +} + +export interface ErrorInfoData { + errorId?: string; + favorite: boolean; + viewed: boolean; + source: string; + name: string; + message: string; + stack0InfoString: string; + status: string; + parentErrorId?: string; + users: number; + sessions: number; + lastOccurrence: number; + firstOccurrence: number; + chart: any[]; + chart24: any[]; + chart30: any[]; + tags: string[]; + customTags: string[]; + lastHydratedSession: Session; + disabled: boolean; +} + +export class ErrorInfo implements ErrorInfoData { + errorId?: string; + favorite = false; + viewed = false; + source = ''; + name = ''; + message = ''; + stack0InfoString = ''; + status = ''; + parentErrorId?: string; + users = 0; + sessions = 0; + lastOccurrence = Date.now(); + firstOccurrence = Date.now(); + chart: any[] = []; + chart24: any[] = []; + chart30: any[] = []; + tags: string[] = []; + customTags: string[] = []; + lastHydratedSession: Session; + disabled = false; + + constructor(data?: Partial) { + if (data) { + Object.assign(this, data); } - return s; - } \ No newline at end of file + if (data?.lastHydratedSession) { + this.lastHydratedSession = new Session().fromJson( + data.lastHydratedSession + ); + } else { + this.lastHydratedSession = new Session(); + } + } + + static fromJS(data: any): ErrorInfo { + const { stack, lastHydratedSession, ...other } = data; + return new ErrorInfo({ + ...other, + lastHydratedSession: new Session().fromJson(data.lastHydratedSession), + stack0InfoString: getStck0InfoString(stack || []), + }); + } +} diff --git a/frontend/app/services/ErrorService.ts b/frontend/app/services/ErrorService.ts index 9e9402b45..dc8b3336e 100644 --- a/frontend/app/services/ErrorService.ts +++ b/frontend/app/services/ErrorService.ts @@ -1,17 +1,27 @@ import BaseService from './BaseService'; export default class ErrorService extends BaseService { - all(params: any = {}): Promise { - return this.client.post('/errors/search', params) - .then(r => r.json()) - .then((response: { data: any; }) => response.data || []) - .catch(e => Promise.reject(e)) - } + fetchError = async (id: string) => { + const r = await this.client.get(`/errors/${id}`); - one(id: string): Promise { - return this.client.get(`/errors/${id}`) - .then(r => r.json()) - .then((response: { data: any; }) => response.data || {}) - .catch(e => Promise.reject(e)) - } -} \ No newline at end of file + return await r.json(); + }; + + fetchErrorList = async (params: Record) => { + const r = await this.client.post('/errors/search', params); + + return await r.json(); + }; + + fetchErrorTrace = async (id: string) => { + const r = await this.client.get(`/errors/${id}/sourcemaps`); + + return await r.json(); + }; + + fetchNewErrorsCount = async (params: any) => { + const r = await this.client.get('/errors/stats', params); + + return await r.json(); + }; +} diff --git a/frontend/app/types/filter/customFilter.js b/frontend/app/types/filter/customFilter.js index 29557da21..09e0d3ff7 100644 --- a/frontend/app/types/filter/customFilter.js +++ b/frontend/app/types/filter/customFilter.js @@ -75,7 +75,7 @@ export const KEYS = { UTM_SOURCE, UTM_MEDIUM, UTM_CAMPAIGN, - + DOM_COMPLETE, LARGEST_CONTENTFUL_PAINT_TIME, TIME_BETWEEN_EVENTS, @@ -89,7 +89,7 @@ const getOperatorDefault = (type) => { if (type === KEYS.SLOW_SESSION) return 'true'; if (type === KEYS.CLICK_RAGE) return 'true'; if (type === KEYS.CLICK) return 'on'; - + return 'is'; } diff --git a/frontend/app/types/filter/filter.js b/frontend/app/types/filter/filter.js index 09fbd2d63..ea94a90a4 100644 --- a/frontend/app/types/filter/filter.js +++ b/frontend/app/types/filter/filter.js @@ -2,7 +2,7 @@ import { List, Map } from 'immutable'; import Record from 'Types/Record'; import { KEYS } from 'Types/filter/customFilter'; import { TYPES } from 'Types/filter/event'; -import { +import { DATE_RANGE_VALUES, CUSTOM_RANGE, getDateRangeFromValue @@ -52,7 +52,7 @@ export default Record({ const js = this.toJS(); js.filters = js.filters.map(filter => { filter.type = filter.key - + delete filter.category delete filter.icon delete filter.operatorOptions @@ -156,7 +156,6 @@ export const defaultFilters = [ { label: 'UTM Source', key: KEYS.UTM_SOURCE, type: KEYS.UTM_SOURCE, filterKey: KEYS.UTM_SOURCE, icon: 'exclamation-circle', isFilter: true }, { label: 'UTM Medium', key: KEYS.UTM_MEDIUM, type: KEYS.UTM_MEDIUM, filterKey: KEYS.UTM_MEDIUM, icon: 'exclamation-circle', isFilter: true }, { label: 'UTM Campaign', key: KEYS.UTM_CAMPAIGN, type: KEYS.UTM_CAMPAIGN, filterKey: KEYS.UTM_CAMPAIGN, icon: 'exclamation-circle', isFilter: true }, - { label: 'Fetch Requests', key: KEYS.FETCH, type: KEYS.FETCH, filterKey: KEYS.FETCH, icon: 'fetch', isFilter: false }, { label: 'GraphQL Queries', key: KEYS.GRAPHQL, type: KEYS.GRAPHQL, filterKey: KEYS.GRAPHQL, icon: 'vendors/graphql', isFilter: false }, { label: 'Store Actions', key: KEYS.STATEACTION, type: KEYS.STATEACTION, filterKey: KEYS.STATEACTION, icon: 'store', isFilter: false }, @@ -205,4 +204,4 @@ export const getEventIcon = (filter) => { return 'integrations/' + source; } return ''; -} \ No newline at end of file +}