Browse Source

feat(ui): Split proguard from other DIFs (#18592)

We are introducing a new page inside processing section of project settings called Android Mappings. Proguard files (android mappings) are living now with other debug files, but we are starting to move them into their own page and later we will change their design a bit.

This is behind the android-mappings feature flag. I am going to make it part of sentry-experiments team.
Matej Minar 4 years ago
parent
commit
3c08289ee8

+ 11 - 4
src/sentry/api/endpoints/debug_files.py

@@ -30,6 +30,7 @@ from sentry.utils import json
 
 
 logger = logging.getLogger("sentry.api")
 logger = logging.getLogger("sentry.api")
 ERR_FILE_EXISTS = "A file matching this debug identifier already exists"
 ERR_FILE_EXISTS = "A file matching this debug identifier already exists"
+DIF_MIMETYPES = dict((v, k) for k, v in KNOWN_DIF_FORMATS.items())
 
 
 
 
 def upload_from_request(request, project):
 def upload_from_request(request, project):
@@ -89,6 +90,7 @@ class DebugFilesEndpoint(ProjectEndpoint):
                                      DIFs of.
                                      DIFs of.
         :qparam string query: If set, this parameter is used to locate DIFs with.
         :qparam string query: If set, this parameter is used to locate DIFs with.
         :qparam string id: If set, the specified DIF will be sent in the response.
         :qparam string id: If set, the specified DIF will be sent in the response.
+        :qparam string file_formats: If set, only DIFs with these formats will be returned.
         :auth: required
         :auth: required
         """
         """
         download_requested = request.GET.get("id") is not None
         download_requested = request.GET.get("id") is not None
@@ -98,6 +100,7 @@ class DebugFilesEndpoint(ProjectEndpoint):
         code_id = request.GET.get("code_id")
         code_id = request.GET.get("code_id")
         debug_id = request.GET.get("debug_id")
         debug_id = request.GET.get("debug_id")
         query = request.GET.get("query")
         query = request.GET.get("query")
+        file_formats = request.GET.getlist("file_formats")
 
 
         # If this query contains a debug identifier, normalize it to allow for
         # If this query contains a debug identifier, normalize it to allow for
         # more lenient queries (e.g. supporting Breakpad ids). Use the index to
         # more lenient queries (e.g. supporting Breakpad ids). Use the index to
@@ -124,13 +127,17 @@ class DebugFilesEndpoint(ProjectEndpoint):
                 | Q(file__headers__icontains=query)
                 | Q(file__headers__icontains=query)
             )
             )
 
 
-            KNOWN_DIF_FORMATS_REVERSE = dict((v, k) for (k, v) in six.iteritems(KNOWN_DIF_FORMATS))
-            file_format = KNOWN_DIF_FORMATS_REVERSE.get(query)
-            if file_format:
-                q |= Q(file__headers__icontains=file_format)
+            known_file_format = DIF_MIMETYPES.get(query)
+            if known_file_format:
+                q |= Q(file__headers__icontains=known_file_format)
         else:
         else:
             q = Q()
             q = Q()
 
 
+        for file_format in file_formats:
+            known_file_format = DIF_MIMETYPES.get(file_format)
+            if known_file_format:
+                q |= Q(file__headers__icontains=known_file_format)
+
         queryset = ProjectDebugFile.objects.filter(q, project=project).select_related("file")
         queryset = ProjectDebugFile.objects.filter(q, project=project).select_related("file")
 
 
         return self.paginate(
         return self.paginate(

+ 2 - 0
src/sentry/conf/server.py

@@ -798,6 +798,8 @@ SENTRY_FEATURES = {
     "auth:register": True,
     "auth:register": True,
     # Enable advanced search features, like negation and wildcard matching.
     # Enable advanced search features, like negation and wildcard matching.
     "organizations:advanced-search": True,
     "organizations:advanced-search": True,
+    # Enable android mappings in processing section of settings.
+    "organizations:android-mappings": False,
     # Enable obtaining and using API keys.
     # Enable obtaining and using API keys.
     "organizations:api-keys": False,
     "organizations:api-keys": False,
     # Enable explicit use of AND and OR in search.
     # Enable explicit use of AND and OR in search.

+ 1 - 0
src/sentry/features/__init__.py

@@ -54,6 +54,7 @@ default_manager.add("organizations:create")
 
 
 # Organization scoped features
 # Organization scoped features
 default_manager.add("organizations:advanced-search", OrganizationFeature)  # NOQA
 default_manager.add("organizations:advanced-search", OrganizationFeature)  # NOQA
+default_manager.add("organizations:android-mappings", OrganizationFeature)  # NOQA
 default_manager.add("organizations:boolean-search", OrganizationFeature)  # NOQA
 default_manager.add("organizations:boolean-search", OrganizationFeature)  # NOQA
 default_manager.add("organizations:api-keys", OrganizationFeature)  # NOQA
 default_manager.add("organizations:api-keys", OrganizationFeature)  # NOQA
 default_manager.add("organizations:data-export", OrganizationFeature)  # NOQA
 default_manager.add("organizations:data-export", OrganizationFeature)  # NOQA

+ 10 - 2
src/sentry/static/sentry/app/routes.jsx

@@ -394,7 +394,6 @@ function routes() {
         }
         }
         component={errorHandler(LazyLoad)}
         component={errorHandler(LazyLoad)}
       />
       />
-
       <Route
       <Route
         name={t('Data Privacy')}
         name={t('Data Privacy')}
         path="data-privacy/"
         path="data-privacy/"
@@ -405,7 +404,6 @@ function routes() {
           )
           )
         }
         }
       />
       />
-
       <Route
       <Route
         path="debug-symbols/"
         path="debug-symbols/"
         name="Debug Information Files"
         name="Debug Information Files"
@@ -416,6 +414,16 @@ function routes() {
         }
         }
         component={errorHandler(LazyLoad)}
         component={errorHandler(LazyLoad)}
       />
       />
+      <Route
+        path="android-mappings/"
+        name={t('Android Mappings')}
+        componentPromise={() =>
+          import(
+            /* webpackChunkName: "ProjectAndroidMappings" */ 'app/views/settings/projectAndroidMappings'
+          )
+        }
+        component={errorHandler(LazyLoad)}
+      />
       <Route
       <Route
         path="processing-issues/"
         path="processing-issues/"
         name="Processing Issues"
         name="Processing Issues"

+ 5 - 0
src/sentry/static/sentry/app/views/settings/project/navigationConfiguration.tsx

@@ -62,6 +62,11 @@ export default function getConfiguration({
           path: `${pathPrefix}/debug-symbols/`,
           path: `${pathPrefix}/debug-symbols/`,
           title: t('Debug Files'),
           title: t('Debug Files'),
         },
         },
+        {
+          path: `${pathPrefix}/android-mappings/`,
+          title: t('Android Mappings'),
+          show: () => organization.features?.includes('android-mappings'),
+        },
         {
         {
           path: `${pathPrefix}/data-privacy/`,
           path: `${pathPrefix}/data-privacy/`,
           title: t('Data Privacy'),
           title: t('Data Privacy'),

+ 42 - 0
src/sentry/static/sentry/app/views/settings/projectAndroidMappings/index.tsx

@@ -0,0 +1,42 @@
+import React from 'react';
+
+import {t} from 'app/locale';
+import {PageContent} from 'app/styles/organization';
+import SentryTypes from 'app/sentryTypes';
+import Feature from 'app/components/acl/feature';
+import Alert from 'app/components/alert';
+import withOrganization from 'app/utils/withOrganization';
+
+import ProjectAndroidMappings from './projectAndroidMappings';
+
+class ProjectAndroidMappingsContainer extends React.Component<
+  ProjectAndroidMappings['props']
+> {
+  static propTypes = {
+    organization: SentryTypes.Organization.isRequired,
+  };
+
+  renderNoAccess() {
+    return (
+      <PageContent>
+        <Alert type="warning">{t("You don't have access to this feature")}</Alert>
+      </PageContent>
+    );
+  }
+
+  render() {
+    const {organization} = this.props;
+
+    return (
+      <Feature
+        features={['android-mappings']}
+        organization={organization}
+        renderDisabled={this.renderNoAccess}
+      >
+        <ProjectAndroidMappings {...this.props} />
+      </Feature>
+    );
+  }
+}
+
+export default withOrganization(ProjectAndroidMappingsContainer);

+ 223 - 0
src/sentry/static/sentry/app/views/settings/projectAndroidMappings/projectAndroidMappings.tsx

@@ -0,0 +1,223 @@
+import {RouteComponentProps} from 'react-router/lib/Router';
+import React from 'react';
+import styled from '@emotion/styled';
+
+import space from 'app/styles/space';
+import {PanelTable} from 'app/components/panels';
+import {t} from 'app/locale';
+import AsyncView from 'app/views/asyncView';
+import Pagination from 'app/components/pagination';
+import SettingsPageHeader from 'app/views/settings/components/settingsPageHeader';
+import TextBlock from 'app/views/settings/components/text/textBlock';
+import {Organization, Project} from 'app/types';
+import routeTitleGen from 'app/utils/routeTitle';
+import Checkbox from 'app/components/checkbox';
+import SearchBar from 'app/components/searchBar';
+// TODO(android-mappings): use own components once we decide how this should look like
+import DebugFileRow from 'app/views/settings/projectDebugFiles/debugFileRow';
+import {DebugFile} from 'app/views/settings/projectDebugFiles/types';
+
+type Props = RouteComponentProps<{orgId: string; projectId: string}, {}> & {
+  organization: Organization;
+  project: Project;
+};
+
+type State = AsyncView['state'] & {
+  mappings: DebugFile[];
+  showDetails: boolean;
+};
+
+class ProjectAndroidMappings extends AsyncView<Props, State> {
+  getTitle() {
+    const {projectId} = this.props.params;
+
+    return routeTitleGen(t('Android Mappings'), projectId, false);
+  }
+
+  getDefaultState(): State {
+    return {
+      ...super.getDefaultState(),
+      mappings: [],
+      showDetails: false,
+    };
+  }
+
+  getEndpoints() {
+    const {params, location} = this.props;
+    const {orgId, projectId} = params;
+
+    const endpoints: ReturnType<AsyncView['getEndpoints']> = [
+      [
+        'mappings',
+        `/projects/${orgId}/${projectId}/files/dsyms/`,
+        {query: {query: location.query.query, file_formats: 'proguard'}},
+      ],
+    ];
+
+    return endpoints;
+  }
+
+  handleDelete = (id: string) => {
+    const {orgId, projectId} = this.props.params;
+
+    this.setState({
+      loading: true,
+    });
+
+    this.api.request(
+      `/projects/${orgId}/${projectId}/files/dsyms/?id=${encodeURIComponent(id)}`,
+      {
+        method: 'DELETE',
+        complete: () => this.fetchData(),
+      }
+    );
+  };
+
+  handleSearch = (query: string) => {
+    const {location, router} = this.props;
+
+    router.push({
+      ...location,
+      query: {...location.query, cursor: undefined, query},
+    });
+  };
+
+  getQuery() {
+    const {query} = this.props.location.query;
+
+    return typeof query === 'string' ? query : undefined;
+  }
+
+  getEmptyMessage() {
+    if (this.getQuery()) {
+      return t('There are no mappings that match your search.');
+    }
+
+    return t('There are no mappings for this project.');
+  }
+
+  renderLoading() {
+    return this.renderBody();
+  }
+
+  renderMappings() {
+    const {mappings, showDetails} = this.state;
+    const {orgId, projectId} = this.props.params;
+
+    if (!mappings?.length) {
+      return null;
+    }
+
+    return mappings.map(mapping => {
+      const downloadUrl = `${
+        this.api.baseUrl
+      }/projects/${orgId}/${projectId}/files/dsyms/?id=${encodeURIComponent(mapping.id)}`;
+
+      return (
+        <DebugFileRow
+          debugFile={mapping}
+          showDetails={showDetails}
+          downloadUrl={downloadUrl}
+          onDelete={this.handleDelete}
+          key={mapping.id}
+        />
+      );
+    });
+  }
+
+  renderBody() {
+    const {loading, showDetails, mappings, mappingsPageLinks} = this.state;
+
+    return (
+      <React.Fragment>
+        <SettingsPageHeader title={t('Android Mappings')} />
+
+        <TextBlock>
+          {t(
+            `Android mapping files are used to convert minified classes, methods and field names into a human readable format.`
+          )}
+        </TextBlock>
+
+        <Wrapper>
+          <TextBlock noMargin>{t('Uploaded mappings')}:</TextBlock>
+
+          <Filters>
+            <Label>
+              <Checkbox
+                checked={showDetails}
+                onChange={e => {
+                  this.setState({showDetails: (e.target as HTMLInputElement).checked});
+                }}
+              />
+              {t('show details')}
+            </Label>
+
+            <SearchBar
+              placeholder={t('Search mappings')}
+              onSearch={this.handleSearch}
+              query={this.getQuery()}
+            />
+          </Filters>
+        </Wrapper>
+
+        <StyledPanelTable
+          headers={[
+            t('Debug ID'),
+            t('Information'),
+            <TextRight key="actions">{t('Actions')}</TextRight>,
+          ]}
+          emptyMessage={this.getEmptyMessage()}
+          isEmpty={mappings?.length === 0}
+          isLoading={loading}
+        >
+          {this.renderMappings()}
+        </StyledPanelTable>
+        <Pagination pageLinks={mappingsPageLinks} />
+      </React.Fragment>
+    );
+  }
+}
+
+const StyledPanelTable = styled(PanelTable)`
+  grid-template-columns: 37% 1fr auto;
+`;
+
+const TextRight = styled('div')`
+  text-align: right;
+`;
+
+const Wrapper = styled('div')`
+  display: grid;
+  grid-template-columns: auto 1fr;
+  grid-gap: ${space(4)};
+  align-items: center;
+  margin-top: ${space(4)};
+  margin-bottom: ${space(1)};
+  @media (max-width: ${p => p.theme.breakpoints[0]}) {
+    display: block;
+  }
+`;
+
+const Filters = styled('div')`
+  display: grid;
+  grid-template-columns: min-content minmax(200px, 400px);
+  align-items: center;
+  justify-content: flex-end;
+  grid-gap: ${space(2)};
+  @media (max-width: ${p => p.theme.breakpoints[0]}) {
+    grid-template-columns: min-content 1fr;
+  }
+`;
+
+const Label = styled('label')`
+  font-weight: normal;
+  display: flex;
+  margin-bottom: 0;
+  white-space: nowrap;
+  input {
+    margin-top: 0;
+    margin-right: ${space(1)};
+  }
+`;
+
+export default ProjectAndroidMappings;

+ 9 - 2
src/sentry/static/sentry/app/views/settings/projectDebugFiles/index.tsx

@@ -55,7 +55,14 @@ class ProjectDebugSymbols extends AsyncView<Props, State> {
       [
       [
         'debugFiles',
         'debugFiles',
         `/projects/${orgId}/${projectId}/files/dsyms/`,
         `/projects/${orgId}/${projectId}/files/dsyms/`,
-        {query: {query: location.query.query}},
+        {
+          query: {
+            query: location.query.query,
+            file_formats: organization.features.includes('android-mappings')
+              ? ['breakpad', 'macho', 'elf', 'pe', 'pdb', 'sourcebundle']
+              : undefined,
+          },
+        },
       ],
       ],
     ];
     ];
 
 
@@ -205,7 +212,7 @@ class ProjectDebugSymbols extends AsyncView<Props, State> {
         <StyledPanelTable
         <StyledPanelTable
           headers={[
           headers={[
             t('Debug ID'),
             t('Debug ID'),
-            t('Name'),
+            t('Information'),
             <TextRight key="actions">{t('Actions')}</TextRight>,
             <TextRight key="actions">{t('Actions')}</TextRight>,
           ]}
           ]}
           emptyMessage={this.getEmptyMessage()}
           emptyMessage={this.getEmptyMessage()}