123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392 |
- import {Fragment} from 'react';
- import type {RouteComponentProps} from 'react-router';
- import styled from '@emotion/styled';
- import {
- addErrorMessage,
- addLoadingMessage,
- addSuccessMessage,
- } from 'sentry/actionCreators/indicator';
- import {openRemoteConfigCreateFeatureModal} from 'sentry/actionCreators/modal';
- import Feature from 'sentry/components/acl/feature';
- import Alert from 'sentry/components/alert';
- import {Button} from 'sentry/components/button';
- import {openConfirmModal} from 'sentry/components/confirm';
- import {Flex} from 'sentry/components/container/flex';
- import EmptyMessage from 'sentry/components/emptyMessage';
- import TextArea from 'sentry/components/forms/controls/textarea';
- import FieldGroup from 'sentry/components/forms/fieldGroup';
- import FieldControl from 'sentry/components/forms/fieldGroup/fieldControl';
- import * as Layout from 'sentry/components/layouts/thirds';
- import NoProjectMessage from 'sentry/components/noProjectMessage';
- import Panel from 'sentry/components/panels/panel';
- import PanelBody from 'sentry/components/panels/panelBody';
- import PanelHeader from 'sentry/components/panels/panelHeader';
- import {PanelTable} from 'sentry/components/panels/panelTable';
- import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
- import {Slider} from 'sentry/components/slider';
- import SplitDiff from 'sentry/components/splitDiff';
- import TextCopyInput from 'sentry/components/textCopyInput';
- import {IconSubtract} from 'sentry/icons';
- import {t, tct} from 'sentry/locale';
- import {space} from 'sentry/styles/space';
- import type {Organization} from 'sentry/types/organization';
- import type {Project} from 'sentry/types/project';
- import type {RemoteConfigFeature, RemoteConfigOptions} from 'sentry/types/remoteConfig';
- import SettingsPageHeader from 'sentry/views/settings/components/settingsPageHeader';
- import useRemoteConfigSettings from 'sentry/views/settings/project/remoteConfig/useRemoteConfigSettings';
- type RouteParams = {
- projectId: string;
- };
- type Props = RouteComponentProps<RouteParams, {}> & {
- organization: Organization;
- project: Project;
- };
- export default function RemoteConfigContainer(props: Props) {
- return (
- <Feature
- features="remote-config"
- organization={props.organization}
- renderDisabled={NoAccess}
- >
- <NoProjectMessage organization={props.organization}>
- <ProjectRemoteConfig {...props} />
- </NoProjectMessage>
- </Feature>
- );
- }
- function NoAccess() {
- return (
- <Layout.Page withPadding>
- <Alert type="warning">{t("You don't have access to this feature")}</Alert>
- </Layout.Page>
- );
- }
- function ProjectRemoteConfig({organization, project, params: {projectId}}: Props) {
- const {result, staged, dispatch, handleSave, handleDelete} = useRemoteConfigSettings({
- organization,
- projectId,
- });
- const disabled = result.isLoading || result.isFetching;
- const addFeatureButton = (
- <Flex justify="flex-end">
- <Button
- size="xs"
- onClick={() => {
- openRemoteConfigCreateFeatureModal({
- createFeature: (key: string, value: string) =>
- dispatch({type: 'addFeature', key, value}),
- isValid: key =>
- !staged.data.features.map(feature => feature.key).includes(key),
- });
- }}
- >
- {t('Add Feature')}
- </Button>
- </Flex>
- );
- return (
- <SentryDocumentTitle title={t('Remote Config')} projectSlug={project.slug}>
- <SettingsPageHeader title={t('Remote Config')} />
- <Panel>
- <PanelHeader>{t('Settings')}</PanelHeader>
- <PanelBody>
- <OptionsPanelContent
- disabled={disabled}
- dispatch={dispatch}
- options={staged.data.options}
- />
- </PanelBody>
- </Panel>
- <FeaturesPanelTable
- isLoading={result.isLoading}
- headers={[t('Feature Key'), t('Feature Value'), addFeatureButton]}
- >
- <FeaturesPanelContent
- disabled={disabled}
- dispatch={dispatch}
- features={staged.data.features || []}
- />
- </FeaturesPanelTable>
- <PanelTable headers={['Current Config', 'Proposed Config']}>
- <PreviewPanelContent result={result} staged={staged} />
- </PanelTable>
- <SaveRow
- result={result}
- staged={staged}
- handleSave={handleSave}
- dispatch={dispatch}
- />
- <Panel>
- <PanelHeader>{t('Danger Zone')}</PanelHeader>
- <PanelBody>
- <DangerZonePanelContent
- onDelete={handleDelete}
- project={project}
- result={result}
- />
- </PanelBody>
- </Panel>
- </SentryDocumentTitle>
- );
- }
- function OptionsPanelContent({
- disabled,
- dispatch,
- options,
- }: {
- disabled: boolean;
- dispatch: ReturnType<typeof useRemoteConfigSettings>['dispatch'];
- options: RemoteConfigOptions;
- }) {
- return (
- <Fragment>
- <FieldGroup
- id="sample_rate"
- label={t('Sample Rate')}
- help={t(
- 'Configures the sample rate for error events, in the range of 0.0 to 1.0. The default is 1.0, which means that 100% of error events will be sent. If set to 0.1, only 10% of error events will be sent. Events are picked randomly.'
- )}
- >
- <FieldControl>
- <Slider
- id="sample_rate"
- aria-describedby="sample_rate_help"
- aria-label={t('Sample Rate')}
- disabled={disabled}
- onChangeEnd={value => {
- dispatch({
- type: 'updateOption',
- key: 'sample_rate',
- value: value as number,
- });
- }}
- showThumbLabels
- min={0}
- max={1}
- step={0.01}
- value={options.sample_rate}
- />
- </FieldControl>
- </FieldGroup>
- <FieldGroup
- id="traces_sample_rate"
- label={t('Traces Sample Rate')}
- help={t(
- 'A number between 0 and 1, controlling the percentage chance a given transaction will be sent to Sentry. (0 represents 0% while 1 represents 100%.) Applies equally to all transactions created in the app. Either this or traces_sampler must be defined to enable tracing.'
- )}
- >
- <FieldControl>
- <Slider
- id="traces_sample_rate"
- aria-describedby="traces_sample_rate_help"
- aria-label={t('Traces Sample Rate')}
- disabled={disabled}
- onChangeEnd={value => {
- dispatch({
- type: 'updateOption',
- key: 'traces_sample_rate',
- value: value as number,
- });
- }}
- showThumbLabels
- min={0}
- max={1}
- step={0.01}
- value={options.traces_sample_rate}
- />
- </FieldControl>
- </FieldGroup>
- </Fragment>
- );
- }
- function FeaturesPanelContent({
- disabled,
- dispatch,
- features,
- }: {
- disabled: boolean;
- dispatch: ReturnType<typeof useRemoteConfigSettings>['dispatch'];
- features: RemoteConfigFeature[];
- }) {
- if (!features.length) {
- return (
- <Fragment>
- <ColSpanner>
- <EmptyMessage>{t('No features defined')}</EmptyMessage>
- </ColSpanner>
- <div style={{padding: 0}} />
- <div style={{padding: 0}} />
- </Fragment>
- );
- }
- return features.map(feature => {
- return [
- <div key={`key[${feature.key}]`}>
- <TextCopyInput size="sm">{feature.key}</TextCopyInput>
- </div>,
- <div key={`value[${feature.key}]`}>
- <TextArea
- name={`feature[${feature.key}]`}
- defaultValue={feature.value}
- rows={2}
- onChange={event => {
- const value = event.currentTarget.value;
- dispatch({type: 'updateFeature', key: feature.key, value});
- }}
- />
- </div>,
- <Flex justify="flex-end" key={`remove[${feature.key}]`}>
- <Button
- size="xs"
- disabled={disabled}
- icon={<IconSubtract isCircled />}
- aria-label={t('Remove')}
- onClick={() => {
- dispatch({type: 'removeFeature', key: feature.key});
- }}
- >
- {t('Remove')}
- </Button>
- </Flex>,
- ];
- });
- }
- function PreviewPanelContent({
- result,
- staged,
- }: {
- result: ReturnType<typeof useRemoteConfigSettings>['result'];
- staged: ReturnType<typeof useRemoteConfigSettings>['staged'];
- }) {
- const baseJSON = JSON.stringify(result.data, null, '\t');
- const targetJSON = JSON.stringify(staged, null, '\t');
- return (
- <Fragment>
- <ColSpanner>
- <SplitDiff key="diff" type="words" base={baseJSON} target={targetJSON} />
- </ColSpanner>
- <div style={{padding: 0}} />
- </Fragment>
- );
- }
- function SaveRow({
- dispatch,
- handleSave,
- result,
- staged,
- }: {
- dispatch: ReturnType<typeof useRemoteConfigSettings>['dispatch'];
- handleSave: ReturnType<typeof useRemoteConfigSettings>['handleSave'];
- result: ReturnType<typeof useRemoteConfigSettings>['result'];
- staged: ReturnType<typeof useRemoteConfigSettings>['staged'];
- }) {
- const baseJSON = JSON.stringify(result.data, null, '\t');
- const targetJSON = JSON.stringify(staged, null, '\t');
- const isDisabled = !result.data || baseJSON === targetJSON;
- const handleRevert = result.data
- ? () => dispatch({type: 'revertStaged', data: result.data})
- : () => {};
- return (
- <ButtonFlex gap={space(2)}>
- <Button
- size="md"
- priority="primary"
- onClick={() => {
- addLoadingMessage(t('Saving remote config...'));
- handleSave(
- () => addSuccessMessage(t('Remote config saved')),
- () => addErrorMessage(t('Unable to save remote config'))
- );
- }}
- disabled={isDisabled}
- >
- {t('Save Changes')}
- </Button>
- <Button size="md" onClick={handleRevert} disabled={isDisabled}>
- {t('Revert Changes')}
- </Button>
- </ButtonFlex>
- );
- }
- function DangerZonePanelContent({
- onDelete,
- project,
- result,
- }: {
- onDelete: ReturnType<typeof useRemoteConfigSettings>['handleDelete'];
- project: Project;
- result: ReturnType<typeof useRemoteConfigSettings>['result'];
- }) {
- const disabled = result.isError;
- return (
- <FieldGroup
- label={t('Delete Remote Config')}
- help={tct(
- 'If you want to start over, you can delete the remote config for [projectName].',
- {projectName: <strong>{project.slug}</strong>}
- )}
- >
- <FieldControl>
- <div>
- <Button
- priority="danger"
- disabled={disabled}
- onClick={() => {
- openConfirmModal({
- header: t('Delete Remote Config'),
- message: t(
- 'Are you sure you want to delete the remote config for %s back to defaults? This cannot be undone.',
- project.slug
- ),
- onConfirm: () => {
- addLoadingMessage(t('Deleting remote config...'));
- onDelete(
- () => addSuccessMessage(t('Remote config deleted')),
- () => addErrorMessage(t('Unable to delete remote config'))
- );
- },
- });
- }}
- >
- {t('Delete Config')}
- </Button>
- </div>
- </FieldControl>
- </FieldGroup>
- );
- }
- const ButtonFlex = styled(Flex)`
- margin-bottom: ${space(4)};
- `;
- const FeaturesPanelTable = styled(PanelTable)`
- grid-template-columns: 1fr 2fr max-content;
- `;
- const ColSpanner = styled('div')`
- display: flex;
- flex-flow: column;
- grid-column-end: -1;
- grid-column-start: 1;
- `;
|