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:
parent
1d96ec02fc
commit
c144add4bd
73 changed files with 1585 additions and 792 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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', '')
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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 }
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './BugsnagForm';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './CloudwatchForm';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
33
frontend/app/components/Client/Integrations/FormField.tsx
Normal file
33
frontend/app/components/Client/Integrations/FormField.tsx
Normal 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;
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 />,
|
||||
// },
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './NewrelicForm';
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
@ -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;
|
||||
|
|
@ -1 +0,0 @@
|
|||
export { default } from './SumoLogicForm';
|
||||
85
frontend/app/components/Client/Integrations/apiMethods.ts
Normal file
85
frontend/app/components/Client/Integrations/apiMethods.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
19
frontend/app/components/ui/Icons/integrations_dynatrace.tsx
Normal file
19
frontend/app/components/ui/Icons/integrations_dynatrace.tsx
Normal 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
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
0
frontend/app/duck/components/player.ts
Normal file
0
frontend/app/duck/components/player.ts
Normal file
110
frontend/app/hooks/useForm.ts
Normal file
110
frontend/app/hooks/useForm.ts
Normal 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;
|
||||
|
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -141,6 +141,10 @@ export default class SessionStore {
|
|||
makeAutoObservable(this);
|
||||
}
|
||||
|
||||
get currentId() {
|
||||
return this.current.sessionId;
|
||||
}
|
||||
|
||||
setUserTimezone = (timezone: string) => {
|
||||
this.userTimezone = timezone;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -382,4 +382,8 @@ svg {
|
|||
display: unset!important;
|
||||
height: 100%!important;
|
||||
}
|
||||
|
||||
|
||||
.code-font {
|
||||
font-family: Menlo, Monaco, Consolas, serif;
|
||||
letter-spacing: -0.025rem
|
||||
}
|
||||
15
frontend/app/svg/icons/integrations/dynatrace.svg
Normal file
15
frontend/app/svg/icons/integrations/dynatrace.svg
Normal 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 |
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue