Backend logs UI (#2635)

* backend integrations ui start

* some more ui things

* moving around some integration code

* add dynatrace

* add datadog, useform hook and other things to update backend logging integration forms

* change api queries

* backend logging modals

* tracker: fix some types

* remove deprecated integrations, update types

* some ui fixes and improvements

* update notifications on success/error

* ui: debugging log output, autoclose fix

* ui: some stuff for logs base64str

* ui: improve log formatting,  change datadog data format

* some improvs for logging irm

* ui: fixup for sentry
This commit is contained in:
Delirium 2024-10-29 15:15:28 +01:00 committed by GitHub
parent 1d96ec02fc
commit c144add4bd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
73 changed files with 1585 additions and 792 deletions

View file

@ -106,7 +106,7 @@ const SPOT_PATH = routes.spot();
const SCOPE_SETUP = routes.scopeSetup();
function PrivateRoutes() {
const { projectsStore, userStore } = useStore();
const { projectsStore, userStore, integrationsStore } = useStore();
const onboarding = userStore.onboarding;
const scope = userStore.scopeState;
const tenantId = userStore.account.tenantId;
@ -118,6 +118,11 @@ function PrivateRoutes() {
!onboarding && (localStorage.getItem(GLOBAL_HAS_NO_RECORDINGS) === 'true' || (sites.length > 0 && !hasRecordings)) && scope > 0;
const siteIdList: any = sites.map(({ id }) => id);
React.useEffect(() => {
if (integrationsStore.integrations.list.length === 0 && siteId) {
void integrationsStore.integrations.fetchIntegrations(siteId);
}
}, [siteId])
return (
<Suspense fallback={<Loader loading={true} className="flex-1" />}>
<Switch key="content">

View file

@ -166,7 +166,7 @@ export default class APIClient {
let fetch = window.fetch;
let edp = window.env.API_EDP || window.location.origin + '/api';
const noChalice = path.includes('/spot') && !path.includes('/login')
const noChalice = path.includes('v1/integrations') || path.includes('/spot') && !path.includes('/login')
if (noChalice && !edp.includes('api.openreplay.com')) {
edp = edp.replace('/api', '')
}

View file

@ -0,0 +1,145 @@
import { Button } from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
import FormField from 'App/components/Client/Integrations/FormField';
import { useIntegration } from 'App/components/Client/Integrations/apiMethods';
import useForm from 'App/hooks/useForm';
import { useStore } from 'App/mstore';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import { Loader } from 'UI';
import DocLink from 'Shared/DocLink/DocLink';
import { toast } from ".store/react-toastify-virtual-9dd0f3eae1/package";
interface DatadogConfig {
site: string;
api_key: string;
app_key: string;
}
const initialValues = {
site: '',
api_key: '',
app_key: '',
};
const DatadogFormModal = ({
onClose,
integrated,
}: {
onClose: () => void;
integrated: boolean;
}) => {
const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId;
const {
data = initialValues,
isPending,
saveMutation,
removeMutation,
} = useIntegration<DatadogConfig>('datadog', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, {
site: {
required: true,
},
api_key: {
required: true,
},
app_key: {
required: true,
},
});
const exists = Boolean(data.api_key);
const save = async () => {
if (checkErrors()) {
return;
}
try {
await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) {
console.error(e)
}
onClose();
};
const remove = async () => {
try {
await removeMutation.mutateAsync({ siteId });
} catch (e) {
console.error(e)
}
onClose();
};
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title="Datadog"
icon="integrations/datadog"
description="Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside">
<li>Generate Datadog API Key & Application Key</li>
<li>Enter the API key below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink
className="mt-4"
label="Integrate Datadog"
url="https://docs.openreplay.com/integrations/datadog"
/>
<Loader loading={isPending}>
<FormField
label="Site"
name="site"
value={values.site}
onChange={handleChange}
autoFocus
errors={errors.site}
/>
<FormField
label="API Key"
name="api_key"
value={values.api_key}
onChange={handleChange}
errors={errors.api_key}
/>
<FormField
label="Application Key"
name="app_key"
value={values.app_key}
onChange={handleChange}
errors={errors.app_key}
/>
<div className={'flex items-center gap-2'}>
<Button
onClick={save}
disabled={hasErrors}
loading={saveMutation.isPending}
type="primary"
>
{exists ? 'Update' : 'Add'}
</Button>
{integrated && (
<Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'}
</Button>
)}
</div>
</Loader>
</div>
</div>
);
};
DatadogFormModal.displayName = 'DatadogForm';
export default observer(DatadogFormModal);

View file

@ -0,0 +1,164 @@
import { Button } from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
import FormField from 'App/components/Client/Integrations/FormField';
import { useIntegration } from 'App/components/Client/Integrations/apiMethods';
import useForm from 'App/hooks/useForm';
import { useStore } from 'App/mstore';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import { Loader } from 'UI';
import DocLink from 'Shared/DocLink/DocLink';
import { toast } from ".store/react-toastify-virtual-9dd0f3eae1/package";
interface DynatraceConfig {
environment: string;
client_id: string;
client_secret: string;
resource: string;
}
const initialValues = {
environment: '',
client_id: '',
client_secret: '',
resource: '',
};
const DynatraceFormModal = ({
onClose,
integrated,
}: {
onClose: () => void;
integrated: boolean;
}) => {
const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId;
const {
data = initialValues,
isPending,
saveMutation,
removeMutation,
} = useIntegration<DynatraceConfig>('dynatrace', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, {
environment: {
required: true,
},
client_id: {
required: true,
},
client_secret: {
required: true,
},
resource: {
required: true,
},
});
const exists = Boolean(data.client_id);
const save = async () => {
if (checkErrors()) {
return;
}
try {
await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) {
console.error(e)
}
onClose();
};
const remove = async () => {
try {
await removeMutation.mutateAsync({ siteId });
} catch (e) {
console.error(e)
}
onClose();
};
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title="Dynatrace"
icon="integrations/dynatrace"
useIcon
description="Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside">
<li>
Enter your Environment ID, Client ID, Client Secret, and Account URN
in the form below.
</li>
<li>
Create a custom Log attribute openReplaySessionToken in Dynatrace.
</li>
<li>
Propagate openReplaySessionToken in your application's backend logs.
</li>
</ol>
<DocLink
className="mt-4"
label="See detailed steps"
url="https://docs.openreplay.com/integrations/dynatrace"
/>
<Loader loading={isPending}>
<FormField
label="Environment ID"
name="environment"
value={values.environment}
onChange={handleChange}
errors={errors.environment}
autoFocus
/>
<FormField
label="Client ID"
name="client_id"
value={values.client_id}
onChange={handleChange}
errors={errors.client_id}
/>
<FormField
label="Client Secret"
name="client_secret"
value={values.client_secret}
onChange={handleChange}
errors={errors.client_secret}
/>
<FormField
label="Dynatrace Account URN"
name="resource"
value={values.resource}
onChange={handleChange}
errors={errors.resource}
/>
<div className={'flex items-center gap-2'}>
<Button
onClick={save}
disabled={hasErrors}
loading={saveMutation.isPending}
type="primary"
>
{exists ? 'Update' : 'Add'}
</Button>
{integrated && (
<Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'}
</Button>
)}
</div>
</Loader>
</div>
</div>
);
};
DynatraceFormModal.displayName = 'DynatraceFormModal';
export default observer(DynatraceFormModal);

View file

@ -0,0 +1,152 @@
import { Button } from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
import FormField from 'App/components/Client/Integrations/FormField';
import { useIntegration } from 'App/components/Client/Integrations/apiMethods';
import useForm from 'App/hooks/useForm';
import { useStore } from 'App/mstore';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import { Loader } from 'UI';
import DocLink from 'Shared/DocLink/DocLink';
import { toast } from ".store/react-toastify-virtual-9dd0f3eae1/package";
interface ElasticConfig {
url: string;
api_key_id: string;
api_key: string;
indexes: string;
}
const initialValues = {
url: '',
api_key_id: '',
api_key: '',
indexes: '',
};
function ElasticsearchForm({
onClose,
integrated,
}: {
onClose: () => void;
integrated: boolean;
}) {
const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId;
const {
data = initialValues,
isPending,
saveMutation,
removeMutation,
} = useIntegration<ElasticConfig>('elasticsearch', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors } = useForm(data, {
url: {
required: true,
},
api_key_id: {
required: true,
},
api_key: {
required: true,
},
});
const exists = Boolean(data.api_key_id);
const save = async () => {
if (checkErrors()) {
return;
}
try {
await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) {
console.error(e)
}
onClose();
};
const remove = async () => {
try {
await removeMutation.mutateAsync({ siteId });
} catch (e) {
console.error(e)
}
onClose();
};
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title="Elasticsearch"
icon="integrations/elasticsearch"
description="Integrate Elasticsearch with session replays to seamlessly observe backend errors."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside">
<li>Create a new Elastic API key</li>
<li>Enter the API key below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink
className="mt-4"
label="Integrate Elasticsearch"
url="https://docs.openreplay.com/integrations/elastic"
/>
<Loader loading={isPending}>
<FormField
label="URL"
name="url"
value={values.url}
onChange={handleChange}
errors={errors.url}
autoFocus
/>
<FormField
label="API Key ID"
name="api_key_id"
value={values.api_key_id}
onChange={handleChange}
errors={errors.api_key_id}
/>
<FormField
label="API Key"
name="api_key"
value={values.api_key}
onChange={handleChange}
errors={errors.api_key}
/>
<FormField
label="Indexes"
name="indexes"
value={values.indexes}
onChange={handleChange}
errors={errors.indexes}
/>
<div className={'flex items-center gap-2'}>
<Button
onClick={save}
disabled={hasErrors}
loading={saveMutation.isPending}
type="primary"
>
{exists ? 'Update' : 'Add'}
</Button>
{integrated && (
<Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'}
</Button>
)}
</div>
</Loader>
</div>
</div>
);
}
export default observer(ElasticsearchForm);

View file

@ -0,0 +1,143 @@
import { Button } from 'antd';
import { observer } from 'mobx-react-lite';
import React from 'react';
import FormField from 'App/components/Client/Integrations/FormField';
import { useIntegration } from 'App/components/Client/Integrations/apiMethods';
import useForm from 'App/hooks/useForm';
import { useStore } from 'App/mstore';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import { Loader } from 'UI';
import { toast } from 'react-toastify';
import DocLink from 'Shared/DocLink/DocLink';
interface SentryConfig {
organization_slug: string;
project_slug: string;
token: string;
}
const initialValues = {
organization_slug: '',
project_slug: '',
token: '',
};
function SentryForm({
onClose,
integrated,
}: {
onClose: () => void;
integrated: boolean;
}) {
const { integrationsStore } = useStore();
const siteId = integrationsStore.integrations.siteId;
const {
data = initialValues,
isPending,
saveMutation,
removeMutation,
} = useIntegration<SentryConfig>('sentry', siteId, initialValues);
const { values, errors, handleChange, hasErrors, checkErrors, } = useForm(data, {
organization_slug: {
required: true,
},
project_slug: {
required: true,
},
token: {
required: true,
},
});
const exists = Boolean(data.token);
const save = async () => {
if (checkErrors()) {
return;
}
try {
await saveMutation.mutateAsync({ values, siteId, exists });
} catch (e) {
console.error(e)
}
onClose();
};
const remove = async () => {
try {
await removeMutation.mutateAsync({ siteId });
} catch (e) {
console.error(e)
}
onClose();
};
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title="Sentry"
icon="integrations/sentry"
description="Integrate Sentry with session replays to seamlessly observe backend errors."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside">
<li>Generate Sentry Auth Token</li>
<li>Enter the token below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink
className="mt-4"
label="See detailed steps"
url="https://docs.openreplay.com/integrations/sentry"
/>
<Loader loading={isPending}>
<FormField
label="Organization Slug"
name="organization_slug"
value={values.organization_slug}
onChange={handleChange}
errors={errors.url}
autoFocus
/>
<FormField
label="Project Slug"
name="project_slug"
value={values.project_slug}
onChange={handleChange}
errors={errors.project_slug}
/>
<FormField
label="Token"
name="token"
value={values.token}
onChange={handleChange}
errors={errors.token}
/>
<div className={'flex items-center gap-2'}>
<Button
onClick={save}
disabled={hasErrors}
loading={saveMutation.isPending}
type="primary"
>
{exists ? 'Update' : 'Add'}
</Button>
{integrated && (
<Button loading={removeMutation.isPending} onClick={remove}>
{'Delete'}
</Button>
)}
</div>
</Loader>
</div>
</div>
);
}
export default observer(SentryForm);

View file

@ -1,43 +0,0 @@
import React from 'react';
import { tokenRE } from 'Types/integrations/bugsnagConfig';
import IntegrationForm from '../IntegrationForm';
// import ProjectListDropdown from './ProjectListDropdown';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const BugsnagForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Bugsnag' icon='integrations/bugsnag'
description='Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Generate Bugsnag Auth Token</li>
<li>Enter the token below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='Integrate Bugsnag' url='https://docs.openreplay.com/integrations/bugsnag' />
</div>
<IntegrationForm
{...props}
name='bugsnag'
formFields={[
{
key: 'authorizationToken',
label: 'Authorisation Token'
},
{
key: 'bugsnagProjectId',
label: 'Project',
checkIfDisplayed: (config) => tokenRE.test(config.authorizationToken),
// component: ProjectListDropdown
}
]}
/>
</div>
);
BugsnagForm.displayName = 'BugsnagForm';
export default BugsnagForm;

View file

@ -1,74 +0,0 @@
import React from 'react';
import { connect } from 'react-redux';
import { tokenRE } from 'Types/integrations/bugsnagConfig';
import Select from 'Shared/Select';
import { withRequest } from 'HOCs';
function ProjectListDropdown(props) {
}
@connect(state => ({
token: state.getIn([ 'bugsnag', 'instance', 'authorizationToken' ])
}))
@withRequest({
dataName: "projects",
initialData: [],
dataWrapper: (data = []) => {
if (!Array.isArray(data)) throw new Error('Wrong responce format.');
const withOrgName = data.length > 1;
return data.reduce((accum, { name: orgName, projects }) => {
if (!Array.isArray(projects)) throw new Error('Wrong responce format.');
if (withOrgName) projects = projects.map(p => ({ ...p, name: `${ p.name } (${ orgName })` }))
return accum.concat(projects);
}, []);
},
resetBeforeRequest: true,
requestName: "fetchProjectList",
endpoint: '/integrations/bugsnag/list_projects',
method: 'POST',
})
export default class ProjectListDropdown extends React.PureComponent {
constructor(props) {
super(props);
this.fetchProjectList()
}
fetchProjectList() {
const { token } = this.props;
if (!tokenRE.test(token)) return;
this.props.fetchProjectList({
authorizationToken: token,
})
}
componentDidUpdate(prevProps) {
if (prevProps.token !== this.props.token) {
this.fetchProjectList();
}
}
onChange = (target) => {
if (typeof this.props.onChange === 'function') {
this.props.onChange({ target });
}
}
render() {
const {
projects,
name,
value,
placeholder,
loading,
} = this.props;
const options = projects.map(({ name, id }) => ({ text: name, value: id }));
return (
<Select
// selection
options={ options }
name={ name }
value={ options.find(o => o.value === value) }
placeholder={ placeholder }
onChange={ this.onChange }
loading={ loading }
/>
);
}
}

View file

@ -1 +0,0 @@
export { default } from './BugsnagForm';

View file

@ -1,68 +0,0 @@
import {
ACCESS_KEY_ID_LENGTH,
SECRET_ACCESS_KEY_LENGTH,
} from 'Types/integrations/cloudwatchConfig';
import React from 'react';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationForm from '../IntegrationForm';
import LogGroupDropdown from './LogGroupDropdown';
import RegionDropdown from './RegionDropdown';
const CloudwatchForm = (props) => (
<div className="bg-white h-screen overflow-y-auto" style={{ width: '350px' }}>
<IntegrationModalCard
title="Cloud Watch"
icon="integrations/aws"
description="Integrate CloudWatch to see backend logs and errors alongside session replay."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside">
<li>Create a Service Account</li>
<li>Enter the details below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink
className="mt-4"
label="Integrate CloudWatch"
url="https://docs.openreplay.com/integrations/cloudwatch"
/>
</div>
<IntegrationForm
{...props}
name="cloudwatch"
formFields={[
{
key: 'awsAccessKeyId',
label: 'AWS Access Key ID',
},
{
key: 'awsSecretAccessKey',
label: 'AWS Secret Access Key',
},
{
key: 'region',
label: 'Region',
component: RegionDropdown,
},
{
key: 'logGroupName',
label: 'Log Group Name',
component: LogGroupDropdown,
checkIfDisplayed: (config) =>
config.awsSecretAccessKey.length === SECRET_ACCESS_KEY_LENGTH &&
config.region !== '' &&
config.awsAccessKeyId.length === ACCESS_KEY_ID_LENGTH,
},
]}
/>
</div>
);
CloudwatchForm.displayName = 'CloudwatchForm';
export default CloudwatchForm;

View file

@ -1,93 +0,0 @@
import React, { useState, useEffect, useCallback } from 'react';
import { ACCESS_KEY_ID_LENGTH, SECRET_ACCESS_KEY_LENGTH } from 'Types/integrations/cloudwatchConfig';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import Select from 'Shared/Select';
import { integrationsService } from "App/services";
const LogGroupDropdown = (props) => {
const { integrationsStore } = useStore();
const config = integrationsStore.cloudwatch.instance;
const edit = integrationsStore.cloudwatch.edit;
const {
value,
name,
placeholder,
onChange,
} = props;
const [values, setValues] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
const { region, awsSecretAccessKey, awsAccessKeyId } = config;
const fetchLogGroups = useCallback(() => {
if (
region === '' ||
awsSecretAccessKey.length !== SECRET_ACCESS_KEY_LENGTH ||
awsAccessKeyId.length !== ACCESS_KEY_ID_LENGTH
) {
return;
}
setLoading(true);
setError(false);
setValues([]); // Reset values before request
const params = {
region: region,
awsSecretAccessKey: awsSecretAccessKey,
awsAccessKeyId: awsAccessKeyId,
};
integrationsService.client
.post('/integrations/cloudwatch/list_groups', params)
.then((response) => response.json())
.then(({ errors, data }) => {
if (errors) {
setError(true);
setLoading(false);
return;
}
setValues(data);
setLoading(false);
// If current value is not in the new values list, update it
if (!data.includes(value) && data.length > 0) {
edit({
[name]: data[0],
});
}
})
.catch(() => {
setError(true);
setLoading(false);
});
}, [region, awsSecretAccessKey, awsAccessKeyId, value, name, edit]);
// Fetch log groups on mount and when config changes
useEffect(() => {
fetchLogGroups();
}, [fetchLogGroups]);
const handleChange = (target) => {
if (typeof onChange === 'function') {
onChange({ target });
}
};
const options = values.map((g) => ({ text: g, value: g }));
return (
<Select
options={options}
name={name}
value={options.find((o) => o.value === value)}
placeholder={placeholder}
onChange={handleChange}
loading={loading}
/>
);
};
export default observer(LogGroupDropdown);

View file

@ -1,20 +0,0 @@
import React from 'react';
import { regionLabels as labels } from 'Types/integrations/cloudwatchConfig';
import Select from 'Shared/Select';
const options = Object.keys(labels).map(key => ({ text: labels[ key ], label: key }));
const RegionDropdown = props => (
<Select
{ ...props }
onChange={({ value }) => props.onChange({value})}
selection
value={ options.find(option => option.value === props.value) }
options={ options }
/>
);
RegionDropdown.displayName = "RegionDropdown";
export default RegionDropdown;

View file

@ -1 +0,0 @@
export { default } from './CloudwatchForm';

View file

@ -1,39 +0,0 @@
import React from 'react';
import IntegrationForm from './IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const DatadogForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Datadog' icon='integrations/datadog'
description='Incorporate DataDog to visualize backend errors alongside session replay, for easy troubleshooting.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Generate Datadog API Key & Application Key</li>
<li>Enter the API key below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='Integrate Datadog' url='https://docs.openreplay.com/integrations/datadog' />
</div>
<IntegrationForm
{...props}
name='datadog'
formFields={[
{
key: 'apiKey',
label: 'API Key',
autoFocus: true
},
{
key: 'applicationKey',
label: 'Application Key'
}
]}
/>
</div>
);
DatadogForm.displayName = 'DatadogForm';
export default DatadogForm;

View file

@ -1,64 +0,0 @@
import React from 'react';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationForm from './IntegrationForm';
const ElasticsearchForm = (props) => {
return (
<div
className="bg-white h-screen overflow-y-auto"
style={{ width: '350px' }}
>
<IntegrationModalCard
title="Elasticsearch"
icon="integrations/elasticsearch"
description="Integrate Elasticsearch with session replays to seamlessly observe backend errors."
/>
<div className="p-5 border-b mb-4">
<div className="font-medium mb-1">How it works?</div>
<ol className="list-decimal list-inside">
<li>Create a new Elastic API key</li>
<li>Enter the API key below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink
className="mt-4"
label="Integrate Elasticsearch"
url="https://docs.openreplay.com/integrations/elastic"
/>
</div>
<IntegrationForm
{...props}
name="elasticsearch"
formFields={[
{
key: 'host',
label: 'Host',
},
{
key: 'apiKeyId',
label: 'API Key ID',
},
{
key: 'apiKey',
label: 'API Key',
},
{
key: 'indexes',
label: 'Indexes',
},
{
key: 'port',
label: 'Port',
type: 'number',
},
]}
/>
</div>
);
};
export default ElasticsearchForm;

View file

@ -0,0 +1,33 @@
import React from "react";
import { Input } from 'antd'
export function FormField({
label,
name,
value,
onChange,
autoFocus,
errors,
}: {
label: string;
name: string
value: string;
onChange: (e: React.ChangeEvent<HTMLInputElement>) => void;
autoFocus?: boolean;
errors?: string;
}) {
return (
<div className="mb-4">
<label className="block text-sm font-medium text-gray-700">{label}</label>
<Input
type="text"
name={name}
value={value}
onChange={onChange}
autoFocus={autoFocus}
/>
{errors && <div className="text-red-500 text-xs mt-1">{errors}</div>}
</div>
);
}
export default FormField;

View file

@ -8,8 +8,8 @@ import { toast } from 'react-toastify';
function IntegrationForm(props: any) {
const { formFields, name, integrated } = props;
const { integrationsStore, projectsStore } = useStore();
const initialSiteId = projectsStore.siteId;
const { integrationsStore } = useStore();
const initialSiteId = integrationsStore.integrations.siteId;
const integrationStore = integrationsStore[name as unknown as namedStore];
const config = integrationStore.instance;
const loading = integrationStore.loading;

View file

@ -9,11 +9,12 @@ interface Props {
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void;
integrated?: boolean;
hide?: boolean;
useIcon?: boolean;
}
const IntegrationItem = (props: Props) => {
const { integration, integrated, hide = false } = props;
return hide ? <></> : (
const { integration, integrated, hide = false, useIcon } = props;
return hide ? null : (
<div
className={cn('flex flex-col border rounded-lg p-3 bg-white relative justify-between cursor-pointer hover:bg-active-blue')}
onClick={(e) => props.onClick(e)}
@ -21,7 +22,7 @@ const IntegrationItem = (props: Props) => {
>
<div className='flex gap-3'>
<div className="shrink-0">
<img className='h-10 w-10' src={'/assets/' + integration.icon + '.svg'} alt='integration' />
{useIcon ? <Icon name={integration.icon} size={40} /> : <img className="h-10 w-10" src={"/assets/" + integration.icon + ".svg"} alt="integration" />}
</div>
<div className='flex flex-col'>
<h4 className='text-lg'>{integration.title}</h4>

View file

@ -1,19 +1,19 @@
import React from 'react';
import { Icon } from 'UI';
import DocLink from 'Shared/DocLink';
interface Props {
title: string;
icon: string;
description: string;
useIcon?: boolean;
}
function IntegrationModalCard(props: Props) {
const { title, icon, description } = props;
const { title, icon, description, useIcon } = props;
return (
<div className='flex items-start p-5 gap-4'>
<div className='border rounded-lg p-2 shrink-0'>
<img className='h-20 w-20' src={'/assets/' + icon + '.svg'} alt='integration' />
{useIcon ? <Icon name={icon} size={80} /> : <img className="h-20 w-20" src={"/assets/" + icon + ".svg"} alt="integration" />}
</div>
<div>
<h3 className='text-2xl'>{title}</h3>

View file

@ -1,5 +1,4 @@
import withPageTitle from 'HOCs/withPageTitle';
import cn from 'classnames';
import { observer } from 'mobx-react-lite';
import React, { useEffect, useState } from 'react';
@ -9,30 +8,26 @@ import IntegrationFilters from 'Components/Client/Integrations/IntegrationFilter
import { PageTitle } from 'UI';
import DocCard from 'Shared/DocCard/DocCard';
import SiteDropdown from 'Shared/SiteDropdown';
import AssistDoc from './AssistDoc';
import BugsnagForm from './BugsnagForm';
import CloudwatchForm from './CloudwatchForm';
import DatadogForm from './DatadogForm';
import ElasticsearchForm from './ElasticsearchForm';
import DatadogForm from './Backend/DatadogForm/DatadogFormModal';
import DynatraceFormModal from './Backend/DynatraceForm/DynatraceFormModal';
import ElasticsearchForm from './Backend/ElasticForm/ElasticFormModal';
import SentryForm from './Backend/SentryForm/SentryFormModal';
import GithubForm from './GithubForm';
import GraphQLDoc from './GraphQLDoc';
import IntegrationItem from './IntegrationItem';
import JiraForm from './JiraForm';
import MobxDoc from './MobxDoc';
import NewrelicForm from './NewrelicForm';
import NgRxDoc from './NgRxDoc';
import PiniaDoc from './PiniaDoc';
import ProfilerDoc from './ProfilerDoc';
import ReduxDoc from './ReduxDoc';
import RollbarForm from './RollbarForm';
import SentryForm from './SentryForm';
import SlackForm from './SlackForm';
import StackdriverForm from './StackdriverForm';
import SumoLogicForm from './SumoLogicForm';
import MSTeams from './Teams';
import VueDoc from './VueDoc';
import ZustandDoc from './ZustandDoc';
import AssistDoc from './Tracker/AssistDoc';
import GraphQLDoc from './Tracker/GraphQLDoc';
import MobxDoc from './Tracker/MobxDoc';
import NgRxDoc from './Tracker/NgRxDoc';
import PiniaDoc from './Tracker/PiniaDoc';
import ReduxDoc from './Tracker/ReduxDoc';
import VueDoc from './Tracker/VueDoc';
import ZustandDoc from './Tracker/ZustandDoc';
interface Props {
siteId: string;
@ -41,23 +36,27 @@ interface Props {
function Integrations(props: Props) {
const { integrationsStore, projectsStore } = useStore();
const siteId = projectsStore.siteId;
const initialSiteId = projectsStore.siteId;
const siteId = integrationsStore.integrations.siteId;
const fetchIntegrationList = integrationsStore.integrations.fetchIntegrations;
const storeIntegratedList = integrationsStore.integrations.list;
const { hideHeader = false } = props;
const { showModal } = useModal();
const { showModal, hideModal } = useModal();
const [integratedList, setIntegratedList] = useState<string[]>([]);
const [activeFilter, setActiveFilter] = useState<string>('all');
useEffect(() => {
const list = storeIntegratedList
.filter((item: any) => item.integrated)
const list = integrationsStore.integrations.integratedServices
.map((item: any) => item.name);
setIntegratedList(list);
}, [storeIntegratedList]);
useEffect(() => {
void fetchIntegrationList(siteId);
if (siteId) {
void fetchIntegrationList(siteId);
} else if (initialSiteId) {
integrationsStore.integrations.setSiteId(initialSiteId);
}
}, [siteId]);
const onClick = (integration: any, width: number) => {
@ -84,6 +83,8 @@ function Integrations(props: Props) {
showModal(
React.cloneElement(integration.component, {
integrated: integratedList.includes(integration.slug),
siteId,
onClose: hideModal,
}),
{ right: true, width }
);
@ -112,15 +113,17 @@ function Integrations(props: Props) {
(cat) => cat.integrations
);
console.log(
allIntegrations,
integratedList
)
const onChangeSelect = ({ value }: any) => {
integrationsStore.integrations.setSiteId(value.value);
};
return (
<>
<div className="bg-white rounded-lg border shadow-sm p-5 mb-4">
{!hideHeader && <PageTitle title={<div>Integrations</div>} />}
<div className={'flex items-center gap-4 mb-2'}>
{!hideHeader && <PageTitle title={<div>Integrations</div>} />}
<SiteDropdown value={siteId} onChange={onChangeSelect} />
</div>
<IntegrationFilters
onChange={onChange}
activeItem={activeFilter}
@ -131,37 +134,41 @@ function Integrations(props: Props) {
<div className="mb-4" />
<div
className={cn(`
mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3
`)}
className={'mt-4 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3'}
>
{allIntegrations.map((integration: any) => (
<IntegrationItem
integrated={integratedList.includes(integration.slug)}
integration={integration}
onClick={() =>
onClick(
integration,
filteredIntegrations.find((cat) =>
cat.integrations.includes(integration)
).title === 'Plugins'
? 500
: 350
)
}
hide={
(integration.slug === 'github' &&
integratedList.includes('jira')) ||
(integration.slug === 'jira' && integratedList.includes('github'))
}
/>
{allIntegrations.map((integration, i) => (
<React.Fragment key={`${integration.slug}+${i}`}>
<IntegrationItem
integrated={integratedList.includes(integration.slug)}
integration={integration}
useIcon={integration.useIcon}
onClick={() =>
onClick(
integration,
filteredIntegrations.find((cat) =>
cat.integrations.includes(integration)
)?.title === 'Plugins'
? 500
: 350
)
}
hide={
(integration.slug === 'github' &&
integratedList.includes('jira')) ||
(integration.slug === 'jira' &&
integratedList.includes('github'))
}
/>
</React.Fragment>
))}
</div>
</>
);
}
export default withPageTitle('Integrations - OpenReplay Preferences')(observer(Integrations))
export default withPageTitle('Integrations - OpenReplay Preferences')(
observer(Integrations)
);
const integrations = [
{
@ -219,22 +226,6 @@ const integrations = [
icon: 'integrations/sentry',
component: <SentryForm />,
},
{
title: 'Bugsnag',
subtitle:
'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.',
slug: 'bugsnag',
icon: 'integrations/bugsnag',
component: <BugsnagForm />,
},
{
title: 'Rollbar',
subtitle:
'Integrate Rollbar with session replays to seamlessly observe backend errors.',
slug: 'rollbar',
icon: 'integrations/rollbar',
component: <RollbarForm />,
},
{
title: 'Elasticsearch',
subtitle:
@ -252,36 +243,13 @@ const integrations = [
component: <DatadogForm />,
},
{
title: 'Sumo Logic',
title: 'Dynatrace',
subtitle:
'Integrate Sumo Logic with session replays to seamlessly observe backend errors.',
slug: 'sumologic',
icon: 'integrations/sumologic',
component: <SumoLogicForm />,
},
{
title: 'Google Cloud',
subtitle:
'Integrate Google Cloud to view backend logs and errors in conjunction with session replay',
slug: 'stackdriver',
icon: 'integrations/google-cloud',
component: <StackdriverForm />,
},
{
title: 'CloudWatch',
subtitle:
'Integrate CloudWatch to see backend logs and errors alongside session replay.',
slug: 'cloudwatch',
icon: 'integrations/aws',
component: <CloudwatchForm />,
},
{
title: 'Newrelic',
subtitle:
'Integrate NewRelic with session replays to seamlessly observe backend errors.',
slug: 'newrelic',
icon: 'integrations/newrelic',
component: <NewrelicForm />,
'Integrate Dynatrace with session replays to link backend logs with user sessions for faster issue resolution.',
slug: 'dynatrace',
icon: 'integrations/dynatrace',
useIcon: true,
component: <DynatraceFormModal />,
},
],
},
@ -409,3 +377,56 @@ const integrations = [
],
},
];
/**
*
* @deprecated
* */
// {
// title: 'Sumo Logic',
// subtitle:
// 'Integrate Sumo Logic with session replays to seamlessly observe backend errors.',
// slug: 'sumologic',
// icon: 'integrations/sumologic',
// component: <SumoLogicForm />,
// },
// {
// title: 'Bugsnag',
// subtitle:
// 'Integrate Bugsnag to access the OpenReplay session linked to the JS exception within its interface.',
// slug: 'bugsnag',
// icon: 'integrations/bugsnag',
// component: <BugsnagForm />,
// },
// {
// title: 'Rollbar',
// subtitle:
// 'Integrate Rollbar with session replays to seamlessly observe backend errors.',
// slug: 'rollbar',
// icon: 'integrations/rollbar',
// component: <RollbarForm />,
// },
// {
// title: 'Google Cloud',
// subtitle:
// 'Integrate Google Cloud to view backend logs and errors in conjunction with session replay',
// slug: 'stackdriver',
// icon: 'integrations/google-cloud',
// component: <StackdriverForm />,
// },
// {
// title: 'CloudWatch',
// subtitle:
// 'Integrate CloudWatch to see backend logs and errors alongside session replay.',
// slug: 'cloudwatch',
// icon: 'integrations/aws',
// component: <CloudwatchForm />,
// },
// {
// title: 'Newrelic',
// subtitle:
// 'Integrate NewRelic with session replays to seamlessly observe backend errors.',
// slug: 'newrelic',
// icon: 'integrations/newrelic',
// component: <NewrelicForm />,
// },

View file

@ -2,7 +2,6 @@ import React from 'react';
import IntegrationForm from '../IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import { useModal } from 'App/components/Modal';
import { Icon } from 'UI';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const JiraForm = (props) => {

View file

@ -1,43 +0,0 @@
import React from 'react';
import IntegrationForm from '../IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const NewrelicForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='New Relic' icon='integrations/newrelic'
description='Integrate NewRelic with session replays to seamlessly observe backend errors.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Create Query Key</li>
<li>Enter the details below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='Integrate NewRelic' url='https://docs.openreplay.com/integrations/newrelic' />
</div>
<IntegrationForm
{...props}
name='newrelic'
formFields={[
{
key: 'applicationId',
label: 'Application Id'
},
{
key: 'xQueryKey',
label: 'X-Query-Key'
},
{
key: 'region',
label: 'EU Region',
type: 'checkbox'
}
]}
/>
</div>
);
NewrelicForm.displayName = 'NewrelicForm';
export default NewrelicForm;

View file

@ -1 +0,0 @@
export { default } from './NewrelicForm';

View file

@ -1,34 +0,0 @@
import React from 'react';
import IntegrationForm from './IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const RollbarForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Rollbar' icon='integrations/rollbar'
description='Integrate Rollbar with session replays to seamlessly observe backend errors.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Create Rollbar Access Tokens</li>
<li>Enter the token below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='Integrate Rollbar' url='https://docs.openreplay.com/integrations/rollbar' />
</div>
<IntegrationForm
{...props}
name='rollbar'
formFields={[
{
key: 'accessToken',
label: 'Access Token'
}
]}
/>
</div>
);
RollbarForm.displayName = 'RollbarForm';
export default RollbarForm;

View file

@ -1,42 +0,0 @@
import React from 'react';
import IntegrationForm from './IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const SentryForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Sentry' icon='integrations/sentry'
description='Integrate Sentry with session replays to seamlessly observe backend errors.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Generate Sentry Auth Token</li>
<li>Enter the token below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='See detailed steps' url='https://docs.openreplay.com/integrations/sentry' />
</div>
<IntegrationForm
{...props}
name='sentry'
formFields={[
{
key: 'organizationSlug',
label: 'Organization Slug'
},
{
key: 'projectSlug',
label: 'Project Slug'
},
{
key: 'token',
label: 'Token'
}
]}
/>
</div>
);
SentryForm.displayName = 'SentryForm';
export default SentryForm;

View file

@ -1,40 +0,0 @@
import React from 'react';
import IntegrationForm from './IntegrationForm';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const StackdriverForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Google Cloud' icon='integrations/google-cloud'
description='Integrate Google Cloud Watch to see backend logs and errors alongside session replay.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Create Google Cloud Service Account</li>
<li>Enter the details below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='Integrate Stackdriver'
url='https://docs.openreplay.com/integrations/stackdriver' />
</div>
<IntegrationForm
{...props}
name='stackdriver'
formFields={[
{
key: 'logName',
label: 'Log Name'
},
{
key: 'serviceAccountCredentials',
label: 'Service Account Credentials (JSON)',
component: 'textarea'
}
]}
/>
</div>
);
StackdriverForm.displayName = 'StackdriverForm';
export default StackdriverForm;

View file

@ -1,19 +0,0 @@
import React from 'react';
import { regionLabels as labels } from 'Types/integrations/sumoLogicConfig';
import Select from 'Shared/Select';
const options = Object.keys(labels).map(key => ({ text: labels[ key ], label: key }));
const RegionDropdown = props => (
<Select
{ ...props }
onChange={(e) => props.onChange(e)}
selection
options={ options.find(option => option.value === props.value) }
/>
);
RegionDropdown.displayName = "RegionDropdown";
export default RegionDropdown;

View file

@ -1,44 +0,0 @@
import React from 'react';
import IntegrationForm from '../IntegrationForm';
import RegionDropdown from './RegionDropdown';
import DocLink from 'Shared/DocLink/DocLink';
import IntegrationModalCard from 'Components/Client/Integrations/IntegrationModalCard';
const SumoLogicForm = (props) => (
<div className='bg-white h-screen overflow-y-auto' style={{ width: '350px' }}>
<IntegrationModalCard title='Sumologic' icon='integrations/sumologic'
description='Integrate Sumo Logic with session replays to seamlessly observe backend errors.' />
<div className='p-5 border-b mb-4'>
<div className='font-medium mb-1'>How it works?</div>
<ol className="list-decimal list-inside">
<li>Create a new Access ID and Access Key</li>
<li>Enter the details below</li>
<li>Propagate openReplaySessionToken</li>
</ol>
<DocLink className='mt-4' label='Integrate SumoLogic' url='https://docs.openreplay.com/integrations/sumo' />
</div>
<IntegrationForm
{...props}
name='sumologic'
formFields={[
{
key: 'accessId',
label: 'Access ID'
},
{
key: 'accessKey',
label: 'Access Key'
},
{
key: 'region',
label: 'Region',
component: RegionDropdown
}
]}
/>
</div>
);
SumoLogicForm.displayName = 'SumoLogicForm';
export default SumoLogicForm;

View file

@ -1 +0,0 @@
export { default } from './SumoLogicForm';

View file

@ -0,0 +1,85 @@
import { client } from "App/mstore";
import { useQuery, useMutation } from '@tanstack/react-query';
import { toast } from 'react-toastify';
export type ServiceName = 'datadog' | 'dynatrace' | 'elasticsearch' | 'sentry'
export const serviceNames: Record<ServiceName, string> = {
datadog: 'Datadog',
dynatrace: 'Dynatrace',
elasticsearch: 'Elastic',
sentry: 'Sentry',
};
export async function getIntegrationData<T>(name: ServiceName, projectId: string): Promise<T> {
const r = await client.get(
`/integrations/v1/integrations/${name}/${projectId}`
);
return r.json();
}
export function useIntegration<T>(name: ServiceName, projectId: string, initialValues: T) {
const { data, isPending } = useQuery({
queryKey: ['integrationData', name],
queryFn: async () => {
const resp = await getIntegrationData<T>(
name,
projectId
);
if (resp) {
return resp;
}
return initialValues;
},
initialData: initialValues,
});
const saveMutation = useMutation({
mutationFn: ({ values, siteId, exists }: {
values: T;
siteId: string;
exists?: boolean;
}) =>
saveIntegration(name, values, siteId, exists),
});
const removeMutation = useMutation({
mutationFn: ({ siteId }: {
siteId: string;
}) => removeIntegration(name, siteId),
});
return {
data,
isPending,
saveMutation,
removeMutation,
};
}
export async function saveIntegration<T>(
name: string,
data: T,
projectId: string,
exists?: boolean
) {
const method = exists ? 'patch' : 'post';
const r = await client[method](
`/integrations/v1/integrations/${name}/${projectId}`,
{ data }
);
if (r.ok) {
toast.success(`${name} integration saved`);
} else {
toast.error(`Failed to save ${name} integration`);
}
return r.ok;
}
export async function removeIntegration(name: string, projectId: string) {
const r = await client.delete(`/integrations/v1/integrations/${name}/${projectId}`);
if (r.ok) {
toast.success(`${name} integration removed`);
} else {
toast.error(`Failed to remove ${name} integration`);
}
return r.ok;
}

View file

@ -15,6 +15,7 @@ import {
EXCEPTIONS,
INSPECTOR,
OVERVIEW,
BACKENDLOGS
} from 'App/mstore/uiPlayerStore';
import { WebNetworkPanel } from 'Shared/DevTools/NetworkPanel';
import Storage from 'Components/Session_/Storage';
@ -31,6 +32,7 @@ import { PlayerContext } from 'App/components/Session/playerContext';
import { debounce } from 'App/utils';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import BackendLogsPanel from "../SharedComponents/BackendLogs/BackendLogsPanel";
interface IProps {
fullView: boolean;
@ -147,6 +149,7 @@ function Player(props: IProps) {
{bottomBlock === PERFORMANCE && <ConnectedPerformance />}
{bottomBlock === GRAPHQL && <GraphQL panelHeight={panelHeight} />}
{bottomBlock === EXCEPTIONS && <Exceptions />}
{bottomBlock === BACKENDLOGS && <BackendLogsPanel />}
</div>
)}
{!fullView ? (

View file

@ -0,0 +1,161 @@
import { useQuery } from '@tanstack/react-query';
import { Segmented } from 'antd';
import React from 'react';
import { VList, VListHandle } from 'virtua';
import { processLog, UnifiedLog } from './utils';
import { observer } from 'mobx-react-lite';
import { useStore } from 'App/mstore';
import {
ServiceName,
serviceNames,
} from 'App/components/Client/Integrations/apiMethods';
import BottomBlock from 'App/components/shared/DevTools/BottomBlock';
import { capitalize } from 'App/utils';
import { Icon, Input } from 'UI';
import { client } from 'App/mstore';
import { FailedFetch, LoadingFetch } from "./StatusMessages";
import {
TableHeader,
LogRow
} from './Table'
async function fetchLogs(
tab: string,
projectId: string,
sessionId: string
): Promise<UnifiedLog[]> {
const data = await client.get(
`/integrations/v1/integrations/${tab}/${projectId}/data/${sessionId}`
);
const json = await data.json();
try {
const logsResp = await fetch(json.url)
if (logsResp.ok) {
const logJson = await logsResp.json()
return processLog(logJson)
} else {
throw new Error('Failed to fetch logs')
}
} catch (e) {
console.log(e)
throw e
}
}
function BackendLogsPanel() {
const { projectsStore, sessionStore, integrationsStore } = useStore();
const integratedServices =
integrationsStore.integrations.backendLogIntegrations;
const defaultTab = integratedServices[0]!.name;
const sessionId = sessionStore.currentId;
const projectId = projectsStore.siteId!;
const [tab, setTab] = React.useState<ServiceName>(defaultTab as ServiceName);
const { data, isError, isPending, isSuccess, refetch } = useQuery<
UnifiedLog[]
>({
queryKey: ['integrationLogs', tab, sessionId],
staleTime: 1000 * 30,
queryFn: () => fetchLogs(tab!, projectId, sessionId),
enabled: tab !== null,
retry: 3,
});
console.log(isError, isPending, isSuccess)
const [filter, setFilter] = React.useState('');
const _list = React.useRef<VListHandle>(null);
const activeIndex = 1;
React.useEffect(() => {
if (_list.current) {
_list.current.scrollToIndex(activeIndex);
}
}, [activeIndex]);
const onFilterChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setFilter(e.target.value);
};
const tabs = Object.entries(serviceNames)
.filter(
([slug]) => integratedServices.findIndex((i) => i.name === slug) !== -1
)
.map(([slug, name]) => ({
label: (
<div className={'flex items-center gap-2'}>
<Icon size={14} name={`integrations/${slug}`} /> <div>{name}</div>
</div>
),
value: slug,
}));
return (
<BottomBlock style={{ height: '100%' }}>
<BottomBlock.Header>
<div className={'flex gap-2 items-center w-full'}>
<div className={'font-semibold'}>Traces</div>
{tabs.length && tab ? (
<div>
<Segmented options={tabs} value={tab} onChange={setTab} />
</div>
) : null}
<div className={'ml-auto'} />
<Input
className="input-small h-8"
placeholder="Filter by keyword"
icon="search"
name="filter"
height={28}
onChange={onFilterChange}
value={filter}
/>
</div>
</BottomBlock.Header>
<BottomBlock.Content className="overflow-y-auto">
{isPending ? (
<LoadingFetch provider={capitalize(tab)} />
) : null}
{isError ? (
<FailedFetch
provider={capitalize(tab)}
onRetry={refetch}
/>
) : null}
{isSuccess ? (
<>
<TableHeader size={data.length} />
<VList ref={_list} count={testLogs.length}>
{data.map((log, index) => (
<LogRow key={index} log={log} />
))}
</VList>
</>
) : null}
</BottomBlock.Content>
</BottomBlock>
);
}
const testLogs = [
{
key: 1,
timestamp: '2021-09-01 12:00:00',
status: 'INFO',
content: 'This is a test log',
},
{
key: 2,
timestamp: '2021-09-01 12:00:00',
status: 'WARN',
content: 'This is a test log',
},
{
key: 3,
timestamp: '2021-09-01 12:00:00',
status: 'ERROR',
content:
'This is a test log that is very long and should be truncated to fit in the table cell and it will be displayed later in a separate thing when clicked on a row because its so long you never gonna give htem up or alskjhaskfjhqwfhwekfqwfjkqlwhfkjqhflqkwjhefqwklfehqwlkfjhqwlkjfhqwe \n kjhdafskjfhlqkwjhfwelefkhwqlkqehfkqlwehfkqwhefkqhwefkjqwhf',
},
];
export default observer(BackendLogsPanel);

View file

@ -0,0 +1,31 @@
import { Avatar } from 'antd';
import React from 'react';
import ControlButton from 'App/components/Session_/Player/Controls/ControlButton';
import { Icon } from 'UI';
function LogsButton({
integrated,
onClick,
}: {
integrated: string[];
onClick: () => void;
}) {
return (
<ControlButton
label={'Traces'}
customTags={
<Avatar.Group>
{integrated.map((name) => (
<Avatar key={name} size={16} src={<Icon name={`integrations/${name}`} size={14} />} />
))
}
</Avatar.Group>
}
onClick={onClick}
/>
);
}
export default LogsButton;

View file

@ -0,0 +1,48 @@
import React from 'react';
import { client as settingsPath, CLIENT_TABS } from 'App/routes';
import { Icon } from 'UI';
import { LoadingOutlined } from '@ant-design/icons';
import { useHistory } from 'react-router-dom';
import { Button } from 'antd';
export function LoadingFetch({ provider }: { provider: string }) {
return (
<div
className={
'w-full h-full flex items-center justify-center flex-col gap-2'
}
>
<LoadingOutlined style={{ fontSize: 32 }} />
<div>Fetching logs from {provider}...</div>
</div>
);
}
export function FailedFetch({
provider,
onRetry,
}: {
provider: string;
onRetry: () => void;
}) {
const history = useHistory();
const intPath = settingsPath(CLIENT_TABS.INTEGRATIONS);
return (
<div
className={
'w-full h-full flex flex-col items-center justify-center gap-2'
}
>
<Icon name={'exclamation-circle'} size={32} />
<div className={'flex items-center gap-1'}>
<span>Failed to fetch logs from {provider}. </span>
<div className={'link'} onClick={onRetry}>
Retry
</div>
</div>
<div className={'link'} onClick={() => history.push(intPath)}>
Check Configuration
</div>
</div>
);
}

View file

@ -0,0 +1,112 @@
import React from 'react';
import { Icon } from 'UI';
import { CopyOutlined } from '@ant-design/icons';
import { Button } from 'antd';
import cn from 'classnames';
import copy from 'copy-to-clipboard';
import { getDateFromString } from 'App/date';
export function TableHeader({ size }: { size: number }) {
return (
<div
className={'grid items-center py-2 px-4 bg-gray-lighter'}
style={{
gridTemplateColumns: 'repeat(14, minmax(0, 1fr))',
}}
>
<div className={'col-span-2'}>timestamp</div>
<div className={'col-span-1 pl-2'}>status</div>
<div className={'col-span-11 flex items-center justify-between'}>
<div>content</div>
<div>
<span className={'font-semibold'}>{size}</span> Records
</div>
</div>
</div>
);
}
export function LogRow({
log,
}: {
log: { timestamp: string; status: string; content: string };
}) {
const [isExpanded, setIsExpanded] = React.useState(false);
const bg = (status: string) => {
//types: warn error info none
if (status === 'WARN') {
return 'bg-yellow';
}
if (status === 'ERROR') {
return 'bg-red-lightest';
}
return 'bg-white';
};
const border = (status: string) => {
//types: warn error info none
if (status === 'WARN') {
return 'border-l border-l-4 border-l-amber-500';
}
if (status === 'ERROR') {
return 'border-l border-l-4 border-l-red';
}
return 'border-l border-l-4 border-gray-lighter';
};
return (
<div className={'code-font'}>
<div
className={cn(
'text-sm grid items-center py-2 px-4',
'cursor-pointer border-b border-b-gray-light last:border-b-0',
border(log.status),
bg(log.status)
)}
style={{
gridTemplateColumns: 'repeat(14, minmax(0, 1fr))',
}}
onClick={() => setIsExpanded((prev) => !prev)}
>
<div className={'col-span-2'}>
<div className={'flex items-center gap-2'}>
<Icon
name={'chevron-right'}
className={
isExpanded ? 'rotate-90 transition' : 'rotate-0 transition'
}
/>
<div className={'whitespace-nowrap'}>
{getDateFromString(log.timestamp)}
</div>
</div>
</div>
<div className={'col-span-1 pl-2'}>{log.status}</div>
<div
className={
'col-span-11 whitespace-nowrap overflow-hidden text-ellipsis'
}
>
{log.content}
</div>
</div>
{isExpanded ? (
<div className={'rounded bg-gray-lightest px-4 py-2 relative mx-4 my-2'}>
{log.content.split('\n').map((line, index) => (
<div key={index} className={'flex items-start gap-2'}>
<div className={'border-r border-r-gray-light pr-2 select-none'}>{index}</div>
<div className={'whitespace-pre-wrap'}>{line}</div>
</div>
))}
<div className={'absolute top-1 right-1'}>
<Button
size={'small'}
icon={<CopyOutlined />}
onClick={() => copy(log.content)}
/>
</div>
</div>
) : null}
</div>
);
}

View file

@ -0,0 +1,109 @@
export interface UnifiedLog {
key: string;
timestamp: string;
content: string;
status: string;
}
export function processLog(log: any): UnifiedLog[] {
if (isDatadogLog(log)) {
return log.map(processDatadogLog);
} else if (isElasticLog(log)) {
return log.map(processElasticLog);
} else if (isSentryLog(log)) {
return log.map(processSentryLog);
} else if (isDynatraceLog(log)) {
return log.map(processDynatraceLog);
} else {
throw new Error("Unknown log format");
}
}
function isDynatraceLog(log: any): boolean {
return (
log &&
log[0].results &&
Array.isArray(log[0].results) &&
log[0].results.length > 0 &&
log[0].results[0].eventType === "LOG"
);
}
function isDatadogLog(log: any): boolean {
return log && log[0].attributes && typeof log[0].attributes.message === 'string';
}
function isElasticLog(log: any): boolean {
return log && log[0]._source && log[0]._source.message;
}
function isSentryLog(log: any): boolean {
return log && log[0].id && log[0].message && log[0].title;
}
function processDynatraceLog(log: any): UnifiedLog {
const result = log.results[0];
const key =
result.additionalColumns?.["trace_id"]?.[0] ||
result.additionalColumns?.["span_id"]?.[0] ||
String(result.timestamp);
const timestamp = new Date(result.timestamp).toISOString();
let message = result.content || "";
let level = result.status?.toLowerCase() || "info";
const contentPattern = /^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\.\d+\|(\w+)\|.*?\| (.*)$/;
const contentMatch = message.match(contentPattern);
if (contentMatch) {
level = contentMatch[1].toLowerCase();
message = contentMatch[2];
}
return { key, timestamp, content: message, status: level };
}
function processDatadogLog(log: any): UnifiedLog {
const key = log.id || '';
const timestamp = log.timestamp || log.attributes?.timestamp || '';
const message = log.attributes?.message || '';
const level = log.attributes?.status || 'info';
return { key, timestamp, content: message, status: level.toUpperCase() };
}
function processElasticLog(log: any): UnifiedLog {
const key = log._id || '';
const timestamp = log._source['@timestamp'] || '';
const message = log._source.message || '';
const level = getLevelFromElasticTags(log._source.level);
return { key, timestamp, content: message, status: level };
}
function getLevelFromElasticTags(tags: string[]): string {
const levels = ['error', 'warning', 'info', 'debug'];
for (const level of levels) {
if (tags.includes(level.toLowerCase())) {
return level;
}
}
return 'info';
}
function processSentryLog(log: any): UnifiedLog {
const key = log.id || log.eventID || '';
const timestamp = log.dateCreated || 'N/A';
const message = `${log.title}: \n ${log.message}`;
const level = log.tags ? getLevelFromSentryTags(log.tags) : 'N/A';
return { key, timestamp, content: message, status: level };
}
function getLevelFromSentryTags(tags: any[]): string {
for (const tag of tags) {
if (tag.key === 'level') {
return tag.value;
}
}
return 'info';
}

View file

@ -17,6 +17,7 @@ interface IProps {
containerClassName?: string;
noIcon?: boolean;
popover?: React.ReactNode;
customTags?: React.ReactNode;
}
const ControlButton = ({
@ -26,6 +27,7 @@ const ControlButton = ({
hasErrors = false,
active = false,
popover = undefined,
customTags,
}: IProps) => (
<Popover content={popover} open={popover ? undefined : false}>
<Button
@ -34,6 +36,7 @@ const ControlButton = ({
id={'control-button-' + label.toLowerCase()}
disabled={disabled}
>
{customTags}
{hasErrors && <div className={stl.labels}><div className={stl.errorSymbol} /></div>}
<span className={cn('font-semibold hover:text-main', active ? 'color-main' : 'color-gray-darkest')}>
{label}

View file

@ -16,7 +16,7 @@ import {
LaunchNetworkShortcut,
LaunchPerformanceShortcut,
LaunchStateShortcut,
LaunchXRaShortcut
LaunchXRaShortcut,
} from 'Components/Session_/Player/Controls/components/KeyboardHelp';
import {
CONSOLE,
@ -27,9 +27,11 @@ import {
PERFORMANCE,
PROFILER,
STACKEVENTS,
STORAGE
STORAGE,
BACKENDLOGS,
} from 'App/mstore/uiPlayerStore';
import { Icon } from 'UI';
import LogsButton from 'App/components/Session/Player/SharedComponents/BackendLogs/LogsButton';
import ControlButton from './ControlButton';
import Timeline from './Timeline';
@ -43,7 +45,7 @@ export const SKIP_INTERVALS = {
15: 15e3,
20: 2e4,
30: 3e4,
60: 6e4
60: 6e4,
};
function getStorageName(type: any) {
@ -65,13 +67,22 @@ function getStorageName(type: any) {
}
}
function Controls({
setActiveTab
}: any) {
function Controls({ setActiveTab }: any) {
const { player, store } = React.useContext(PlayerContext);
const { uxtestingStore, uiPlayerStore, projectsStore, sessionStore, userStore } = useStore();
const {
uxtestingStore,
uiPlayerStore,
projectsStore,
sessionStore,
userStore,
} = useStore();
const permissions = userStore.account.permissions || [];
const disableDevtools = userStore.isEnterprise && !(permissions.includes('DEV_TOOLS') || permissions.includes('SERVICE_DEV_TOOLS'));
const disableDevtools =
userStore.isEnterprise &&
!(
permissions.includes('DEV_TOOLS') ||
permissions.includes('SERVICE_DEV_TOOLS')
);
const fullscreen = uiPlayerStore.fullscreen;
const bottomBlock = uiPlayerStore.bottomBlock;
const toggleBottomBlock = uiPlayerStore.toggleBottomBlock;
@ -89,7 +100,7 @@ function Controls({
speed,
messagesLoading,
markedTargets,
inspectorMode
inspectorMode,
} = store.get();
const session = sessionStore.current;
@ -116,7 +127,7 @@ function Controls({
openNextSession: nextHandler,
openPrevSession: prevHandler,
setActiveTab,
disableDevtools
disableDevtools,
});
const forthTenSeconds = () => {
@ -137,8 +148,8 @@ function Controls({
const state = completed
? PlayingState.Completed
: playing
? PlayingState.Playing
: PlayingState.Paused;
? PlayingState.Playing
: PlayingState.Paused;
const events = session.stackEvents ?? [];
return (
@ -206,13 +217,13 @@ interface IDevtoolsButtons {
const DevtoolsButtons = observer(
({
showStorageRedux,
toggleBottomTools,
bottomBlock,
disabled,
events
}: IDevtoolsButtons) => {
const { aiSummaryStore } = useStore();
showStorageRedux,
toggleBottomTools,
bottomBlock,
disabled,
events,
}: IDevtoolsButtons) => {
const { aiSummaryStore, integrationsStore } = useStore();
const { store, player } = React.useContext(PlayerContext);
// @ts-ignore
@ -249,6 +260,8 @@ const DevtoolsButtons = observer(
};
const possibleAudio = events.filter((e) => e.name.includes('media/audio'));
const integratedServices =
integrationsStore.integrations.backendLogIntegrations;
return (
<>
{isSaas ? <SummaryButton onClick={showSummary} /> : null}
@ -349,6 +362,12 @@ const DevtoolsButtons = observer(
label="Profiler"
/>
)}
{integratedServices.length ? (
<LogsButton
integrated={integratedServices.map((service) => service.name)}
onClick={() => toggleBottomTools(BACKENDLOGS)}
/>
) : null}
{possibleAudio.length ? (
<DropdownAudioPlayer audioEvents={possibleAudio} />
) : null}
@ -358,11 +377,11 @@ const DevtoolsButtons = observer(
);
export function SummaryButton({
onClick,
withToggle,
onToggle,
toggleValue
}: {
onClick,
withToggle,
onToggle,
toggleValue,
}: {
onClick?: () => void;
withToggle?: boolean;
onToggle?: () => void;
@ -398,7 +417,7 @@ export const gradientButton = {
height: 24,
display: 'flex',
alignItems: 'center',
justifyContent: 'center'
justifyContent: 'center',
};
const onHoverFillStyle = {
width: '100%',
@ -408,7 +427,7 @@ const onHoverFillStyle = {
gap: 2,
alignItems: 'center',
padding: '1px 8px',
background: 'linear-gradient(156deg, #E3E6FF 0%, #E4F3F4 69.48%)'
background: 'linear-gradient(156deg, #E3E6FF 0%, #E4F3F4 69.48%)',
};
const fillStyle = {
width: '100%',
@ -417,7 +436,7 @@ const fillStyle = {
borderRadius: '60px',
gap: 2,
alignItems: 'center',
padding: '1px 8px'
padding: '1px 8px',
};
export default observer(Controls);

View file

@ -7,27 +7,21 @@ const VersionComparison = {
Same: 0,
Higher: 1,
};
function parseVersion(version: string) {
const cleanVersion = version.split(/[-+]/)[0];
return cleanVersion.split('.').map(Number);
}
function compareVersions(
suppliedVersion: string,
currentVersion: string
): number {
function parseVersion(version: string) {
const cleanVersion = version.split(/[-+]/)[0];
return cleanVersion.split('.').map(Number);
}
const v1 = parseVersion(suppliedVersion);
const v2 = parseVersion(currentVersion);
const length = Math.max(v1.length, v2.length);
while (v1.length < length) v1.push(0);
while (v2.length < length) v2.push(0);
for (let i = 0; i < length; i++) {
if (v1[i] < v2[i]) return VersionComparison.Lower;
if (v1[i] > v2[i]) return VersionComparison.Higher;
}
if (v1[0] < v2[0]) return VersionComparison.Lower;
if (v1[0] > v2[0]) return VersionComparison.Higher;
return VersionComparison.Same;
}

View file

@ -319,6 +319,7 @@ export { default as Integrations_bugsnag } from './integrations_bugsnag';
export { default as Integrations_cloudwatch_text } from './integrations_cloudwatch_text';
export { default as Integrations_cloudwatch } from './integrations_cloudwatch';
export { default as Integrations_datadog } from './integrations_datadog';
export { default as Integrations_dynatrace } from './integrations_dynatrace';
export { default as Integrations_elasticsearch_text } from './integrations_elasticsearch_text';
export { default as Integrations_elasticsearch } from './integrations_elasticsearch';
export { default as Integrations_github } from './integrations_github';

View file

@ -0,0 +1,19 @@
/* Auto-generated, do not edit */
import React from 'react';
interface Props {
size?: number | string;
width?: number | string;
height?: number | string;
fill?: string;
}
function Integrations_dynatrace(props: Props) {
const { size = 14, width = size, height = size, fill = '' } = props;
return (
<svg viewBox="0 0 14 15" width={ `${ width }px` } height={ `${ height }px` } fill={ `${ fill }` }><g><path d="M4.77 1.403c-.18.95-.4 2.36-.52 3.79-.21 2.52-.08 4.21-.08 4.21l-3.55 3.37s-.27-1.89-.41-4.02C.13 7.433.1 6.273.1 5.573c0-.04.02-.08.02-.12 0-.05.06-.52.52-.96.5-.48 4.19-3.37 4.13-3.09Z" fill="#394EFF"/><path d="M4.77 1.403c-.18.95-.4 2.36-.52 3.79 0 0-3.93-.47-4.15.48 0-.05.07-.63.53-1.07.5-.48 4.2-3.48 4.14-3.2Z" fill="#1284EA"/><path d="M.1 5.443v.22c.04-.17.11-.29.25-.48.29-.37.76-.47.95-.49.96-.13 2.38-.28 3.81-.32 2.53-.08 4.2.13 4.2.13l3.55-3.37S11 .783 8.88.533c-1.39-.17-2.61-.26-3.3-.3-.05 0-.54-.06-1 .38-.5.48-3.04 2.89-4.06 3.86-.46.44-.42.93-.42.97Z" fill="#B4DC00"/><path d="M12.73 9.753c-.96.13-2.38.29-3.81.34-2.53.08-4.21-.13-4.21-.13l-3.55 3.38s1.88.37 4 .61c1.3.15 2.45.23 3.15.27.05 0 .13-.04.18-.04.05 0 .54-.09 1-.53.5-.48 3.52-3.93 3.24-3.9Z" fill="#6F2DA8"/><path d="M12.73 9.753c-.96.13-2.38.29-3.81.34 0 0 .27 3.95-.68 4.12.05 0 .7-.03 1.16-.47.5-.48 3.61-4.02 3.33-3.99Z" fill="#591F91"/><path d="M8.45 14.233c-.07 0-.14-.01-.22-.01.18-.03.3-.09.49-.23.38-.27.5-.74.54-.93.17-.95.4-2.36.51-3.79.2-2.52.08-4.2.08-4.2l3.55-3.38s.26 1.88.41 4.01c.09 1.39.12 2.62.13 3.3 0 .05.04.54-.42.98-.5.48-3.04 2.9-4.05 3.87-.48.44-.97.38-1.02.38Z" fill="#73BE28"/></g><defs><clipPath id="a"><path fill="#fff" transform="translate(0 .133)" d="M0 0h14v14.2H0z"/></clipPath></defs></svg>
);
}
export default Integrations_dynatrace;

File diff suppressed because one or more lines are too long

View file

@ -3,6 +3,10 @@
import { DateTime, Duration } from 'luxon'; // TODO
import { Timezone } from 'App/mstore/types/sessionSettings';
export function getDateFromString(date: string, format = 'yyyy-MM-dd HH:mm:ss:SSS'): string {
return DateTime.fromISO(date).toFormat(format);
}
/**
* Formats a given duration.
*

View file

View file

@ -0,0 +1,110 @@
import React, { useEffect, useState } from 'react';
interface ValidationRule {
custom?: (
value: string | boolean | number
) => string | undefined;
length?: [min: number, max: number];
format?: [regexPattern: string, errorMsg: string];
required?: boolean;
}
type FormValue = string | boolean | number;
function useForm<T extends { [K in keyof T]: FormValue }>(
initialValues: T,
validationRules?: Partial<Record<keyof T, ValidationRule>>
) {
const [values, setValues] = useState(initialValues);
const [errors, setErrors] = useState<Record<string, string | undefined>>({});
const [hasErrors, setHasErrors] = useState(false);
useEffect(() => {
setValues(initialValues);
}, [initialValues]);
useEffect(() => {
const hasErrors = Object.values(errors).some((error) => !!error);
setHasErrors(hasErrors);
}, [errors])
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target as unknown as { name: keyof T; value: FormValue };
setValues((prevValues) => ({
...prevValues,
[name]: value,
}));
if (validationRules?.[name]) {
validateField(name, value);
}
};
const validateField = (
fieldName: keyof T,
value: string | boolean | number
) => {
const rules = validationRules![fieldName];
let error = '';
if (typeof value !== 'string') return;
if (rules) {
if (rules.required) {
if (!value) {
error = 'Required';
}
}
if (rules.length) {
const [min, max] = rules.length;
if (value.length < min || value.length > max) {
error = `Must be between ${min} and ${max} characters`;
}
}
if (!error && rules.format) {
const [pattern, errorMsg] = rules.format;
const regex = new RegExp(pattern);
if (!regex.test(value)) {
error = errorMsg || 'Invalid format';
}
}
if (!error && typeof rules.custom === 'function') {
const customError = rules.custom(value);
if (customError) {
error = customError;
}
}
}
setErrors((prevErrors) => ({
...prevErrors,
[fieldName]: error,
}));
return Boolean(error);
};
const checkErrors = () => {
const errSignals: boolean[] = [];
Object.keys(values).forEach((key) => {
// @ts-ignore
errSignals.push(validateField(key, values[key]));
});
return errSignals.some((signal) => signal);
}
const resetForm = () => {
setValues(initialValues);
setErrors({});
};
return {
values,
errors,
handleChange,
resetForm,
hasErrors,
checkErrors,
};
}
export default useForm;

View file

@ -10,10 +10,15 @@ import { ConfigProvider, theme, ThemeConfig } from 'antd';
import colors from 'App/theme/colors';
import { BrowserRouter } from 'react-router-dom';
import { Notification, MountPoint } from 'UI';
import {
QueryClient,
QueryClientProvider,
} from '@tanstack/react-query'
// @ts-ignore
window.getCommitHash = () => console.log(window.env.COMMIT_HASH);
const queryClient = new QueryClient()
const customTheme: ThemeConfig = {
// algorithm: theme.compactAlgorithm,
components: {
@ -70,6 +75,7 @@ document.addEventListener('DOMContentLoaded', () => {
// const theme = window.localStorage.getItem('theme');
root.render(
<QueryClientProvider client={queryClient}>
<ConfigProvider theme={customTheme}>
<StoreProvider store={new RootStore()}>
<DndProvider backend={HTML5Backend}>
@ -81,5 +87,6 @@ document.addEventListener('DOMContentLoaded', () => {
<MountPoint />
</StoreProvider>
</ConfigProvider>
</QueryClientProvider>
);
});

View file

@ -75,7 +75,8 @@ window.getJWT = () => {
window.setJWT = (jwt) => {
userStore.updateJwt({jwt});
};
export const client = new APIClient();
const client = new APIClient();
export class RootStore {
dashboardStore: DashboardStore;
@ -167,4 +168,4 @@ export const withStore = (Component: any) => (props: any) => {
return <Component {...props} mstore={useStore()} />;
};
export { userStore, sessionStore, searchStore, searchStoreLive, projectStore };
export { userStore, sessionStore, searchStore, searchStoreLive, projectStore, client };

View file

@ -1,29 +1,24 @@
import { makeAutoObservable } from 'mobx';
import { integrationsService } from 'App/services';
import ElasticsearchForm from "../components/Client/Integrations/ElasticsearchForm";
import { MessengerConfig } from './types/integrations/messengers';
import {
Bugsnag,
Cloudwatch,
DatadogInt,
ElasticSearchInt,
GithubInt,
Integration,
IssueTracker,
JiraInt,
NewRelicInt,
RollbarInt,
SentryInt,
StackDriverInt,
SumoLogic,
} from './types/integrations/services';
import { serviceNames } from 'App/components/Client/Integrations/apiMethods';
class GenericIntegrationsStore {
list: any[] = [];
isLoading: boolean = false;
siteId: string = '';
constructor() {
makeAutoObservable(this);
}
@ -32,6 +27,15 @@ class GenericIntegrationsStore {
this.siteId = siteId;
}
get integratedServices() {
return this.list.filter(int => int.integrated);
}
get backendLogIntegrations(): { name: string, integrated: boolean }[] {
const backendServices = Object.keys(serviceNames);
return this.list.filter(int => int.integrated && backendServices.includes(int.name));
}
setList(list: any[]) {
this.list = list;
}
@ -276,13 +280,7 @@ export type namedStore = 'sentry'
export class IntegrationsStore {
sentry = new NamedIntegrationStore('sentry', (d) => new SentryInt(d));
datadog = new NamedIntegrationStore('datadog', (d) => new DatadogInt(d));
stackdriver = new NamedIntegrationStore('stackdriver', (d) => new StackDriverInt(d));
rollbar = new NamedIntegrationStore('rollbar', (d) => new RollbarInt(d));
newrelic = new NamedIntegrationStore('newrelic', (d) => new NewRelicInt(d));
bugsnag = new NamedIntegrationStore('bugsnag', (d) => new Bugsnag(d));
cloudwatch = new NamedIntegrationStore('cloudwatch', (d) => new Cloudwatch(d));
elasticsearch = new NamedIntegrationStore('elasticsearch', (d) => new ElasticSearchInt(d));
sumologic = new NamedIntegrationStore('sumologic', (d) => new SumoLogic(d));
jira = new NamedIntegrationStore('jira', (d) => new JiraInt(d));
github = new NamedIntegrationStore('github', (d) => new GithubInt(d));
issues = new NamedIntegrationStore('issues', (d) => new IssueTracker(d));

View file

@ -141,6 +141,10 @@ export default class SessionStore {
makeAutoObservable(this);
}
get currentId() {
return this.current.sessionId;
}
setUserTimezone = (timezone: string) => {
this.userTimezone = timezone;
}

View file

@ -17,6 +17,8 @@ export const FETCH = 8;
export const EXCEPTIONS = 9;
export const INSPECTOR = 11;
export const OVERVIEW = 12;
export const BACKENDLOGS = 13;
export const blocks = {
none: NONE,
@ -31,6 +33,7 @@ export const blocks = {
exceptions: EXCEPTIONS,
inspector: INSPECTOR,
overview: OVERVIEW,
backendLogs: BACKENDLOGS,
} as const;
export const blockValues = [
@ -46,6 +49,7 @@ export const blockValues = [
EXCEPTIONS,
INSPECTOR,
OVERVIEW,
BACKENDLOGS,
] as const;
export default class UiPlayerStore {

View file

@ -382,4 +382,8 @@ svg {
display: unset!important;
height: 100%!important;
}
.code-font {
font-family: Menlo, Monaco, Consolas, serif;
letter-spacing: -0.025rem
}

View file

@ -0,0 +1,15 @@
<svg viewBox="0 0 14 15" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_40_126)">
<path d="M4.76949 1.40322C4.58949 2.35322 4.36949 3.76322 4.24949 5.19322C4.03949 7.71322 4.16949 9.40322 4.16949 9.40322L0.619487 12.7732C0.619487 12.7732 0.349487 10.8832 0.209487 8.75322C0.129487 7.43322 0.0994873 6.27322 0.0994873 5.57322C0.0994873 5.53322 0.119487 5.49322 0.119487 5.45322C0.119487 5.40322 0.179487 4.93322 0.639487 4.49322C1.13949 4.01322 4.82949 1.12322 4.76949 1.40322Z" fill="#394EFF"/>
<path d="M4.76949 1.40317C4.58949 2.35317 4.36949 3.76317 4.24949 5.19317C4.24949 5.19317 0.319487 4.72317 0.0994873 5.67317C0.0994873 5.62317 0.169487 5.04317 0.629487 4.60317C1.12949 4.12317 4.82949 1.12317 4.76949 1.40317Z" fill="#1284EA"/>
<path d="M0.0995107 5.44322V5.66322C0.139511 5.49322 0.209511 5.37322 0.349511 5.18322C0.639511 4.81322 1.10951 4.71322 1.29951 4.69322C2.25951 4.56322 3.67951 4.41322 5.10951 4.37322C7.63951 4.29322 9.30951 4.50322 9.30951 4.50322L12.8595 1.13322C12.8595 1.13322 10.9995 0.783224 8.87951 0.533224C7.48951 0.363224 6.26951 0.273224 5.57951 0.233224C5.52951 0.233224 5.03951 0.173224 4.57951 0.613224C4.07951 1.09322 1.53951 3.50322 0.519511 4.47322C0.0595107 4.91322 0.0995107 5.40322 0.0995107 5.44322Z" fill="#B4DC00"/>
<path d="M12.7295 9.75325C11.7695 9.88325 10.3495 10.0432 8.91948 10.0932C6.38948 10.1732 4.70948 9.96325 4.70948 9.96325L1.15948 13.3432C1.15948 13.3432 3.03948 13.7132 5.15948 13.9532C6.45948 14.1032 7.60948 14.1832 8.30948 14.2232C8.35948 14.2232 8.43948 14.1832 8.48948 14.1832C8.53948 14.1832 9.02948 14.0932 9.48948 13.6532C9.98948 13.1732 13.0095 9.72325 12.7295 9.75325Z" fill="#6F2DA8"/>
<path d="M12.7295 9.75324C11.7695 9.88324 10.3495 10.0432 8.9195 10.0932C8.9195 10.0932 9.1895 14.0432 8.2395 14.2132C8.2895 14.2132 8.9395 14.1832 9.3995 13.7432C9.8995 13.2632 13.0095 9.72324 12.7295 9.75324Z" fill="#591F91"/>
<path d="M8.44949 14.2332C8.37949 14.2332 8.30949 14.2232 8.22949 14.2232C8.40949 14.1932 8.52949 14.1332 8.71949 13.9932C9.09949 13.7232 9.21949 13.2532 9.25949 13.0632C9.42949 12.1132 9.65949 10.7032 9.76949 9.27324C9.96949 6.75324 9.84949 5.07324 9.84949 5.07324L13.3995 1.69324C13.3995 1.69324 13.6595 3.57324 13.8095 5.70324C13.8995 7.09324 13.9295 8.32324 13.9395 9.00324C13.9395 9.05324 13.9795 9.54324 13.5195 9.98324C13.0195 10.4632 10.4795 12.8832 9.46949 13.8532C8.98949 14.2932 8.49949 14.2332 8.44949 14.2332Z" fill="#73BE28"/>
</g>
<defs>
<clipPath id="clip0_40_126">
<rect width="14" height="14.2" fill="white" transform="translate(0 0.133301)"/>
</clipPath>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.5 KiB

View file

@ -28,6 +28,7 @@
"@medv/finder": "^3.1.0",
"@sentry/browser": "^5.21.1",
"@svg-maps/world": "^1.0.1",
"@tanstack/react-query": "^5.56.2",
"@wojtekmaj/react-daterange-picker": "^6.0.0",
"antd": "^5.21.2",
"chroma-js": "^2.4.2",

View file

@ -2888,6 +2888,24 @@ __metadata:
languageName: node
linkType: hard
"@tanstack/query-core@npm:5.59.13":
version: 5.59.13
resolution: "@tanstack/query-core@npm:5.59.13"
checksum: 10c1/04ad8684a04d995488da547f0a885f7f41adf8a06920c8c2fff0e614e4f322609ce007540e5d313268cad601fc5387c55837609fb1de3b137873a75a754153db
languageName: node
linkType: hard
"@tanstack/react-query@npm:^5.56.2":
version: 5.59.15
resolution: "@tanstack/react-query@npm:5.59.15"
dependencies:
"@tanstack/query-core": "npm:5.59.13"
peerDependencies:
react: ^18 || ^19
checksum: 10c1/7642f9ad4e48b3d1684f6aee5194df16655dbb50575ed0aac473a1b393759e9a48a5fb22c077c8d9c0c0a81f49d2d7cd964400b0ad41b1f41a887bb29e67bcae
languageName: node
linkType: hard
"@tootallnate/once@npm:2":
version: 2.0.0
resolution: "@tootallnate/once@npm:2.0.0"
@ -11392,6 +11410,7 @@ __metadata:
"@openreplay/sourcemap-uploader": "npm:^3.0.8"
"@sentry/browser": "npm:^5.21.1"
"@svg-maps/world": "npm:^1.0.1"
"@tanstack/react-query": "npm:^5.56.2"
"@trivago/prettier-plugin-sort-imports": "npm:^4.3.0"
"@types/luxon": "npm:^3.4.2"
"@types/node": "npm:^22.7.8"

View file

@ -12,6 +12,7 @@ export default function (app: App) {
const docFonts: Map<Document, FFData[]> = new Map()
const patchWindow = (wnd: typeof globalThis) => {
// @ts-ignore
class FontFaceInterceptor extends wnd.FontFace {
constructor(...args: ConstructorParameters<typeof FontFace>) {
//maybe do this on load(). In this case check if the document.fonts.load(...) function calls the font's load()