diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java index 2bde7cb61047b..a647d0ae4e3bb 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/Constants.java @@ -21,6 +21,7 @@ private Constants() {} public static final String LINEAGE_SCHEMA_FILE = "lineage.graphql"; public static final String PROPERTIES_SCHEMA_FILE = "properties.graphql"; public static final String FORMS_SCHEMA_FILE = "forms.graphql"; + public static final String INCIDENTS_SCHEMA_FILE = "incident.graphql"; public static final String BROWSE_PATH_DELIMITER = "/"; public static final String BROWSE_PATH_V2_DELIMITER = "␟"; public static final String VERSION_STAMP_FIELD_NAME = "versionStamp"; diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java index fb9d837d6640c..7b30005ce875b 100644 --- a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/GmsGraphQLEngine.java @@ -63,6 +63,7 @@ import com.linkedin.datahub.graphql.generated.GlossaryNode; import com.linkedin.datahub.graphql.generated.GlossaryTerm; import com.linkedin.datahub.graphql.generated.GlossaryTermAssociation; +import com.linkedin.datahub.graphql.generated.IncidentSource; import com.linkedin.datahub.graphql.generated.IngestionSource; import com.linkedin.datahub.graphql.generated.InstitutionalMemoryMetadata; import com.linkedin.datahub.graphql.generated.LineageRelationship; @@ -125,7 +126,6 @@ import com.linkedin.datahub.graphql.resolvers.dataproduct.DeleteDataProductResolver; import com.linkedin.datahub.graphql.resolvers.dataproduct.ListDataProductAssetsResolver; import com.linkedin.datahub.graphql.resolvers.dataproduct.UpdateDataProductResolver; -import com.linkedin.datahub.graphql.resolvers.dataset.DatasetHealthResolver; import com.linkedin.datahub.graphql.resolvers.dataset.DatasetStatsSummaryResolver; import com.linkedin.datahub.graphql.resolvers.dataset.DatasetUsageStatsResolver; import com.linkedin.datahub.graphql.resolvers.deprecation.UpdateDeprecationResolver; @@ -158,6 +158,10 @@ import com.linkedin.datahub.graphql.resolvers.group.ListGroupsResolver; import com.linkedin.datahub.graphql.resolvers.group.RemoveGroupMembersResolver; import com.linkedin.datahub.graphql.resolvers.group.RemoveGroupResolver; +import com.linkedin.datahub.graphql.resolvers.health.EntityHealthResolver; +import com.linkedin.datahub.graphql.resolvers.incident.EntityIncidentsResolver; +import com.linkedin.datahub.graphql.resolvers.incident.RaiseIncidentResolver; +import com.linkedin.datahub.graphql.resolvers.incident.UpdateIncidentStatusResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CancelIngestionExecutionRequestResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CreateIngestionExecutionRequestResolver; import com.linkedin.datahub.graphql.resolvers.ingest.execution.CreateTestConnectionRequestResolver; @@ -305,6 +309,7 @@ import com.linkedin.datahub.graphql.types.form.FormType; import com.linkedin.datahub.graphql.types.glossary.GlossaryNodeType; import com.linkedin.datahub.graphql.types.glossary.GlossaryTermType; +import com.linkedin.datahub.graphql.types.incident.IncidentType; import com.linkedin.datahub.graphql.types.mlmodel.MLFeatureTableType; import com.linkedin.datahub.graphql.types.mlmodel.MLFeatureType; import com.linkedin.datahub.graphql.types.mlmodel.MLModelGroupType; @@ -460,6 +465,7 @@ public class GmsGraphQLEngine { private final DataTypeType dataTypeType; private final EntityTypeType entityTypeType; private final FormType formType; + private final IncidentType incidentType; private final int graphQLQueryComplexityLimit; private final int graphQLQueryDepthLimit; @@ -567,6 +573,7 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { this.dataTypeType = new DataTypeType(entityClient); this.entityTypeType = new EntityTypeType(entityClient); this.formType = new FormType(entityClient); + this.incidentType = new IncidentType(entityClient); this.graphQLQueryComplexityLimit = args.graphQLQueryComplexityLimit; this.graphQLQueryDepthLimit = args.graphQLQueryDepthLimit; @@ -609,7 +616,8 @@ public GmsGraphQLEngine(final GmsGraphQLEngineArgs args) { structuredPropertyType, dataTypeType, entityTypeType, - formType); + formType, + incidentType); this.loadableTypes = new ArrayList<>(entityTypes); // Extend loadable types with types from the plugins // This allows us to offer search and browse capabilities out of the box for those types @@ -698,6 +706,7 @@ public void configureRuntimeWiring(final RuntimeWiring.Builder builder) { configurePluginResolvers(builder); configureStructuredPropertyResolvers(builder); configureFormResolvers(builder); + configureIncidentResolvers(builder); } private void configureOrganisationRoleResolvers(RuntimeWiring.Builder builder) { @@ -747,7 +756,8 @@ public GraphQLEngine.Builder builder() { .addSchema(fileBasedSchema(STEPS_SCHEMA_FILE)) .addSchema(fileBasedSchema(LINEAGE_SCHEMA_FILE)) .addSchema(fileBasedSchema(PROPERTIES_SCHEMA_FILE)) - .addSchema(fileBasedSchema(FORMS_SCHEMA_FILE)); + .addSchema(fileBasedSchema(FORMS_SCHEMA_FILE)) + .addSchema(fileBasedSchema(INCIDENTS_SCHEMA_FILE)); for (GmsGraphQLPlugin plugin : this.graphQLPlugins) { List pluginSchemaFiles = plugin.getSchemaFiles(); @@ -1202,7 +1212,11 @@ private void configureMutationResolvers(final RuntimeWiring.Builder builder) { "createDynamicFormAssignment", new CreateDynamicFormAssignmentResolver(this.formService)) .dataFetcher( - "verifyForm", new VerifyFormResolver(this.formService, this.groupService))); + "verifyForm", new VerifyFormResolver(this.formService, this.groupService)) + .dataFetcher("raiseIncident", new RaiseIncidentResolver(this.entityClient)) + .dataFetcher( + "updateIncidentStatus", + new UpdateIncidentStatusResolver(this.entityClient, this.entityService))); } private void configureGenericEntityResolvers(final RuntimeWiring.Builder builder) { @@ -1485,7 +1499,12 @@ private void configureDatasetResolvers(final RuntimeWiring.Builder builder) { .dataFetcher("usageStats", new DatasetUsageStatsResolver(this.usageClient)) .dataFetcher("statsSummary", new DatasetStatsSummaryResolver(this.usageClient)) .dataFetcher( - "health", new DatasetHealthResolver(graphClient, timeseriesAspectService)) + "health", + new EntityHealthResolver( + entityClient, + graphClient, + timeseriesAspectService, + new EntityHealthResolver.Config(true, true))) .dataFetcher("schemaMetadata", new AspectResolver()) .dataFetcher( "assertions", new EntityAssertionsResolver(entityClient, graphClient)) @@ -1834,7 +1853,14 @@ private void configureDashboardResolvers(final RuntimeWiring.Builder builder) { .dataFetcher( "statsSummary", new DashboardStatsSummaryResolver(timeseriesAspectService)) .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) - .dataFetcher("exists", new EntityExistsResolver(entityService))); + .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher( + "health", + new EntityHealthResolver( + entityClient, + graphClient, + timeseriesAspectService, + new EntityHealthResolver.Config(false, true)))); builder.type( "DashboardInfo", typeWiring -> @@ -1951,7 +1977,14 @@ private void configureChartResolvers(final RuntimeWiring.Builder builder) { .dataFetcher( "statsSummary", new ChartStatsSummaryResolver(this.timeseriesAspectService)) .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) - .dataFetcher("exists", new EntityExistsResolver(entityService))); + .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher( + "health", + new EntityHealthResolver( + entityClient, + graphClient, + timeseriesAspectService, + new EntityHealthResolver.Config(false, true)))); builder.type( "ChartInfo", typeWiring -> @@ -2056,7 +2089,14 @@ private void configureDataJobResolvers(final RuntimeWiring.Builder builder) { })) .dataFetcher("runs", new DataJobRunsResolver(entityClient)) .dataFetcher("privileges", new EntityPrivilegesResolver(entityClient)) - .dataFetcher("exists", new EntityExistsResolver(entityService))) + .dataFetcher("exists", new EntityExistsResolver(entityService)) + .dataFetcher( + "health", + new EntityHealthResolver( + entityClient, + graphClient, + timeseriesAspectService, + new EntityHealthResolver.Config(false, true)))) .type( "DataJobInputOutput", typeWiring -> @@ -2119,7 +2159,14 @@ private void configureDataFlowResolvers(final RuntimeWiring.Builder builder) { return dataFlow.getDataPlatformInstance() != null ? dataFlow.getDataPlatformInstance().getUrn() : null; - }))); + })) + .dataFetcher( + "health", + new EntityHealthResolver( + entityClient, + graphClient, + timeseriesAspectService, + new EntityHealthResolver.Config(false, true)))); } /** @@ -2660,4 +2707,35 @@ private void configureIngestionSourceResolvers(final RuntimeWiring.Builder build : null; }))); } + + private void configureIncidentResolvers(final RuntimeWiring.Builder builder) { + builder.type( + "Incident", + typeWiring -> + typeWiring.dataFetcher( + "relationships", new EntityRelationshipsResultResolver(graphClient))); + builder.type( + "IncidentSource", + typeWiring -> + typeWiring.dataFetcher( + "source", + new LoadableTypeResolver<>( + this.assertionType, + (env) -> { + final IncidentSource incidentSource = env.getSource(); + return incidentSource.getSource() != null + ? incidentSource.getSource().getUrn() + : null; + }))); + + // Add incidents attribute to all entities that support it + final List entitiesWithIncidents = + ImmutableList.of("Dataset", "DataJob", "DataFlow", "Dashboard", "Chart"); + for (String entity : entitiesWithIncidents) { + builder.type( + entity, + typeWiring -> + typeWiring.dataFetcher("incidents", new EntityIncidentsResolver(entityClient))); + } + } } diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/health/EntityHealthResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/health/EntityHealthResolver.java new file mode 100644 index 0000000000000..7958503556274 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/health/EntityHealthResolver.java @@ -0,0 +1,319 @@ +package com.linkedin.datahub.graphql.resolvers.health; + +import com.google.common.collect.ImmutableList; +import com.linkedin.common.EntityRelationships; +import com.linkedin.data.template.StringArray; +import com.linkedin.data.template.StringArrayArray; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.Health; +import com.linkedin.datahub.graphql.generated.HealthStatus; +import com.linkedin.datahub.graphql.generated.HealthStatusType; +import com.linkedin.datahub.graphql.generated.IncidentState; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.graph.GraphClient; +import com.linkedin.metadata.query.filter.Condition; +import com.linkedin.metadata.query.filter.ConjunctiveCriterion; +import com.linkedin.metadata.query.filter.ConjunctiveCriterionArray; +import com.linkedin.metadata.query.filter.Criterion; +import com.linkedin.metadata.query.filter.CriterionArray; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.query.filter.RelationshipDirection; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.utils.QueryUtils; +import com.linkedin.metadata.timeseries.TimeseriesAspectService; +import com.linkedin.r2.RemoteInvocationException; +import com.linkedin.timeseries.AggregationSpec; +import com.linkedin.timeseries.AggregationType; +import com.linkedin.timeseries.GenericTable; +import com.linkedin.timeseries.GroupingBucket; +import com.linkedin.timeseries.GroupingBucketType; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; +import javax.annotation.Nullable; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +/** + * Resolver for generating the health badge for an asset, which depends on + * + *

1. Assertions status - whether the asset has active assertions 2. Incidents status - whether + * the asset has active incidents + */ +@Slf4j +public class EntityHealthResolver implements DataFetcher>> { + private static final String ASSERTS_RELATIONSHIP_NAME = "Asserts"; + private static final String ASSERTION_RUN_EVENT_SUCCESS_TYPE = "SUCCESS"; + private static final String INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME = "entities.keyword"; + private static final String INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME = "state"; + + private final EntityClient _entityClient; + private final GraphClient _graphClient; + private final TimeseriesAspectService _timeseriesAspectService; + + private final Config _config; + + public EntityHealthResolver( + @Nonnull final EntityClient entityClient, + @Nonnull final GraphClient graphClient, + @Nonnull final TimeseriesAspectService timeseriesAspectService) { + this(entityClient, graphClient, timeseriesAspectService, new Config(true, true)); + } + + public EntityHealthResolver( + @Nonnull final EntityClient entityClient, + @Nonnull final GraphClient graphClient, + @Nonnull final TimeseriesAspectService timeseriesAspectService, + @Nonnull final Config config) { + _entityClient = entityClient; + _graphClient = graphClient; + _timeseriesAspectService = timeseriesAspectService; + _config = config; + } + + @Override + public CompletableFuture> get(final DataFetchingEnvironment environment) + throws Exception { + final Entity parent = environment.getSource(); + return CompletableFuture.supplyAsync( + () -> { + try { + final HealthStatuses statuses = + computeHealthStatusForAsset(parent.getUrn(), environment.getContext()); + return statuses.healths; + } catch (Exception e) { + throw new RuntimeException("Failed to resolve asset's health status.", e); + } + }); + } + + /** + * Computes the "resolved health status" for an asset by + * + *

- fetching active (non-deleted) assertions - fetching latest assertion run for each - + * checking whether any of the assertions latest runs are failing + */ + private HealthStatuses computeHealthStatusForAsset( + final String entityUrn, final QueryContext context) { + final List healthStatuses = new ArrayList<>(); + + if (_config.getIncidentsEnabled()) { + final Health incidentsHealth = computeIncidentsHealthForAsset(entityUrn, context); + if (incidentsHealth != null) { + healthStatuses.add(incidentsHealth); + } + } + + if (_config.getAssertionsEnabled()) { + final Health assertionsHealth = computeAssertionHealthForAsset(entityUrn, context); + if (assertionsHealth != null) { + healthStatuses.add(assertionsHealth); + } + } + + return new HealthStatuses(healthStatuses); + } + + /** + * Returns the resolved "incidents health", which is currently a static function of whether there + * are any active incidents open on an asset + * + * @param entityUrn the asset to compute health for + * @param context the query context + * @return an instance of {@link Health} for the entity, null if one cannot be computed. + */ + private Health computeIncidentsHealthForAsset( + final String entityUrn, final QueryContext context) { + try { + final Filter filter = buildIncidentsEntityFilter(entityUrn, IncidentState.ACTIVE.toString()); + final SearchResult searchResult = + _entityClient.filter( + Constants.INCIDENT_ENTITY_NAME, filter, null, 0, 1, context.getAuthentication()); + final Integer activeIncidentCount = searchResult.getNumEntities(); + if (activeIncidentCount > 0) { + // There are active incidents. + return new Health( + HealthStatusType.INCIDENTS, + HealthStatus.FAIL, + String.format( + "%s active incident%s", activeIncidentCount, activeIncidentCount > 1 ? "s" : ""), + ImmutableList.of("ACTIVE_INCIDENTS")); + } + // Report pass if there are no active incidents. + return new Health(HealthStatusType.INCIDENTS, HealthStatus.PASS, null, null); + } catch (RemoteInvocationException e) { + log.error("Failed to compute incident health status!", e); + return null; + } + } + + private Filter buildIncidentsEntityFilter(final String entityUrn, final String state) { + final Map criterionMap = new HashMap<>(); + criterionMap.put(INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, entityUrn); + criterionMap.put(INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME, state); + return QueryUtils.newFilter(criterionMap); + } + + /** + * TODO: Replace this with the assertions summary aspect. + * + *

Returns the resolved "assertions health", which is currently a static function of whether + * the most recent run of all asset assertions has succeeded. + * + * @param entityUrn the entity to compute health for + * @param context the query context + * @return an instance of {@link Health} for the asset, null if one cannot be computed. + */ + @Nullable + private Health computeAssertionHealthForAsset( + final String entityUrn, final QueryContext context) { + // Get active assertion urns + final EntityRelationships relationships = + _graphClient.getRelatedEntities( + entityUrn, + ImmutableList.of(ASSERTS_RELATIONSHIP_NAME), + RelationshipDirection.INCOMING, + 0, + 500, + context.getActorUrn()); + + if (relationships.getTotal() > 0) { + + // If there are assertions defined, then we should return a non-null health for this asset. + final Set activeAssertionUrns = + relationships.getRelationships().stream() + .map(relationship -> relationship.getEntity().toString()) + .collect(Collectors.toSet()); + + final GenericTable assertionRunResults = getAssertionRunsTable(entityUrn); + + if (!assertionRunResults.hasRows() || assertionRunResults.getRows().size() == 0) { + // No assertion run results found. Return empty health! + return null; + } + + final List failingAssertionUrns = + getFailingAssertionUrns(assertionRunResults, activeAssertionUrns); + + // Finally compute & return the health. + final Health health = new Health(); + health.setType(HealthStatusType.ASSERTIONS); + if (failingAssertionUrns.size() > 0) { + health.setStatus(HealthStatus.FAIL); + health.setMessage( + String.format( + "%s of %s assertions are failing", + failingAssertionUrns.size(), activeAssertionUrns.size())); + health.setCauses(failingAssertionUrns); + } else { + health.setStatus(HealthStatus.PASS); + health.setMessage("All assertions are passing"); + } + return health; + } + return null; + } + + private GenericTable getAssertionRunsTable(final String asserteeUrn) { + return _timeseriesAspectService.getAggregatedStats( + Constants.ASSERTION_ENTITY_NAME, + Constants.ASSERTION_RUN_EVENT_ASPECT_NAME, + createAssertionAggregationSpecs(), + createAssertionsFilter(asserteeUrn), + createAssertionGroupingBuckets()); + } + + private List getFailingAssertionUrns( + final GenericTable assertionRunsResult, final Set candidateAssertionUrns) { + // Create the buckets based on the result + return resultToFailedAssertionUrns(assertionRunsResult.getRows(), candidateAssertionUrns); + } + + private Filter createAssertionsFilter(final String datasetUrn) { + final Filter filter = new Filter(); + final ArrayList criteria = new ArrayList<>(); + + // Add filter for asserteeUrn == datasetUrn + Criterion datasetUrnCriterion = + new Criterion().setField("asserteeUrn").setCondition(Condition.EQUAL).setValue(datasetUrn); + criteria.add(datasetUrnCriterion); + + // Add filter for result == result + Criterion startTimeCriterion = + new Criterion() + .setField("status") + .setCondition(Condition.EQUAL) + .setValue(Constants.ASSERTION_RUN_EVENT_STATUS_COMPLETE); + criteria.add(startTimeCriterion); + + filter.setOr( + new ConjunctiveCriterionArray( + ImmutableList.of(new ConjunctiveCriterion().setAnd(new CriterionArray(criteria))))); + return filter; + } + + private AggregationSpec[] createAssertionAggregationSpecs() { + // Simply fetch the timestamp, result type for the assertion URN. + AggregationSpec resultTypeAggregation = + new AggregationSpec().setAggregationType(AggregationType.LATEST).setFieldPath("type"); + AggregationSpec timestampAggregation = + new AggregationSpec() + .setAggregationType(AggregationType.LATEST) + .setFieldPath("timestampMillis"); + return new AggregationSpec[] {resultTypeAggregation, timestampAggregation}; + } + + private GroupingBucket[] createAssertionGroupingBuckets() { + // String grouping bucket on "assertionUrn" + GroupingBucket assertionUrnBucket = new GroupingBucket(); + assertionUrnBucket.setKey("assertionUrn").setType(GroupingBucketType.STRING_GROUPING_BUCKET); + return new GroupingBucket[] {assertionUrnBucket}; + } + + private List resultToFailedAssertionUrns( + final StringArrayArray rows, final Set activeAssertionUrns) { + final List failedAssertionUrns = new ArrayList<>(); + for (StringArray row : rows) { + // Result structure should be assertionUrn, event.result.type, timestampMillis + if (row.size() != 3) { + throw new RuntimeException( + String.format( + "Failed to fetch assertion run events from Timeseries index! Expected row of size 3, found %s", + row.size())); + } + + final String assertionUrn = row.get(0); + final String resultType = row.get(1); + + // If assertion is "active" (not deleted) & is failing, then we report a degradation in + // health. + if (activeAssertionUrns.contains(assertionUrn) + && !ASSERTION_RUN_EVENT_SUCCESS_TYPE.equals(resultType)) { + failedAssertionUrns.add(assertionUrn); + } + } + return failedAssertionUrns; + } + + @Data + @AllArgsConstructor + public static class Config { + private Boolean assertionsEnabled; + private Boolean incidentsEnabled; + } + + @AllArgsConstructor + private static class HealthStatuses { + private final List healths; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java new file mode 100644 index 0000000000000..c797044d1b224 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolver.java @@ -0,0 +1,124 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityIncidentsResult; +import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.datahub.graphql.types.incident.IncidentMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.utils.QueryUtils; +import com.linkedin.r2.RemoteInvocationException; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.concurrent.CompletableFuture; +import java.util.stream.Collectors; + +/** GraphQL Resolver used for fetching the list of Assertions associated with an Entity. */ +public class EntityIncidentsResolver + implements DataFetcher> { + + static final String INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME = "entities.keyword"; + static final String INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME = "state"; + static final String CREATED_TIME_SEARCH_INDEX_FIELD_NAME = "created"; + + private final EntityClient _entityClient; + + public EntityIncidentsResolver(final EntityClient entityClient) { + _entityClient = entityClient; + } + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) { + return CompletableFuture.supplyAsync( + () -> { + final QueryContext context = environment.getContext(); + + final String entityUrn = ((Entity) environment.getSource()).getUrn(); + final Integer start = environment.getArgumentOrDefault("start", 0); + final Integer count = environment.getArgumentOrDefault("count", 20); + final Optional maybeState = Optional.ofNullable(environment.getArgument("state")); + + try { + // Step 1: Fetch set of incidents associated with the target entity from the Search + // Index! + // We use the search index so that we can easily sort by the last updated time. + final Filter filter = buildIncidentsEntityFilter(entityUrn, maybeState); + final SortCriterion sortCriterion = buildIncidentsSortCriterion(); + final SearchResult searchResult = + _entityClient.filter( + Constants.INCIDENT_ENTITY_NAME, + filter, + sortCriterion, + start, + count, + context.getAuthentication()); + + final List incidentUrns = + searchResult.getEntities().stream() + .map(SearchEntity::getEntity) + .collect(Collectors.toList()); + + // Step 2: Hydrate the incident entities + final Map entities = + _entityClient.batchGetV2( + Constants.INCIDENT_ENTITY_NAME, + new HashSet<>(incidentUrns), + null, + context.getAuthentication()); + + // Step 3: Map GMS incident model to GraphQL model + final List entityResult = new ArrayList<>(); + for (Urn urn : incidentUrns) { + entityResult.add(entities.getOrDefault(urn, null)); + } + final List incidents = + entityResult.stream() + .filter(Objects::nonNull) + .map(IncidentMapper::map) + .collect(Collectors.toList()); + + // Step 4: Package and return result + final EntityIncidentsResult result = new EntityIncidentsResult(); + result.setCount(searchResult.getPageSize()); + result.setStart(searchResult.getFrom()); + result.setTotal(searchResult.getNumEntities()); + result.setIncidents(incidents); + return result; + } catch (URISyntaxException | RemoteInvocationException e) { + throw new RuntimeException("Failed to retrieve incidents from GMS", e); + } + }); + } + + private Filter buildIncidentsEntityFilter( + final String entityUrn, final Optional maybeState) { + final Map criterionMap = new HashMap<>(); + criterionMap.put(INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, entityUrn); + maybeState.ifPresent( + incidentState -> criterionMap.put(INCIDENT_STATE_SEARCH_INDEX_FIELD_NAME, incidentState)); + return QueryUtils.newFilter(criterionMap); + } + + private SortCriterion buildIncidentsSortCriterion() { + final SortCriterion sortCriterion = new SortCriterion(); + sortCriterion.setField(CREATED_TIME_SEARCH_INDEX_FIELD_NAME); + sortCriterion.setOrder(SortOrder.DESCENDING); + return sortCriterion; + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java new file mode 100644 index 0000000000000..2314b3fab5b4a --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/RaiseIncidentResolver.java @@ -0,0 +1,129 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static com.linkedin.datahub.graphql.resolvers.AuthUtils.*; +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; +import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; +import static com.linkedin.metadata.Constants.*; + +import com.datahub.authorization.ConjunctivePrivilegeGroup; +import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.SetMode; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.generated.RaiseIncidentInput; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentSource; +import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.incident.IncidentType; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.key.IncidentKey; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.net.URISyntaxException; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +/** Resolver used for creating (raising) a new asset incident. */ +@Slf4j +@RequiredArgsConstructor +public class RaiseIncidentResolver implements DataFetcher> { + + private final EntityClient _entityClient; + + @Override + public CompletableFuture get(DataFetchingEnvironment environment) throws Exception { + + final QueryContext context = environment.getContext(); + final RaiseIncidentInput input = + bindArgument(environment.getArgument("input"), RaiseIncidentInput.class); + final Urn resourceUrn = Urn.createFromString(input.getResourceUrn()); + + return CompletableFuture.supplyAsync( + () -> { + if (!isAuthorizedToCreateIncidentForResource(resourceUrn, context)) { + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + + try { + // Create the Domain Key + final IncidentKey key = new IncidentKey(); + + // Generate a random UUID for the incident + final String id = UUID.randomUUID().toString(); + key.setId(id); + + // Create the MCP + final MetadataChangeProposal proposal = + buildMetadataChangeProposalWithKey( + key, + INCIDENT_ENTITY_NAME, + INCIDENT_INFO_ASPECT_NAME, + mapIncidentInfo(input, context)); + return _entityClient.ingestProposal(proposal, context.getAuthentication(), false); + } catch (Exception e) { + log.error("Failed to create incident. {}", e.getMessage()); + throw new RuntimeException("Failed to incident", e); + } + }); + } + + private IncidentInfo mapIncidentInfo(final RaiseIncidentInput input, final QueryContext context) + throws URISyntaxException { + final IncidentInfo result = new IncidentInfo(); + result.setType( + IncidentType.valueOf( + input + .getType() + .name())); // Assumption Alert: This assumes that GMS incident type === GraphQL + // incident type. + result.setCustomType(input.getCustomType(), SetMode.IGNORE_NULL); + result.setTitle(input.getTitle(), SetMode.IGNORE_NULL); + result.setDescription(input.getDescription(), SetMode.IGNORE_NULL); + result.setEntities( + new UrnArray(ImmutableList.of(Urn.createFromString(input.getResourceUrn())))); + result.setCreated( + new AuditStamp() + .setActor(Urn.createFromString(context.getActorUrn())) + .setTime(System.currentTimeMillis())); + // Create the incident in the 'active' state by default. + result.setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setLastUpdated( + new AuditStamp() + .setActor(Urn.createFromString(context.getActorUrn())) + .setTime(System.currentTimeMillis()))); + result.setSource(new IncidentSource().setType(IncidentSourceType.MANUAL), SetMode.IGNORE_NULL); + result.setPriority(input.getPriority(), SetMode.IGNORE_NULL); + return result; + } + + private boolean isAuthorizedToCreateIncidentForResource( + final Urn resourceUrn, final QueryContext context) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + ALL_PRIVILEGES_GROUP, + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType())))); + + return AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getActorUrn(), + resourceUrn.getEntityType(), + resourceUrn.toString(), + orPrivilegeGroups); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java new file mode 100644 index 0000000000000..c9d3c23021d38 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/resolvers/incident/UpdateIncidentStatusResolver.java @@ -0,0 +1,105 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static com.linkedin.datahub.graphql.resolvers.ResolverUtils.*; +import static com.linkedin.datahub.graphql.resolvers.mutate.MutationUtils.*; +import static com.linkedin.metadata.Constants.*; + +import com.datahub.authorization.ConjunctivePrivilegeGroup; +import com.datahub.authorization.DisjunctivePrivilegeGroup; +import com.google.common.collect.ImmutableList; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.urn.Urn; +import com.linkedin.common.urn.UrnUtils; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.authorization.AuthorizationUtils; +import com.linkedin.datahub.graphql.exception.AuthorizationException; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLErrorCode; +import com.linkedin.datahub.graphql.exception.DataHubGraphQLException; +import com.linkedin.datahub.graphql.generated.UpdateIncidentStatusInput; +import com.linkedin.datahub.graphql.resolvers.AuthUtils; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.metadata.authorization.PoliciesConfig; +import com.linkedin.metadata.entity.EntityService; +import com.linkedin.metadata.entity.EntityUtils; +import com.linkedin.mxe.MetadataChangeProposal; +import graphql.schema.DataFetcher; +import graphql.schema.DataFetchingEnvironment; +import java.util.concurrent.CompletableFuture; +import lombok.RequiredArgsConstructor; + +/** GraphQL Resolver that updates an incident's status */ +@RequiredArgsConstructor +public class UpdateIncidentStatusResolver implements DataFetcher> { + + private final EntityClient _entityClient; + private final EntityService _entityService; + + @Override + public CompletableFuture get(final DataFetchingEnvironment environment) + throws Exception { + final QueryContext context = environment.getContext(); + final Urn incidentUrn = Urn.createFromString(environment.getArgument("urn")); + final UpdateIncidentStatusInput input = + bindArgument(environment.getArgument("input"), UpdateIncidentStatusInput.class); + return CompletableFuture.supplyAsync( + () -> { + + // Check whether the incident exists. + IncidentInfo info = + (IncidentInfo) + EntityUtils.getAspectFromEntity( + incidentUrn.toString(), INCIDENT_INFO_ASPECT_NAME, _entityService, null); + + if (info != null) { + // Check whether the actor has permission to edit the incident + // Currently only supporting a single entity. TODO: Support multiple incident entities. + final Urn resourceUrn = info.getEntities().get(0); + if (isAuthorizedToUpdateIncident(resourceUrn, context)) { + info.setStatus( + new IncidentStatus() + .setState(IncidentState.valueOf(input.getState().name())) + .setLastUpdated( + new AuditStamp() + .setActor(UrnUtils.getUrn(context.getActorUrn())) + .setTime(System.currentTimeMillis()))); + if (input.getMessage() != null) { + info.getStatus().setMessage(input.getMessage()); + } + try { + // Finally, create the MetadataChangeProposal. + final MetadataChangeProposal proposal = + buildMetadataChangeProposalWithUrn( + incidentUrn, INCIDENT_INFO_ASPECT_NAME, info); + _entityClient.ingestProposal(proposal, context.getAuthentication(), false); + return true; + } catch (Exception e) { + throw new RuntimeException("Failed to update incident status!", e); + } + } + throw new AuthorizationException( + "Unauthorized to perform this action. Please contact your DataHub administrator."); + } + throw new DataHubGraphQLException( + "Failed to update incident. Incident does not exist.", + DataHubGraphQLErrorCode.NOT_FOUND); + }); + } + + private boolean isAuthorizedToUpdateIncident(final Urn resourceUrn, final QueryContext context) { + final DisjunctivePrivilegeGroup orPrivilegeGroups = + new DisjunctivePrivilegeGroup( + ImmutableList.of( + AuthUtils.ALL_PRIVILEGES_GROUP, + new ConjunctivePrivilegeGroup( + ImmutableList.of(PoliciesConfig.EDIT_ENTITY_INCIDENTS_PRIVILEGE.getType())))); + return AuthorizationUtils.isAuthorized( + context.getAuthorizer(), + context.getActorUrn(), + resourceUrn.getEntityType(), + resourceUrn.toString(), + orPrivilegeGroups); + } +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java new file mode 100644 index 0000000000000..f3824f3237617 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentMapper.java @@ -0,0 +1,74 @@ +package com.linkedin.datahub.graphql.types.incident; + +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.GetMode; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.datahub.graphql.generated.IncidentSource; +import com.linkedin.datahub.graphql.generated.IncidentSourceType; +import com.linkedin.datahub.graphql.generated.IncidentState; +import com.linkedin.datahub.graphql.generated.IncidentStatus; +import com.linkedin.datahub.graphql.generated.IncidentType; +import com.linkedin.datahub.graphql.types.common.mappers.AuditStampMapper; +import com.linkedin.datahub.graphql.types.common.mappers.UrnToEntityMapper; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.metadata.Constants; + +/** Maps a GMS {@link EntityResponse} to a GraphQL incident. */ +public class IncidentMapper { + + public static Incident map(final EntityResponse entityResponse) { + final Incident result = new Incident(); + final Urn entityUrn = entityResponse.getUrn(); + final EnvelopedAspectMap aspects = entityResponse.getAspects(); + result.setType(EntityType.INCIDENT); + result.setUrn(entityUrn.toString()); + + final EnvelopedAspect envelopedIncidentInfo = aspects.get(Constants.INCIDENT_INFO_ASPECT_NAME); + if (envelopedIncidentInfo != null) { + final IncidentInfo info = new IncidentInfo(envelopedIncidentInfo.getValue().data()); + // Assumption alert! This assumes the incident type in GMS exactly equals that in GraphQL + result.setIncidentType(IncidentType.valueOf(info.getType().name())); + result.setCustomType(info.getCustomType(GetMode.NULL)); + result.setTitle(info.getTitle(GetMode.NULL)); + result.setDescription(info.getDescription(GetMode.NULL)); + result.setPriority(info.getPriority(GetMode.NULL)); + // TODO: Support multiple entities per incident. + result.setEntity(UrnToEntityMapper.map(info.getEntities().get(0))); + if (info.hasSource()) { + result.setSource(mapIncidentSource(info.getSource())); + } + if (info.hasStatus()) { + result.setStatus(mapStatus(info.getStatus())); + } + result.setCreated(AuditStampMapper.map(info.getCreated())); + } else { + throw new RuntimeException(String.format("Incident does not exist!. urn: %s", entityUrn)); + } + return result; + } + + private static IncidentStatus mapStatus( + final com.linkedin.incident.IncidentStatus incidentStatus) { + final IncidentStatus result = new IncidentStatus(); + result.setState(IncidentState.valueOf(incidentStatus.getState().name())); + result.setMessage(incidentStatus.getMessage(GetMode.NULL)); + result.setLastUpdated(AuditStampMapper.map(incidentStatus.getLastUpdated())); + return result; + } + + private static IncidentSource mapIncidentSource( + final com.linkedin.incident.IncidentSource incidentSource) { + final IncidentSource result = new IncidentSource(); + result.setType(IncidentSourceType.valueOf(incidentSource.getType().name())); + if (incidentSource.hasSourceUrn()) { + result.setSource(UrnToEntityMapper.map(incidentSource.getSourceUrn())); + } + return result; + } + + private IncidentMapper() {} +} diff --git a/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentType.java b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentType.java new file mode 100644 index 0000000000000..2e62bf5a0c345 --- /dev/null +++ b/datahub-graphql-core/src/main/java/com/linkedin/datahub/graphql/types/incident/IncidentType.java @@ -0,0 +1,86 @@ +package com.linkedin.datahub.graphql.types.incident; + +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Entity; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.metadata.Constants; +import graphql.execution.DataFetcherResult; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import javax.annotation.Nonnull; + +public class IncidentType + implements com.linkedin.datahub.graphql.types.EntityType { + + static final Set ASPECTS_TO_FETCH = ImmutableSet.of(Constants.INCIDENT_INFO_ASPECT_NAME); + private final EntityClient _entityClient; + + public IncidentType(final EntityClient entityClient) { + _entityClient = entityClient; + } + + @Override + public EntityType type() { + return EntityType.INCIDENT; + } + + @Override + public Function getKeyProvider() { + return Entity::getUrn; + } + + @Override + public Class objectClass() { + return Incident.class; + } + + @Override + public List> batchLoad( + @Nonnull List urns, @Nonnull QueryContext context) throws Exception { + final List incidentUrns = urns.stream().map(this::getUrn).collect(Collectors.toList()); + + try { + final Map entities = + _entityClient.batchGetV2( + Constants.INCIDENT_ENTITY_NAME, + new HashSet<>(incidentUrns), + ASPECTS_TO_FETCH, + context.getAuthentication()); + + final List gmsResults = new ArrayList<>(); + for (Urn urn : incidentUrns) { + gmsResults.add(entities.getOrDefault(urn, null)); + } + return gmsResults.stream() + .map( + gmsResult -> + gmsResult == null + ? null + : DataFetcherResult.newResult() + .data(IncidentMapper.map(gmsResult)) + .build()) + .collect(Collectors.toList()); + } catch (Exception e) { + throw new RuntimeException("Failed to batch load Incidents", e); + } + } + + private Urn getUrn(final String urnStr) { + try { + return Urn.createFromString(urnStr); + } catch (URISyntaxException e) { + throw new RuntimeException(String.format("Failed to convert urn string %s into Urn", urnStr)); + } + } +} diff --git a/datahub-graphql-core/src/main/resources/entity.graphql b/datahub-graphql-core/src/main/resources/entity.graphql index fcd7bd2df6b34..a50f1be7cbead 100644 --- a/datahub-graphql-core/src/main/resources/entity.graphql +++ b/datahub-graphql-core/src/main/resources/entity.graphql @@ -931,6 +931,11 @@ enum EntityType { """ CUSTOM_OWNERSHIP_TYPE + """ + A DataHub incident - SaaS only + """ + INCIDENT + """" A Role from an organisation """ @@ -1697,7 +1702,7 @@ type VersionedDataset implements Entity { domain: DomainAssociation """ - Experimental! The resolved health status of the Dataset + Experimental! The resolved health status of the asset """ health: [Health!] @@ -5161,6 +5166,11 @@ type Dashboard implements EntityWithRelationships & Entity & BrowsableEntity { """ structuredProperties: StructuredProperties + """ + Experimental! The resolved health statuses of the asset + """ + health: [Health!] + """ The forms associated with the Dataset """ @@ -5487,6 +5497,11 @@ type Chart implements EntityWithRelationships & Entity & BrowsableEntity { """ structuredProperties: StructuredProperties + """ + Experimental! The resolved health statuses of the asset + """ + health: [Health!] + """ The forms associated with the Dataset """ @@ -5860,6 +5875,11 @@ type DataFlow implements EntityWithRelationships & Entity & BrowsableEntity { """ structuredProperties: StructuredProperties + """ + Experimental! The resolved health statuses of the asset + """ + health: [Health!] + """ The forms associated with the Dataset """ @@ -6076,6 +6096,11 @@ type DataJob implements EntityWithRelationships & Entity & BrowsableEntity { """ structuredProperties: StructuredProperties + """ + Experimental! The resolved health statuses of the asset + """ + health: [Health!] + """ The forms associated with the Dataset """ @@ -10290,6 +10315,11 @@ enum HealthStatusType { Assertions status """ ASSERTIONS + + """ + Incidents status + """ + INCIDENTS } """ diff --git a/datahub-graphql-core/src/main/resources/incident.graphql b/datahub-graphql-core/src/main/resources/incident.graphql new file mode 100644 index 0000000000000..c3f4f35be608d --- /dev/null +++ b/datahub-graphql-core/src/main/resources/incident.graphql @@ -0,0 +1,340 @@ +extend type Mutation { + """ + Create a new incident for a resource (asset) + """ + raiseIncident( + """ + Input required to create a new incident + """ + input: RaiseIncidentInput!): String + + """ + Update an existing incident for a resource (asset) + """ + updateIncidentStatus( + """ + The urn for an existing incident + """ + urn: String! + + """ + Input required to update the state of an existing incident + """ + input: UpdateIncidentStatusInput!): Boolean +} + +""" +A list of Incidents Associated with an Entity +""" +type EntityIncidentsResult { + """ + The starting offset of the result set returned + """ + start: Int! + + """ + The number of assertions in the returned result set + """ + count: Int! + + """ + The total number of assertions in the result set + """ + total: Int! + + """ + The incidents themselves + """ + incidents: [Incident!]! +} + +""" +An incident represents an active issue on a data asset. +""" +type Incident implements Entity { + """ + The primary key of the Incident + """ + urn: String! + + """ + The standard Entity Type + """ + type: EntityType! + + """ + The type of incident + """ + incidentType: IncidentType! + + """ + A custom type of incident. Present only if type is 'CUSTOM' + """ + customType: String + + """ + An optional title associated with the incident + """ + title: String + + """ + An optional description associated with the incident + """ + description: String + + """ + The status of an incident + """ + status: IncidentStatus! + + """ + Optional priority of the incident. Lower value indicates higher priority. + """ + priority: Int + + """ + The entity that the incident is associated with. + """ + entity: Entity! + + """ + The source of the incident, i.e. how it was generated + """ + source: IncidentSource + + """ + The time at which the incident was initially created + """ + created: AuditStamp! + + """ + List of relationships between the source Entity and some destination entities with a given types + """ + relationships(input: RelationshipsInput!): EntityRelationshipsResult +} + +""" +The state of an incident. +""" +enum IncidentState { + """ + The incident is ongoing, or active. + """ + ACTIVE + """ + The incident is resolved. + """ + RESOLVED +} + +""" +A specific type of incident +""" +enum IncidentType { + """ + An operational incident, e.g. failure to materialize a dataset, or failure to execute a task / pipeline. + """ + OPERATIONAL + + """ + A custom type of incident + """ + CUSTOM +} + + +""" +Details about the status of an asset incident +""" +type IncidentStatus { + """ + The state of the incident + """ + state: IncidentState! + """ + An optional message associated with the status + """ + message: String + """ + The time that the status last changed + """ + lastUpdated: AuditStamp! +} + +""" +The source type of an incident, implying how it was created. +""" +enum IncidentSourceType { + """ + The incident was created manually, from either the API or the UI. + """ + MANUAL +} + +""" +Details about the source of an incident, e.g. how it was created. +""" +type IncidentSource { + """ + The type of the incident source + """ + type: IncidentSourceType! + + """ + The source of the incident. If the source type is ASSERTION_FAILURE, this will have the assertion that generated the incident. + """ + source: Entity +} + +""" +Input required to create a new incident in the 'Active' state. +""" +input RaiseIncidentInput { + """ + The type of incident + """ + type: IncidentType! + """ + A custom type of incident. Present only if type is 'CUSTOM' + """ + customType: String + """ + An optional title associated with the incident + """ + title: String + """ + An optional description associated with the incident + """ + description: String + """ + The resource (dataset, dashboard, chart, dataFlow, etc) that the incident is associated with. + """ + resourceUrn: String! + """ + The source of the incident, i.e. how it was generated + """ + source: IncidentSourceInput + """ + An optional priority for the incident. Lower value indicates a higher priority. + """ + priority: Int +} + +""" +Input required to create an incident source +""" +input IncidentSourceInput { + """ + The type of the incident source + """ + type: IncidentSourceType! +} + +""" +Input required to update status of an existing incident +""" +input UpdateIncidentStatusInput { + """ + The new state of the incident + """ + state: IncidentState! + """ + An optional message associated with the new state + """ + message: String +} + +extend type Dataset { + """ + Incidents associated with the Dataset + """ + incidents( + """ + Optional incident state to filter by, defaults to any state. + """ + state: IncidentState, + """ + Optional start offset, defaults to 0. + """ + start: Int, + """ + Optional start offset, defaults to 20. + """ + count: Int): EntityIncidentsResult +} + +extend type DataJob { + """ + Incidents associated with the DataJob + """ + incidents( + """ + Optional incident state to filter by, defaults to any state. + """ + state: IncidentState, + """ + Optional start offset, defaults to 0. + """ + start: Int, + """ + Optional start offset, defaults to 20. + """ + count: Int): EntityIncidentsResult +} + +extend type DataFlow { + """ + Incidents associated with the DataFlow + """ + incidents( + """ + Optional incident state to filter by, defaults to any state. + """ + state: IncidentState, + """ + Optional start offset, defaults to 0. + """ + start: Int, + """ + Optional start offset, defaults to 20. + """ + count: Int): EntityIncidentsResult +} + +extend type Dashboard { + """ + Incidents associated with the Dashboard + """ + incidents( + """ + Optional incident state to filter by, defaults to any state. + """ + state: IncidentState, + """ + Optional start offset, defaults to 0. + """ + start: Int, + """ + Optional start offset, defaults to 20. + """ + count: Int): EntityIncidentsResult +} + +extend type Chart { + """ + Incidents associated with the Chart + """ + incidents( + """ + Optional incident state to filter by, defaults to any state. + """ + state: IncidentState, + """ + Optional start offset, defaults to 0. + """ + start: Int, + """ + Optional start offset, defaults to 20. + """ + count: Int): EntityIncidentsResult +} \ No newline at end of file diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/dataset/DatasetHealthResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/health/EntityHealthResolverTest.java similarity index 97% rename from datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/dataset/DatasetHealthResolverTest.java rename to datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/health/EntityHealthResolverTest.java index 3ff0120448e54..2129821e0d95f 100644 --- a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/dataset/DatasetHealthResolverTest.java +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/health/EntityHealthResolverTest.java @@ -1,4 +1,4 @@ -package com.linkedin.datahub.graphql.resolvers.dataset; +package com.linkedin.datahub.graphql.resolvers.health; import static org.testng.Assert.*; @@ -14,6 +14,7 @@ import com.linkedin.datahub.graphql.generated.Dataset; import com.linkedin.datahub.graphql.generated.Health; import com.linkedin.datahub.graphql.generated.HealthStatus; +import com.linkedin.datahub.graphql.resolvers.dataset.DatasetHealthResolver; import com.linkedin.metadata.Constants; import com.linkedin.metadata.graph.GraphClient; import com.linkedin.metadata.query.filter.RelationshipDirection; @@ -25,7 +26,8 @@ import org.mockito.Mockito; import org.testng.annotations.Test; -public class DatasetHealthResolverTest { +// TODO: Update this test once assertions summary has been added. +public class EntityHealthResolverTest { private static final String TEST_DATASET_URN = "urn:li:dataset:(test,test,test)"; private static final String TEST_ASSERTION_URN = "urn:li:assertion:test-guid"; diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java new file mode 100644 index 0000000000000..a3f4b508dfc3e --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/resolvers/incident/EntityIncidentsResolverTest.java @@ -0,0 +1,165 @@ +package com.linkedin.datahub.graphql.resolvers.incident; + +import static com.linkedin.datahub.graphql.resolvers.incident.EntityIncidentsResolver.*; +import static org.testng.Assert.*; + +import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.Dataset; +import com.linkedin.datahub.graphql.generated.EntityIncidentsResult; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentSource; +import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.incident.IncidentType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.key.IncidentKey; +import com.linkedin.metadata.query.filter.Filter; +import com.linkedin.metadata.query.filter.SortCriterion; +import com.linkedin.metadata.query.filter.SortOrder; +import com.linkedin.metadata.search.SearchEntity; +import com.linkedin.metadata.search.SearchEntityArray; +import com.linkedin.metadata.search.SearchResult; +import com.linkedin.metadata.search.utils.QueryUtils; +import graphql.schema.DataFetchingEnvironment; +import java.util.HashMap; +import java.util.Map; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class EntityIncidentsResolverTest { + @Test + public void testGetSuccess() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + + Urn assertionUrn = Urn.createFromString("urn:li:assertion:test"); + Urn userUrn = Urn.createFromString("urn:li:corpuser:test"); + Urn datasetUrn = Urn.createFromString("urn:li:dataset:(test,test,test)"); + Urn incidentUrn = Urn.createFromString("urn:li:incident:test-guid"); + + Map incidentAspects = new HashMap<>(); + incidentAspects.put( + Constants.INCIDENT_KEY_ASPECT_NAME, + new com.linkedin.entity.EnvelopedAspect() + .setValue(new Aspect(new IncidentKey().setId("test-guid").data()))); + + IncidentInfo expectedInfo = + new IncidentInfo() + .setType(IncidentType.OPERATIONAL) + .setCustomType("Custom Type") + .setDescription("Description") + .setPriority(5) + .setTitle("Title") + .setEntities(new UrnArray(ImmutableList.of(datasetUrn))) + .setSource( + new IncidentSource().setType(IncidentSourceType.MANUAL).setSourceUrn(assertionUrn)) + .setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setMessage("Message") + .setLastUpdated(new AuditStamp().setTime(1L).setActor(userUrn))) + .setCreated(new AuditStamp().setTime(0L).setActor(userUrn)); + + incidentAspects.put( + Constants.INCIDENT_INFO_ASPECT_NAME, + new com.linkedin.entity.EnvelopedAspect().setValue(new Aspect(expectedInfo.data()))); + + final Map criterionMap = new HashMap<>(); + criterionMap.put(INCIDENT_ENTITIES_SEARCH_INDEX_FIELD_NAME, datasetUrn.toString()); + Filter expectedFilter = QueryUtils.newFilter(criterionMap); + + SortCriterion expectedSort = new SortCriterion(); + expectedSort.setField(CREATED_TIME_SEARCH_INDEX_FIELD_NAME); + expectedSort.setOrder(SortOrder.DESCENDING); + + Mockito.when( + mockClient.filter( + Mockito.eq(Constants.INCIDENT_ENTITY_NAME), + Mockito.eq(expectedFilter), + Mockito.eq(expectedSort), + Mockito.eq(0), + Mockito.eq(10), + Mockito.any(Authentication.class))) + .thenReturn( + new SearchResult() + .setFrom(0) + .setPageSize(1) + .setNumEntities(1) + .setEntities( + new SearchEntityArray( + ImmutableSet.of(new SearchEntity().setEntity(incidentUrn))))); + + Mockito.when( + mockClient.batchGetV2( + Mockito.eq(Constants.INCIDENT_ENTITY_NAME), + Mockito.eq(ImmutableSet.of(incidentUrn)), + Mockito.eq(null), + Mockito.any(Authentication.class))) + .thenReturn( + ImmutableMap.of( + incidentUrn, + new EntityResponse() + .setEntityName(Constants.INCIDENT_ENTITY_NAME) + .setUrn(incidentUrn) + .setAspects(new EnvelopedAspectMap(incidentAspects)))); + + EntityIncidentsResolver resolver = new EntityIncidentsResolver(mockClient); + + // Execute resolver + QueryContext mockContext = Mockito.mock(QueryContext.class); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + DataFetchingEnvironment mockEnv = Mockito.mock(DataFetchingEnvironment.class); + + Mockito.when(mockEnv.getArgumentOrDefault(Mockito.eq("start"), Mockito.eq(0))).thenReturn(0); + Mockito.when(mockEnv.getArgumentOrDefault(Mockito.eq("count"), Mockito.eq(20))).thenReturn(10); + Mockito.when(mockEnv.getContext()).thenReturn(mockContext); + + Dataset parentEntity = new Dataset(); + parentEntity.setUrn(datasetUrn.toString()); + Mockito.when(mockEnv.getSource()).thenReturn(parentEntity); + + EntityIncidentsResult result = resolver.get(mockEnv).get(); + + // Assert that GraphQL Incident run event matches expectations + assertEquals(result.getStart(), 0); + assertEquals(result.getCount(), 1); + assertEquals(result.getTotal(), 1); + + com.linkedin.datahub.graphql.generated.Incident incident = + resolver.get(mockEnv).get().getIncidents().get(0); + assertEquals(incident.getUrn(), incidentUrn.toString()); + assertEquals(incident.getType(), EntityType.INCIDENT); + assertEquals(incident.getIncidentType().toString(), expectedInfo.getType().toString()); + assertEquals(incident.getTitle(), expectedInfo.getTitle()); + assertEquals(incident.getDescription(), expectedInfo.getDescription()); + assertEquals(incident.getCustomType(), expectedInfo.getCustomType()); + assertEquals( + incident.getStatus().getState().toString(), expectedInfo.getStatus().getState().toString()); + assertEquals(incident.getStatus().getMessage(), expectedInfo.getStatus().getMessage()); + assertEquals( + incident.getStatus().getLastUpdated().getTime(), + expectedInfo.getStatus().getLastUpdated().getTime()); + assertEquals( + incident.getStatus().getLastUpdated().getActor(), + expectedInfo.getStatus().getLastUpdated().getActor().toString()); + assertEquals( + incident.getSource().getType().toString(), expectedInfo.getSource().getType().toString()); + assertEquals( + incident.getSource().getSource().getUrn(), + expectedInfo.getSource().getSourceUrn().toString()); + assertEquals(incident.getCreated().getActor(), expectedInfo.getCreated().getActor().toString()); + assertEquals(incident.getCreated().getTime(), expectedInfo.getCreated().getTime()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java new file mode 100644 index 0000000000000..d637f873533ef --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentMapperTest.java @@ -0,0 +1,96 @@ +package com.linkedin.datahub.graphql.types.incident; + +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNotNull; + +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.data.template.SetMode; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentSource; +import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.incident.IncidentType; +import com.linkedin.metadata.Constants; +import java.util.Collections; +import org.testng.annotations.Test; + +public class IncidentMapperTest { + + @Test + public void testMap() throws Exception { + EntityResponse entityResponse = new EntityResponse(); + Urn urn = Urn.createFromString("urn:li:incident:1"); + Urn userUrn = Urn.createFromString("urn:li:corpuser:test"); + Urn assertionUrn = Urn.createFromString("urn:li:assertion:test"); + entityResponse.setUrn(urn); + + EnvelopedAspect envelopedIncidentInfo = new EnvelopedAspect(); + IncidentInfo incidentInfo = new IncidentInfo(); + incidentInfo.setType(IncidentType.OPERATIONAL); + incidentInfo.setCustomType("Custom Type"); + incidentInfo.setTitle("Test Incident", SetMode.IGNORE_NULL); + incidentInfo.setDescription("This is a test incident", SetMode.IGNORE_NULL); + incidentInfo.setPriority(1, SetMode.IGNORE_NULL); + incidentInfo.setEntities(new UrnArray(Collections.singletonList(urn))); + + IncidentSource source = new IncidentSource(); + source.setType(IncidentSourceType.MANUAL); + source.setSourceUrn(assertionUrn); + incidentInfo.setSource(source); + + AuditStamp lastStatus = new AuditStamp(); + lastStatus.setTime(1000L); + lastStatus.setActor(userUrn); + incidentInfo.setCreated(lastStatus); + + IncidentStatus status = new IncidentStatus(); + status.setState(IncidentState.ACTIVE); + status.setLastUpdated(lastStatus); + status.setMessage("This incident is open.", SetMode.IGNORE_NULL); + incidentInfo.setStatus(status); + + AuditStamp created = new AuditStamp(); + created.setTime(1000L); + created.setActor(userUrn); + incidentInfo.setCreated(created); + + envelopedIncidentInfo.setValue(new Aspect(incidentInfo.data())); + entityResponse.setAspects( + new EnvelopedAspectMap( + Collections.singletonMap(Constants.INCIDENT_INFO_ASPECT_NAME, envelopedIncidentInfo))); + + Incident incident = IncidentMapper.map(entityResponse); + + assertNotNull(incident); + assertEquals(incident.getUrn(), "urn:li:incident:1"); + assertEquals(incident.getType(), EntityType.INCIDENT); + assertEquals(incident.getCustomType(), "Custom Type"); + assertEquals( + incident.getIncidentType().toString(), + com.linkedin.datahub.graphql.generated.IncidentType.OPERATIONAL.toString()); + assertEquals(incident.getTitle(), "Test Incident"); + assertEquals(incident.getDescription(), "This is a test incident"); + assertEquals(incident.getPriority().intValue(), 1); + assertEquals( + incident.getSource().getType().toString(), + com.linkedin.datahub.graphql.generated.IncidentSourceType.MANUAL.toString()); + assertEquals(incident.getSource().getSource().getUrn(), assertionUrn.toString()); + assertEquals( + incident.getStatus().getState().toString(), + com.linkedin.datahub.graphql.generated.IncidentState.ACTIVE.toString()); + assertEquals(incident.getStatus().getMessage(), "This incident is open."); + assertEquals(incident.getStatus().getLastUpdated().getTime().longValue(), 1000L); + assertEquals(incident.getStatus().getLastUpdated().getActor(), userUrn.toString()); + assertEquals(incident.getCreated().getTime().longValue(), 1000L); + assertEquals(incident.getCreated().getActor(), userUrn.toString()); + } +} diff --git a/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentTypeTest.java b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentTypeTest.java new file mode 100644 index 0000000000000..ad787f29e8b2a --- /dev/null +++ b/datahub-graphql-core/src/test/java/com/linkedin/datahub/graphql/types/incident/IncidentTypeTest.java @@ -0,0 +1,174 @@ +package com.linkedin.datahub.graphql.types.incident; + +import static org.testng.Assert.*; + +import com.datahub.authentication.Authentication; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.collect.ImmutableSet; +import com.linkedin.common.AuditStamp; +import com.linkedin.common.UrnArray; +import com.linkedin.common.urn.Urn; +import com.linkedin.datahub.graphql.QueryContext; +import com.linkedin.datahub.graphql.generated.EntityType; +import com.linkedin.datahub.graphql.generated.Incident; +import com.linkedin.entity.Aspect; +import com.linkedin.entity.EntityResponse; +import com.linkedin.entity.EnvelopedAspect; +import com.linkedin.entity.EnvelopedAspectMap; +import com.linkedin.entity.client.EntityClient; +import com.linkedin.incident.IncidentInfo; +import com.linkedin.incident.IncidentSource; +import com.linkedin.incident.IncidentSourceType; +import com.linkedin.incident.IncidentState; +import com.linkedin.incident.IncidentStatus; +import com.linkedin.incident.IncidentType; +import com.linkedin.metadata.Constants; +import com.linkedin.metadata.key.IncidentKey; +import com.linkedin.r2.RemoteInvocationException; +import graphql.execution.DataFetcherResult; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import org.mockito.Mockito; +import org.testng.annotations.Test; + +public class IncidentTypeTest { + + private static final String TEST_INCIDENT_URN = "urn:li:incident:guid-1"; + private static Urn testAssertionUrn; + private static Urn testUserUrn; + private static Urn testDatasetUrn; + + static { + try { + testAssertionUrn = Urn.createFromString("urn:li:assertion:test"); + testUserUrn = Urn.createFromString("urn:li:corpuser:test"); + testDatasetUrn = Urn.createFromString("urn:li:dataset:(test,test,test)"); + } catch (Exception ignored) { + // ignored + } + } + + private static final IncidentKey TEST_INCIDENT_KEY = new IncidentKey().setId("guid-1"); + private static final IncidentInfo TEST_INCIDENT_INFO = + new IncidentInfo() + .setType(IncidentType.OPERATIONAL) + .setCustomType("Custom Type") + .setDescription("Description") + .setPriority(5) + .setTitle("Title") + .setEntities(new UrnArray(ImmutableList.of(testDatasetUrn))) + .setSource( + new IncidentSource() + .setType(IncidentSourceType.MANUAL) + .setSourceUrn(testAssertionUrn)) + .setStatus( + new IncidentStatus() + .setState(IncidentState.ACTIVE) + .setMessage("Message") + .setLastUpdated(new AuditStamp().setTime(1L).setActor(testUserUrn))) + .setCreated(new AuditStamp().setTime(0L).setActor(testUserUrn)); + private static final String TEST_INCIDENT_URN_2 = "urn:li:incident:guid-2"; + + @Test + public void testBatchLoad() throws Exception { + + EntityClient client = Mockito.mock(EntityClient.class); + + Urn incidentUrn1 = Urn.createFromString(TEST_INCIDENT_URN); + Urn incidentUrn2 = Urn.createFromString(TEST_INCIDENT_URN_2); + + Map incident1Aspects = new HashMap<>(); + incident1Aspects.put( + Constants.INCIDENT_KEY_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(TEST_INCIDENT_KEY.data()))); + incident1Aspects.put( + Constants.INCIDENT_INFO_ASPECT_NAME, + new EnvelopedAspect().setValue(new Aspect(TEST_INCIDENT_INFO.data()))); + Mockito.when( + client.batchGetV2( + Mockito.eq(Constants.INCIDENT_ENTITY_NAME), + Mockito.eq(new HashSet<>(ImmutableSet.of(incidentUrn1, incidentUrn2))), + Mockito.eq( + com.linkedin.datahub.graphql.types.incident.IncidentType.ASPECTS_TO_FETCH), + Mockito.any(Authentication.class))) + .thenReturn( + ImmutableMap.of( + incidentUrn1, + new EntityResponse() + .setEntityName(Constants.INCIDENT_ENTITY_NAME) + .setUrn(incidentUrn1) + .setAspects(new EnvelopedAspectMap(incident1Aspects)))); + + com.linkedin.datahub.graphql.types.incident.IncidentType type = + new com.linkedin.datahub.graphql.types.incident.IncidentType(client); + + QueryContext mockContext = Mockito.mock(QueryContext.class); + Mockito.when(mockContext.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + List> result = + type.batchLoad(ImmutableList.of(TEST_INCIDENT_URN, TEST_INCIDENT_URN_2), mockContext); + + // Verify response + Mockito.verify(client, Mockito.times(1)) + .batchGetV2( + Mockito.eq(Constants.INCIDENT_ENTITY_NAME), + Mockito.eq(ImmutableSet.of(incidentUrn1, incidentUrn2)), + Mockito.eq(com.linkedin.datahub.graphql.types.incident.IncidentType.ASPECTS_TO_FETCH), + Mockito.any(Authentication.class)); + + assertEquals(result.size(), 2); + + Incident incident = result.get(0).getData(); + assertEquals(incident.getUrn(), TEST_INCIDENT_URN.toString()); + assertEquals(incident.getType(), EntityType.INCIDENT); + assertEquals(incident.getIncidentType().toString(), TEST_INCIDENT_INFO.getType().toString()); + assertEquals(incident.getTitle(), TEST_INCIDENT_INFO.getTitle()); + assertEquals(incident.getDescription(), TEST_INCIDENT_INFO.getDescription()); + assertEquals(incident.getCustomType(), TEST_INCIDENT_INFO.getCustomType()); + assertEquals( + incident.getStatus().getState().toString(), + TEST_INCIDENT_INFO.getStatus().getState().toString()); + assertEquals(incident.getStatus().getMessage(), TEST_INCIDENT_INFO.getStatus().getMessage()); + assertEquals( + incident.getStatus().getLastUpdated().getTime(), + TEST_INCIDENT_INFO.getStatus().getLastUpdated().getTime()); + assertEquals( + incident.getStatus().getLastUpdated().getActor(), + TEST_INCIDENT_INFO.getStatus().getLastUpdated().getActor().toString()); + assertEquals( + incident.getSource().getType().toString(), + TEST_INCIDENT_INFO.getSource().getType().toString()); + assertEquals( + incident.getSource().getSource().getUrn(), + TEST_INCIDENT_INFO.getSource().getSourceUrn().toString()); + assertEquals( + incident.getCreated().getActor(), TEST_INCIDENT_INFO.getCreated().getActor().toString()); + assertEquals(incident.getCreated().getTime(), TEST_INCIDENT_INFO.getCreated().getTime()); + + // Assert second element is null. + assertNull(result.get(1)); + } + + @Test + public void testBatchLoadClientException() throws Exception { + EntityClient mockClient = Mockito.mock(EntityClient.class); + Mockito.doThrow(RemoteInvocationException.class) + .when(mockClient) + .batchGetV2( + Mockito.anyString(), + Mockito.anySet(), + Mockito.anySet(), + Mockito.any(Authentication.class)); + com.linkedin.datahub.graphql.types.incident.IncidentType type = + new com.linkedin.datahub.graphql.types.incident.IncidentType(mockClient); + + // Execute Batch load + QueryContext context = Mockito.mock(QueryContext.class); + Mockito.when(context.getAuthentication()).thenReturn(Mockito.mock(Authentication.class)); + assertThrows( + RuntimeException.class, + () -> type.batchLoad(ImmutableList.of(TEST_INCIDENT_URN, TEST_INCIDENT_URN_2), context)); + } +} diff --git a/datahub-web-react/src/App.tsx b/datahub-web-react/src/App.tsx index e8910e7dc2ea8..5be31528fe780 100644 --- a/datahub-web-react/src/App.tsx +++ b/datahub-web-react/src/App.tsx @@ -52,6 +52,11 @@ const client = new ApolloClient({ return { ...oldObj, ...newObj }; }, }, + entity: { + merge: (oldObj, newObj) => { + return { ...oldObj, ...newObj }; + }, + }, }, }, }, diff --git a/datahub-web-react/src/Mocks.tsx b/datahub-web-react/src/Mocks.tsx index f533e8d50385b..2a878ed820886 100644 --- a/datahub-web-react/src/Mocks.tsx +++ b/datahub-web-react/src/Mocks.tsx @@ -299,6 +299,7 @@ export const dataset1 = { autoRenderAspects: [], structuredProperties: null, forms: null, + activeIncidents: null, }; export const dataset2 = { @@ -397,6 +398,7 @@ export const dataset2 = { autoRenderAspects: [], structuredProperties: null, forms: null, + activeIncidents: null, }; export const dataset3 = { @@ -632,6 +634,7 @@ export const dataset3 = { lastOperation: null, structuredProperties: null, forms: null, + activeIncidents: null, } as Dataset; export const dataset3WithSchema = { @@ -1346,6 +1349,8 @@ export const dataFlow1 = { domain: null, deprecation: null, autoRenderAspects: [], + activeIncidents: null, + health: [], } as DataFlow; export const dataJob1 = { @@ -1433,6 +1438,8 @@ export const dataJob1 = { status: null, deprecation: null, autoRenderAspects: [], + activeIncidents: null, + health: [], } as DataJob; export const dataJob2 = { @@ -1503,6 +1510,8 @@ export const dataJob2 = { downstream: null, deprecation: null, autoRenderAspects: [], + activeIncidents: null, + health: [], } as DataJob; export const dataJob3 = { @@ -1576,6 +1585,8 @@ export const dataJob3 = { status: null, deprecation: null, autoRenderAspects: [], + activeIncidents: null, + health: [], } as DataJob; export const mlModel = { diff --git a/datahub-web-react/src/app/analytics/event.ts b/datahub-web-react/src/app/analytics/event.ts index dd670b35d49e0..f8e2534e44c31 100644 --- a/datahub-web-react/src/app/analytics/event.ts +++ b/datahub-web-react/src/app/analytics/event.ts @@ -303,6 +303,8 @@ export const EntityActionType = { UpdateSchemaTags: 'UpdateSchemaTags', UpdateSchemaTerms: 'UpdateSchemaTerms', ClickExternalUrl: 'ClickExternalUrl', + AddIncident: 'AddIncident', + ResolvedIncident: 'ResolvedIncident', }; export interface EntityActionEvent extends BaseEvent { type: EventType.EntityActionEvent; diff --git a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx index 33823a8703d7a..2a54a4a96c639 100644 --- a/datahub-web-react/src/app/entity/chart/ChartEntity.tsx +++ b/datahub-web-react/src/app/entity/chart/ChartEntity.tsx @@ -27,6 +27,7 @@ import EmbeddedProfile from '../shared/embed/EmbeddedProfile'; import { LOOKER_URN } from '../../ingest/source/builder/constants'; import { MatchedFieldList } from '../../search/matches/MatchedFieldList'; import { matchedInputFieldRenderer } from '../../search/matches/matchedInputFieldRenderer'; +import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; /** * Definition of the DataHub Chart entity. @@ -104,7 +105,7 @@ export class ChartEntity implements Entity { useEntityQuery={this.useEntityQuery} useUpdateQuery={useUpdateChartMutation} getOverrideProperties={this.getOverridePropertiesFromEntity} - headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION])} + headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION, EntityMenuItems.RAISE_INCIDENT])} subHeader={{ component: ChartStatsSummarySubHeader, }} @@ -150,6 +151,14 @@ export class ChartEntity implements Entity { name: 'Properties', component: PropertiesTab, }, + { + name: 'Incidents', + component: IncidentTab, + getDynamicName: (_, chart) => { + const activeIncidentCount = chart?.chart?.activeIncidents.total; + return `Incidents${(activeIncidentCount && ` (${activeIncidentCount})`) || ''}`; + }, + }, ]} sidebarSections={this.getSidebarSections()} /> @@ -184,6 +193,7 @@ export class ChartEntity implements Entity { domain={data.domain?.domain} dataProduct={getDataProduct(genericProperties?.dataProduct)} parentContainers={data.parentContainers} + health={data.health} /> ); }; @@ -219,6 +229,7 @@ export class ChartEntity implements Entity { } degree={(result as any).degree} paths={(result as any).paths} + health={data.health} /> ); }; @@ -231,6 +242,7 @@ export class ChartEntity implements Entity { icon: entity?.platform?.properties?.logoUrl || undefined, platform: entity?.platform, subtype: entity?.subTypes?.typeNames?.[0] || undefined, + health: entity?.health || undefined, }; }; diff --git a/datahub-web-react/src/app/entity/chart/preview/ChartPreview.tsx b/datahub-web-react/src/app/entity/chart/preview/ChartPreview.tsx index b7fbd63ee231e..adb75aa704527 100644 --- a/datahub-web-react/src/app/entity/chart/preview/ChartPreview.tsx +++ b/datahub-web-react/src/app/entity/chart/preview/ChartPreview.tsx @@ -13,6 +13,7 @@ import { ChartStatsSummary, DataProduct, EntityPath, + Health, } from '../../../../types.generated'; import DefaultPreviewCard from '../../../preview/DefaultPreviewCard'; import { capitalizeFirstLetterOnly } from '../../../shared/textUtil'; @@ -45,6 +46,7 @@ export const ChartPreview = ({ degree, paths, subType, + health, }: { urn: string; platform?: string; @@ -70,6 +72,7 @@ export const ChartPreview = ({ degree?: number; paths?: EntityPath[]; subType?: string | null; + health?: Health[] | null; }): JSX.Element => { const entityRegistry = useEntityRegistry(); @@ -106,6 +109,7 @@ export const ChartPreview = ({ } degree={degree} paths={paths} + health={health || undefined} /> ); }; diff --git a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx index 5634e05882534..9564cbc18198e 100644 --- a/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx +++ b/datahub-web-react/src/app/entity/dashboard/DashboardEntity.tsx @@ -31,6 +31,7 @@ import { getDataProduct } from '../shared/utils'; import { LOOKER_URN } from '../../ingest/source/builder/constants'; import { MatchedFieldList } from '../../search/matches/MatchedFieldList'; import { matchedInputFieldRenderer } from '../../search/matches/matchedInputFieldRenderer'; +import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; /** * Definition of the DataHub Dashboard entity. @@ -111,7 +112,7 @@ export class DashboardEntity implements Entity { useEntityQuery={this.useEntityQuery} useUpdateQuery={useUpdateDashboardMutation} getOverrideProperties={this.getOverridePropertiesFromEntity} - headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION])} + headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION, EntityMenuItems.RAISE_INCIDENT])} subHeader={{ component: DashboardStatsSummarySubHeader, }} @@ -161,6 +162,14 @@ export class DashboardEntity implements Entity { name: 'Properties', component: PropertiesTab, }, + { + name: 'Incidents', + component: IncidentTab, + getDynamicName: (_, dashboard) => { + const activeIncidentCount = dashboard?.dashboard?.activeIncidents.total; + return `Incidents${(activeIncidentCount && ` (${activeIncidentCount})`) || ''}`; + }, + }, ]} sidebarSections={this.getSidebarSections()} /> @@ -201,6 +210,7 @@ export class DashboardEntity implements Entity { lastUpdatedMs={data.properties?.lastModified?.time} createdMs={data.properties?.created?.time} subtype={data.subTypes?.typeNames?.[0]} + health={data.health} /> ); }; @@ -240,6 +250,7 @@ export class DashboardEntity implements Entity { subtype={data.subTypes?.typeNames?.[0]} degree={(result as any).degree} paths={(result as any).paths} + health={data.health} /> ); }; @@ -252,6 +263,7 @@ export class DashboardEntity implements Entity { subtype: entity?.subTypes?.typeNames?.[0] || undefined, icon: entity?.platform?.properties?.logoUrl || undefined, platform: entity?.platform, + health: entity?.health || undefined, }; }; diff --git a/datahub-web-react/src/app/entity/dashboard/preview/DashboardPreview.tsx b/datahub-web-react/src/app/entity/dashboard/preview/DashboardPreview.tsx index d822fd1f613b3..78e87b8f141cc 100644 --- a/datahub-web-react/src/app/entity/dashboard/preview/DashboardPreview.tsx +++ b/datahub-web-react/src/app/entity/dashboard/preview/DashboardPreview.tsx @@ -13,6 +13,7 @@ import { DashboardStatsSummary, DataProduct, EntityPath, + Health, } from '../../../../types.generated'; import DefaultPreviewCard from '../../../preview/DefaultPreviewCard'; import { useEntityRegistry } from '../../../useEntityRegistry'; @@ -46,6 +47,7 @@ export const DashboardPreview = ({ snippet, degree, paths, + health, }: { urn: string; platform?: string; @@ -72,6 +74,7 @@ export const DashboardPreview = ({ snippet?: React.ReactNode | null; degree?: number; paths?: EntityPath[]; + health?: Health[] | null; }): JSX.Element => { const entityRegistry = useEntityRegistry(); @@ -110,6 +113,7 @@ export const DashboardPreview = ({ } degree={degree} paths={paths} + health={health || undefined} /> ); }; diff --git a/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx b/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx index 9f5ce93b1b168..25c1af09e7e5c 100644 --- a/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx +++ b/datahub-web-react/src/app/entity/dataFlow/DataFlowEntity.tsx @@ -18,6 +18,7 @@ import { EntityMenuItems } from '../shared/EntityDropdown/EntityDropdown'; import { capitalizeFirstLetterOnly } from '../../shared/textUtil'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { getDataProduct } from '../shared/utils'; +import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; /** * Definition of the DataHub DataFlow entity. @@ -67,7 +68,7 @@ export class DataFlowEntity implements Entity { useEntityQuery={this.useEntityQuery} useUpdateQuery={useUpdateDataFlowMutation} getOverrideProperties={this.getOverridePropertiesFromEntity} - headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION])} + headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION, EntityMenuItems.RAISE_INCIDENT])} tabs={[ { name: 'Documentation', @@ -81,6 +82,14 @@ export class DataFlowEntity implements Entity { name: 'Tasks', component: DataFlowJobsTab, }, + { + name: 'Incidents', + component: IncidentTab, + getDynamicName: (_, dataFlow) => { + const activeIncidentCount = dataFlow?.dataFlow?.activeIncidents.total; + return `Incidents${(activeIncidentCount && ` (${activeIncidentCount})`) || ''}`; + }, + }, ]} sidebarSections={this.getSidebarSections()} /> @@ -137,6 +146,7 @@ export class DataFlowEntity implements Entity { domain={data.domain?.domain} dataProduct={getDataProduct(genericProperties?.dataProduct)} externalUrl={data.properties?.externalUrl} + health={data.health} /> ); }; @@ -164,6 +174,7 @@ export class DataFlowEntity implements Entity { deprecation={data.deprecation} degree={(result as any).degree} paths={(result as any).paths} + health={data.health} /> ); }; diff --git a/datahub-web-react/src/app/entity/dataFlow/preview/Preview.tsx b/datahub-web-react/src/app/entity/dataFlow/preview/Preview.tsx index c313171d2f241..f210f7c985ebf 100644 --- a/datahub-web-react/src/app/entity/dataFlow/preview/Preview.tsx +++ b/datahub-web-react/src/app/entity/dataFlow/preview/Preview.tsx @@ -8,6 +8,7 @@ import { EntityPath, EntityType, GlobalTags, + Health, Owner, SearchInsight, } from '../../../../types.generated'; @@ -38,6 +39,7 @@ export const Preview = ({ deprecation, degree, paths, + health, }: { urn: string; name: string; @@ -56,6 +58,7 @@ export const Preview = ({ jobCount?: number | null; degree?: number; paths?: EntityPath[]; + health?: Health[] | null; }): JSX.Element => { const entityRegistry = useEntityRegistry(); return ( @@ -87,6 +90,7 @@ export const Preview = ({ } degree={degree} paths={paths} + health={health || undefined} /> ); }; diff --git a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx index 3d064265913da..fe1a906371e9d 100644 --- a/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx +++ b/datahub-web-react/src/app/entity/dataJob/DataJobEntity.tsx @@ -21,6 +21,7 @@ import { DataFlowEntity } from '../dataFlow/DataFlowEntity'; import { capitalizeFirstLetterOnly } from '../../shared/textUtil'; import DataProductSection from '../shared/containers/profile/sidebar/DataProduct/DataProductSection'; import { getDataProduct } from '../shared/utils'; +import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; const getDataJobPlatformName = (data?: DataJob): string => { return ( @@ -78,7 +79,7 @@ export class DataJobEntity implements Entity { useEntityQuery={this.useEntityQuery} useUpdateQuery={useUpdateDataJobMutation} getOverrideProperties={this.getOverridePropertiesFromEntity} - headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION])} + headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION, EntityMenuItems.RAISE_INCIDENT])} tabs={[ { name: 'Documentation', @@ -104,6 +105,14 @@ export class DataJobEntity implements Entity { enabled: (_, dataJob: GetDataJobQuery) => (dataJob?.dataJob?.runs?.total || 0) !== 0, }, }, + { + name: 'Incidents', + component: IncidentTab, + getDynamicName: (_, dataJob) => { + const activeIncidentCount = dataJob?.dataJob?.activeIncidents.total; + return `Incidents${(activeIncidentCount && ` (${activeIncidentCount})`) || ''}`; + }, + }, ]} sidebarSections={this.getSidebarSections()} /> @@ -160,6 +169,7 @@ export class DataJobEntity implements Entity { domain={data.domain?.domain} dataProduct={getDataProduct(genericProperties?.dataProduct)} externalUrl={data.properties?.externalUrl} + health={data.health} /> ); }; @@ -188,6 +198,7 @@ export class DataJobEntity implements Entity { } degree={(result as any).degree} paths={(result as any).paths} + health={data.health} /> ); }; @@ -218,6 +229,7 @@ export class DataJobEntity implements Entity { type: EntityType.DataJob, icon: entity?.dataFlow?.platform?.properties?.logoUrl || undefined, platform: entity?.dataFlow?.platform, + health: entity?.health || undefined, }; }; diff --git a/datahub-web-react/src/app/entity/dataJob/preview/Preview.tsx b/datahub-web-react/src/app/entity/dataJob/preview/Preview.tsx index c21c2f03f734f..b163722b5151c 100644 --- a/datahub-web-react/src/app/entity/dataJob/preview/Preview.tsx +++ b/datahub-web-react/src/app/entity/dataJob/preview/Preview.tsx @@ -10,6 +10,7 @@ import { EntityPath, EntityType, GlobalTags, + Health, Owner, SearchInsight, } from '../../../../types.generated'; @@ -42,6 +43,7 @@ export const Preview = ({ externalUrl, degree, paths, + health, }: { urn: string; name: string; @@ -61,6 +63,7 @@ export const Preview = ({ externalUrl?: string | null; degree?: number; paths?: EntityPath[]; + health?: Health[] | null; }): JSX.Element => { const entityRegistry = useEntityRegistry(); return ( @@ -69,7 +72,7 @@ export const Preview = ({ name={name} urn={urn} description={description || ''} - type={subType || "Data Task"} + type={subType || 'Data Task'} typeIcon={entityRegistry.getIcon(EntityType.DataJob, 14, IconStyleType.ACCENT)} platform={platformName} logoUrl={platformLogo || ''} @@ -94,6 +97,7 @@ export const Preview = ({ } degree={degree} paths={paths} + health={health || undefined} /> ); }; diff --git a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx index d60914f87e35d..0758cc41a7e41 100644 --- a/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx +++ b/datahub-web-react/src/app/entity/dataset/DatasetEntity.tsx @@ -34,6 +34,7 @@ import { getDataProduct } from '../shared/utils'; import AccessManagement from '../shared/tabs/Dataset/AccessManagement/AccessManagement'; import { matchedFieldPathsRenderer } from '../../search/matches/matchedFieldPathsRenderer'; import { getLastUpdatedMs } from './shared/utils'; +import { IncidentTab } from '../shared/tabs/Incident/IncidentTab'; const SUBTYPES = { VIEW: 'view', @@ -95,7 +96,7 @@ export class DatasetEntity implements Entity { useEntityQuery={this.useEntityQuery} useUpdateQuery={useUpdateDatasetMutation} getOverrideProperties={this.getOverridePropertiesFromEntity} - headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION])} + headerDropdownItems={new Set([EntityMenuItems.UPDATE_DEPRECATION, EntityMenuItems.RAISE_INCIDENT])} subHeader={{ component: DatasetStatsSummarySubHeader, }} @@ -191,6 +192,14 @@ export class DatasetEntity implements Entity { enabled: (_, _2) => true, }, }, + { + name: 'Incidents', + component: IncidentTab, + getDynamicName: (_, dataset) => { + const activeIncidentCount = dataset?.dataset?.activeIncidents.total; + return `Incidents${(activeIncidentCount && ` (${activeIncidentCount})`) || ''}`; + }, + }, ]} sidebarSections={this.getSidebarSections()} /> diff --git a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx index 664a77a731d34..2856a219c435d 100644 --- a/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx +++ b/datahub-web-react/src/app/entity/shared/EntityDropdown/EntityDropdown.tsx @@ -10,8 +10,9 @@ import { MoreOutlined, PlusOutlined, CopyOutlined, + WarningOutlined, } from '@ant-design/icons'; -import { Redirect } from 'react-router'; +import { Redirect, useHistory } from 'react-router'; import { EntityType } from '../../../../types.generated'; import CreateGlossaryEntityModal from './CreateGlossaryEntityModal'; import { UpdateDeprecationModal } from './UpdateDeprecationModal'; @@ -25,6 +26,9 @@ import { shouldDisplayChildDeletionWarning, isDeleteDisabled, isMoveDisabled } f import { useUserContext } from '../../../context/useUserContext'; import MoveDomainModal from './MoveDomainModal'; import { useIsNestedDomainsEnabled } from '../../../useAppConfig'; +import { getEntityPath } from '../containers/profile/utils'; +import { useIsSeparateSiblingsMode } from '../siblingUtils'; +import { AddIncidentModal } from '../tabs/Incident/components/AddIncidentModal'; export enum EntityMenuItems { COPY_URL, @@ -34,6 +38,7 @@ export enum EntityMenuItems { DELETE, MOVE, CLONE, + RAISE_INCIDENT, } export const MenuIcon = styled(MoreOutlined)<{ fontSize?: number }>` @@ -81,6 +86,8 @@ interface Props { } function EntityDropdown(props: Props) { + const history = useHistory(); + const { urn, entityData, @@ -97,6 +104,7 @@ function EntityDropdown(props: Props) { const me = useUserContext(); const entityRegistry = useEntityRegistry(); const [updateDeprecation] = useUpdateDeprecationMutation(); + const isHideSiblingMode = useIsSeparateSiblingsMode(); const isNestedDomainsEnabled = useIsNestedDomainsEnabled(); const { onDeleteEntity, hasBeenDeleted } = useDeleteEntity( urn, @@ -112,6 +120,7 @@ function EntityDropdown(props: Props) { const [isCloneEntityModalVisible, setIsCloneEntityModalVisible] = useState(false); const [isDeprecationModalVisible, setIsDeprecationModalVisible] = useState(false); const [isMoveModalVisible, setIsMoveModalVisible] = useState(false); + const [isRaiseIncidentModalVisible, setIsRaiseIncidentModalVisible] = useState(false); const handleUpdateDeprecation = async (deprecatedStatus: boolean) => { message.loading({ content: 'Updating...' }); @@ -245,6 +254,13 @@ function EntityDropdown(props: Props) { )} + {menuItems.has(EntityMenuItems.RAISE_INCIDENT) && ( + + setIsRaiseIncidentModalVisible(true)}> +  Raise Incident + + + )} } trigger={['click']} @@ -285,6 +301,27 @@ function EntityDropdown(props: Props) { )} {isMoveModalVisible && isDomainEntity && setIsMoveModalVisible(false)} />} {hasBeenDeleted && !onDelete && deleteRedirectPath && } + {isRaiseIncidentModalVisible && ( + setIsRaiseIncidentModalVisible(false)} + refetch={ + (() => { + refetchForEntity?.(); + history.push( + `${getEntityPath( + entityType, + urn, + entityRegistry, + false, + isHideSiblingMode, + 'Incidents', + )}`, + ); + }) as any + } + /> + )} ); } diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthPopover.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthPopover.tsx index 4dde3ffcbb6a4..d10601b39bf0c 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthPopover.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthPopover.tsx @@ -55,18 +55,17 @@ type Props = { }; export const EntityHealthPopover = ({ health, baseUrl, children, fontSize, placement = 'right' }: Props) => { - const icon = getHealthSummaryIcon(health, HealthSummaryIconType.OUTLINED, fontSize); - const message = getHealthSummaryMessage(health); return (

- {icon} {message} + {getHealthSummaryIcon(health, HealthSummaryIconType.OUTLINED, fontSize)}{' '} + {getHealthSummaryMessage(health)}
{health.map((h) => ( - + ))} diff --git a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthStatus.tsx b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthStatus.tsx index 601906038d3b7..27c85b85163ae 100644 --- a/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthStatus.tsx +++ b/datahub-web-react/src/app/entity/shared/containers/profile/header/EntityHealthStatus.tsx @@ -39,7 +39,11 @@ export const EntityHealthStatus = ({ type, message, baseUrl }: Props) => { return ( {title} {message} - {redirectPath && details} + {redirectPath && ( + + details + + )} ); }; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Incident/IncidentTab.tsx b/datahub-web-react/src/app/entity/shared/tabs/Incident/IncidentTab.tsx new file mode 100644 index 0000000000000..47627068ad4ce --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Incident/IncidentTab.tsx @@ -0,0 +1,134 @@ +import React, { useState } from 'react'; +import styled from 'styled-components'; +import { Button, Empty, List, Select, Typography } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import { useGetEntityIncidentsQuery } from '../../../../../graphql/incident.generated'; +import TabToolbar from '../../components/styled/TabToolbar'; +import { useEntityData } from '../../EntityContext'; +import IncidentListItem from './components/IncidentListItem'; +import { INCIDENT_DISPLAY_STATES, PAGE_SIZE, getIncidentsStatusSummary } from './incidentUtils'; +import { EntityType, Incident, IncidentState } from '../../../../../types.generated'; +import { IncidentSummary } from './components/IncidentSummary'; +import { AddIncidentModal } from './components/AddIncidentModal'; +import { combineEntityDataWithSiblings } from '../../siblingUtils'; +import { IncidentsLoadingSection } from './components/IncidentsLoadingSection'; +import { ANTD_GRAY } from '../../constants'; + +const Header = styled.div` + border-bottom: 1px solid ${ANTD_GRAY[3]}; + box-shadow: ${(props) => props.theme.styles['box-shadow']}; +`; + +const Summary = styled.div` + display: flex; + align-items: center; + justify-content: space-between; +`; + +const IncidentList = styled.div` + flex: 1; + height: 100%; + overflow: scroll; +`; + +const IncidentStyledList = styled(List)` + &&& { + width: 100%; + border-color: ${(props) => props.theme.styles['border-color-base']}; + flex: 1; + } +`; + +const IncidentStateSelect = styled(Select)` + width: 100px; + margin: 0px 40px; +`; + +export const IncidentTab = () => { + const { urn, entityType } = useEntityData(); + const incidentStates = INCIDENT_DISPLAY_STATES; + const [selectedIncidentState, setSelectedIncidentState] = useState(IncidentState.Active); + const [isRaiseIncidentModalVisible, setIsRaiseIncidentModalVisible] = useState(false); + + // Fetch filtered incidents. + const { loading, data, refetch } = useGetEntityIncidentsQuery({ + variables: { + urn, + start: 0, + count: PAGE_SIZE, + }, + fetchPolicy: 'cache-and-network', + }); + + const hasData = (data?.entity as any)?.incidents; + const combinedData = (entityType === EntityType.Dataset && combineEntityDataWithSiblings(data)) || data; + const allIncidents = + (combinedData && (combinedData as any).entity?.incidents?.incidents?.map((incident) => incident as Incident)) || + []; + const filteredIncidents = allIncidents.filter( + (incident) => !selectedIncidentState || incident.status?.state === selectedIncidentState, + ); + const incidentList = filteredIncidents?.map((incident) => ({ + urn: incident?.urn, + created: incident.created, + customType: incident.customType, + description: incident.description, + status: incident.status, + type: incident?.incidentType, + title: incident?.title, + })); + + return ( + <> +
+ + + setIsRaiseIncidentModalVisible(false)} + /> + + + + setSelectedIncidentState(newState)} + autoFocus + > + {incidentStates.map((incidentType) => { + return ( + + {incidentType.name} + + ); + })} + + +
+ + {(loading && !hasData && ) || null} + {hasData && ( + + + ), + }} + dataSource={incidentList} + renderItem={(item: any) => } + /> + + )} + + ); +}; diff --git a/datahub-web-react/src/app/entity/shared/tabs/Incident/components/AddIncidentModal.tsx b/datahub-web-react/src/app/entity/shared/tabs/Incident/components/AddIncidentModal.tsx new file mode 100644 index 0000000000000..bb83f0f9ddaf7 --- /dev/null +++ b/datahub-web-react/src/app/entity/shared/tabs/Incident/components/AddIncidentModal.tsx @@ -0,0 +1,183 @@ +import React, { useState } from 'react'; +import { message, Modal, Button, Form, Input, Typography, Select } from 'antd'; +import { useApolloClient } from '@apollo/client'; +import TextArea from 'antd/lib/input/TextArea'; +import analytics, { EventType, EntityActionType } from '../../../../../analytics'; +import { useEntityData } from '../../../EntityContext'; +import { EntityType, IncidentSourceType, IncidentState, IncidentType } from '../../../../../../types.generated'; +import { INCIDENT_DISPLAY_TYPES, PAGE_SIZE, addActiveIncidentToCache } from '../incidentUtils'; +import { useRaiseIncidentMutation } from '../../../../../../graphql/mutations.generated'; +import handleGraphQLError from '../../../../../shared/handleGraphQLError'; +import { useUserContext } from '../../../../../context/useUserContext'; + +type AddIncidentProps = { + visible: boolean; + onClose?: () => void; + refetch?: () => Promise; +}; + +export const AddIncidentModal = ({ visible, onClose, refetch }: AddIncidentProps) => { + const { urn, entityType } = useEntityData(); + const { user } = useUserContext(); + const incidentTypes = INCIDENT_DISPLAY_TYPES; + const [selectedIncidentType, setSelectedIncidentType] = useState(IncidentType.Operational); + const [isOtherTypeSelected, setIsOtherTypeSelected] = useState(false); + const [raiseIncidentMutation] = useRaiseIncidentMutation(); + + const client = useApolloClient(); + const [form] = Form.useForm(); + + const handleClose = () => { + form.resetFields(); + setIsOtherTypeSelected(false); + setSelectedIncidentType(IncidentType.Operational); + onClose?.(); + }; + + const onSelectIncidentType = (newType) => { + if (newType === 'OTHER') { + setIsOtherTypeSelected(true); + setSelectedIncidentType(IncidentType.Custom); + } else { + setIsOtherTypeSelected(false); + setSelectedIncidentType(newType); + } + }; + + const handleAddIncident = async (formData: any) => { + raiseIncidentMutation({ + variables: { + input: { + type: selectedIncidentType, + title: formData.title, + description: formData.description, + resourceUrn: urn, + customType: formData.customType, + }, + }, + }) + .then(({ data }) => { + const newIncident = { + urn: data?.raiseIncident, + type: EntityType.Incident, + incidentType: selectedIncidentType, + customType: formData.customType || null, + title: formData.title, + description: formData.description, + status: { + state: IncidentState.Active, + message: null, + lastUpdated: { + __typename: 'AuditStamp', + time: Date.now(), + actor: user?.urn, + }, + }, + source: { + type: IncidentSourceType.Manual, + }, + created: { + time: Date.now(), + actor: user?.urn, + }, + }; + message.success({ content: 'Incident Added', duration: 2 }); + analytics.event({ + type: EventType.EntityActionEvent, + entityType, + entityUrn: urn, + actionType: EntityActionType.AddIncident, + }); + addActiveIncidentToCache(client, urn, newIncident, PAGE_SIZE); + handleClose(); + setTimeout(() => { + refetch?.(); + }, 2000); + }) + .catch((error) => { + console.error(error); + handleGraphQLError({ + error, + defaultMessage: 'Failed to raise incident! An unexpected error occurred', + permissionMessage: + 'Unauthorized to raise incident for this asset. Please contact your DataHub administrator.', + }); + }); + }; + + return ( + <> + + Cancel + , + , + ]} + > +
+ Type}> + + + + + {isOtherTypeSelected && ( + + + + )} + + + + +