diff --git a/package.json b/package.json index c0338d1a8163ee..895b499060054c 100644 --- a/package.json +++ b/package.json @@ -140,7 +140,7 @@ "papaparse": "^5.3.2", "pegjs": "^0.10.0", "pegjs-loader": "^0.5.6", - "platformicons": "^5.10.9", + "platformicons": "^6.0.1", "po-catalog-loader": "2.0.0", "prettier": "3.3.2", "prismjs": "^1.29.0", @@ -263,9 +263,7 @@ "last 3 iOS major versions", "Firefox ESR" ], - "test": [ - "current node" - ] + "test": ["current node"] }, "volta": { "extends": ".volta.json" diff --git a/pyproject.toml b/pyproject.toml index 10426b3eb3db2c..92902db8f36b13 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -360,7 +360,6 @@ module = [ "sentry.plugins.bases.notify", "sentry.plugins.config", "sentry.plugins.endpoints", - "sentry.plugins.providers.repository", "sentry.receivers.releases", "sentry.release_health.metrics_sessions_v2", "sentry.replays.endpoints.project_replay_clicks_index", @@ -530,7 +529,6 @@ module = [ "sentry.issues.constants", "sentry.issues.endpoints", "sentry.issues.endpoints.group_events", - "sentry.issues.endpoints.organization_activity", "sentry.issues.endpoints.organization_group_search_views", "sentry.issues.endpoints.organization_release_previous_commits", "sentry.issues.endpoints.organization_searches", @@ -626,7 +624,6 @@ module = [ "tests.sentry.issues", "tests.sentry.issues.endpoints", "tests.sentry.issues.endpoints.test_actionable_items", - "tests.sentry.issues.endpoints.test_organization_activity", "tests.sentry.issues.endpoints.test_organization_group_search_views", "tests.sentry.issues.endpoints.test_organization_searches", "tests.sentry.issues.endpoints.test_project_stacktrace_link", diff --git a/src/sentry/api/endpoints/project_rules.py b/src/sentry/api/endpoints/project_rules.py index 59c252e5a182af..b6bb8e1ad562c3 100644 --- a/src/sentry/api/endpoints/project_rules.py +++ b/src/sentry/api/endpoints/project_rules.py @@ -81,12 +81,12 @@ def __init__( """ rule.data will supersede rule_data if passed in """ - self._project_id: int = project_id - self._rule_data: dict[Any, Any] = rule.data if rule else rule_data - self._rule_id: int | None = rule_id - self._rule: Rule | None = rule + self._project_id = project_id + self._rule_data = rule.data if rule else rule_data or {} + self._rule_id = rule_id + self._rule = rule - self._keys_to_check: set[str] = self._get_keys_to_check() + self._keys_to_check = self._get_keys_to_check() self._matcher_funcs_by_key: dict[str, Callable[[Rule, str], MatcherResult]] = { self.ENVIRONMENT_KEY: self._environment_matcher, @@ -99,9 +99,7 @@ def _get_keys_to_check(self) -> set[str]: Some keys are ignored as they are not part of the logic. Some keys are required to check, and are added on top. """ - keys_to_check: set[str] = { - key for key in list(self._rule_data.keys()) if key not in self.EXCLUDED_FIELDS - } + keys_to_check = {key for key in self._rule_data if key not in self.EXCLUDED_FIELDS} keys_to_check.update(self.SPECIAL_FIELDS) return keys_to_check diff --git a/src/sentry/api/paginator.py b/src/sentry/api/paginator.py index 0dc9ddfb3998bf..61684a9161f3e7 100644 --- a/src/sentry/api/paginator.py +++ b/src/sentry/api/paginator.py @@ -659,6 +659,8 @@ def _sort_combined_querysets(item): sort_keys = [] sort_keys.append(self.get_item_key(item)) if len(self.model_key_map.get(type(item))) > 1: + # XXX: This doesn't do anything - it just uses a column name as the sort key. It should be pulling the + # value of the other keys out instead. sort_keys.extend(iter(self.model_key_map.get(type(item))[1:])) sort_keys.append(type(item).__name__) return tuple(sort_keys) diff --git a/src/sentry/api/serializers/models/dashboard.py b/src/sentry/api/serializers/models/dashboard.py index 0360acbb41ab23..52c90df7b8e580 100644 --- a/src/sentry/api/serializers/models/dashboard.py +++ b/src/sentry/api/serializers/models/dashboard.py @@ -2,6 +2,7 @@ import orjson +from sentry import features from sentry.api.serializers import Serializer, register, serialize from sentry.constants import ALL_ACCESS_PROJECTS from sentry.models.dashboard import Dashboard @@ -36,6 +37,21 @@ def get_attrs(self, item_list, user): return result def serialize(self, obj, attrs, user, **kwargs): + widget_type = ( + DashboardWidgetTypes.get_type_name(obj.widget_type) + or DashboardWidgetTypes.TYPE_NAMES[0] + ) + + if ( + features.has( + "organizations:performance-discover-dataset-selector", + obj.dashboard.organization, + actor=user, + ) + and obj.discover_widget_split is not None + ): + widget_type = DashboardWidgetTypes.get_type_name(obj.discover_widget_split) + return { "id": str(obj.id), "title": obj.title, @@ -49,8 +65,7 @@ def serialize(self, obj, attrs, user, **kwargs): "queries": attrs["queries"], "limit": obj.limit, # Default to discover type if null - "widgetType": DashboardWidgetTypes.get_type_name(obj.widget_type) - or DashboardWidgetTypes.TYPE_NAMES[0], + "widgetType": widget_type, "layout": obj.detail.get("layout") if obj.detail else None, } diff --git a/src/sentry/data_export/models.py b/src/sentry/data_export/models.py index b416d43d050d59..d3e339e5aa106d 100644 --- a/src/sentry/data_export/models.py +++ b/src/sentry/data_export/models.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import logging +from typing import Any import orjson from django.conf import settings @@ -39,7 +42,7 @@ class ExportedData(Model): date_finished = models.DateTimeField(null=True) date_expired = models.DateTimeField(null=True, db_index=True) query_type = BoundedPositiveIntegerField(choices=ExportQueryType.as_choices()) - query_info = JSONField() + query_info: models.Field[dict[str, Any], dict[str, Any]] = JSONField() @property def status(self) -> ExportStatus: diff --git a/src/sentry/discover/models.py b/src/sentry/discover/models.py index 8d2d98fa06412c..22ed47e5719c32 100644 --- a/src/sentry/discover/models.py +++ b/src/sentry/discover/models.py @@ -1,5 +1,7 @@ +from __future__ import annotations + from enum import Enum -from typing import ClassVar +from typing import Any, ClassVar from django.db import models, router, transaction from django.db.models import Q, UniqueConstraint @@ -89,7 +91,7 @@ class DiscoverSavedQuery(Model): organization = FlexibleForeignKey("sentry.Organization") created_by_id = HybridCloudForeignKey("sentry.User", null=True, on_delete="SET_NULL") name = models.CharField(max_length=255) - query = JSONField() + query: models.Field[dict[str, Any], dict[str, Any]] = JSONField() version = models.IntegerField(null=True) date_created = models.DateTimeField(auto_now_add=True) date_updated = models.DateTimeField(auto_now=True) diff --git a/src/sentry/features/temporary.py b/src/sentry/features/temporary.py index 14ab58ce705abe..4d3070afbb84f6 100644 --- a/src/sentry/features/temporary.py +++ b/src/sentry/features/temporary.py @@ -467,6 +467,10 @@ def register_temporary_features(manager: FeatureManager): manager.add("organizations:uptime-automatic-hostname-detection", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE) # Enables automatic subscription creation in uptime manager.add("organizations:uptime-automatic-subscription-creation", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE) + # Enabled returning uptime monitors from the rule api + manager.add("organizations:uptime-rule-api", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE) + # Enable creating issues via the issue platform + manager.add("organizations:uptime-create-issues", OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE) # Enables uptime related settings for projects and orgs manager.add('organizations:uptime-settings', OrganizationFeature, FeatureHandlerStrategy.FLAGPOLE) manager.add("organizations:use-metrics-layer", OrganizationFeature, FeatureHandlerStrategy.REMOTE) diff --git a/src/sentry/incidents/endpoints/organization_alert_rule_index.py b/src/sentry/incidents/endpoints/organization_alert_rule_index.py index 93fbf853a7e295..f2307fdd27ff72 100644 --- a/src/sentry/incidents/endpoints/organization_alert_rule_index.py +++ b/src/sentry/incidents/endpoints/organization_alert_rule_index.py @@ -2,7 +2,7 @@ from datetime import UTC, datetime from django.conf import settings -from django.db.models import DateTimeField, IntegerField, OuterRef, Q, Subquery, Value +from django.db.models import Case, DateTimeField, IntegerField, OuterRef, Q, Subquery, Value, When from django.db.models.functions import Coalesce from drf_spectacular.utils import extend_schema, extend_schema_serializer from rest_framework import serializers, status @@ -35,9 +35,10 @@ AlertRuleSerializerResponse, CombinedRuleSerializer, ) +from sentry.incidents.endpoints.utils import parse_team_params from sentry.incidents.logic import get_slack_actions_with_async_lookups from sentry.incidents.models.alert_rule import AlertRule -from sentry.incidents.models.incident import Incident +from sentry.incidents.models.incident import Incident, IncidentStatus from sentry.incidents.serializers import AlertRuleSerializer as DrfAlertRuleSerializer from sentry.incidents.utils.sentry_apps import trigger_sentry_app_action_creators_for_incidents from sentry.integrations.slack.utils import RedisRuleStatus @@ -49,10 +50,9 @@ from sentry.signals import alert_rule_created from sentry.snuba.dataset import Dataset from sentry.tasks.integrations.slack import find_channel_id_for_alert_rule +from sentry.uptime.models import ProjectUptimeSubscription, UptimeStatus from sentry.utils.cursors import Cursor, StringCursor -from .utils import parse_team_params - class AlertRuleIndexMixin(Endpoint): def fetch_metric_alert(self, request, organization, project=None): @@ -152,7 +152,7 @@ class OrganizationCombinedRuleIndexEndpoint(OrganizationEndpoint): def get(self, request: Request, organization) -> Response: """ - Fetches (metric) alert rules and legacy (issue alert) rules for an organization + Fetches metric, issue and uptime alert rules for an organization """ project_ids = self.get_requested_project_ids_unchecked(request) or None if project_ids == {-1}: # All projects for org: @@ -196,6 +196,11 @@ def get(self, request: Request, organization) -> Response: project__in=projects, ) + uptime_rules = ProjectUptimeSubscription.objects.filter(project__in=projects) + + if not features.has("organizations:uptime-rule-api", organization): + uptime_rules = ProjectUptimeSubscription.objects.none() + if not features.has("organizations:performance-view", organization): # Filter to only error alert rules alert_rules = alert_rules.filter(snuba_query__dataset=Dataset.Events.value) @@ -208,8 +213,9 @@ def get(self, request: Request, organization) -> Response: name = request.GET.get("name", None) if name: - alert_rules = alert_rules.filter(Q(name__icontains=name)) - issue_rules = issue_rules.filter(Q(label__icontains=name)) + alert_rules = alert_rules.filter(name__icontains=name) + issue_rules = issue_rules.filter(label__icontains=name) + uptime_rules = uptime_rules.filter(name__icontains=name) if teams_query is not None: team_ids = teams_query.values_list("id", flat=True) @@ -220,6 +226,7 @@ def get(self, request: Request, organization) -> Response: team_rule_condition = team_rule_condition | Q(owner_team_id__isnull=True) alert_rules = alert_rules.filter(team_alert_condition) issue_rules = issue_rules.filter(team_rule_condition) + uptime_rules = uptime_rules.filter(team_rule_condition) expand = request.GET.getlist("expand", []) if "latestIncident" in expand: @@ -255,6 +262,14 @@ def get(self, request: Request, organization) -> Response: issue_rules = issue_rules.annotate( incident_status=Value(-2, output_field=IntegerField()) ) + uptime_rules = uptime_rules.annotate( + incident_status=Case( + # If an uptime monitor is failing we want to treat it the same as if an alert is failing, so sort + # by the critical status + When(uptime_status=UptimeStatus.FAILED, then=IncidentStatus.CRITICAL.value), + default=-2, + ) + ) if "date_triggered" in sort_key: far_past_date = Value(datetime.min.replace(tzinfo=UTC), output_field=DateTimeField()) @@ -269,22 +284,20 @@ def get(self, request: Request, organization) -> Response: ), ) issue_rules = issue_rules.annotate(date_triggered=far_past_date) - alert_rules_count = alert_rules.count() - issue_rules_count = issue_rules.count() + uptime_rules = uptime_rules.annotate(date_triggered=far_past_date) alert_rule_intermediary = CombinedQuerysetIntermediary(alert_rules, sort_key) rule_intermediary = CombinedQuerysetIntermediary(issue_rules, rule_sort_key) + uptime_intermediary = CombinedQuerysetIntermediary(uptime_rules, rule_sort_key) response = self.paginate( request, paginator_cls=CombinedQuerysetPaginator, on_results=lambda x: serialize(x, request.user, CombinedRuleSerializer(expand=expand)), default_per_page=25, - intermediaries=[alert_rule_intermediary, rule_intermediary], + intermediaries=[alert_rule_intermediary, rule_intermediary, uptime_intermediary], desc=not is_asc, cursor_cls=StringCursor if case_insensitive else Cursor, case_insensitive=case_insensitive, ) - response["X-Sentry-Issue-Rule-Hits"] = issue_rules_count - response[ALERT_RULES_COUNT_HEADER] = alert_rules_count response[MAX_QUERY_SUBSCRIPTIONS_HEADER] = settings.MAX_QUERY_SUBSCRIPTIONS_PER_ORG return response diff --git a/src/sentry/incidents/endpoints/serializers/alert_rule.py b/src/sentry/incidents/endpoints/serializers/alert_rule.py index 8fe72fcc3118f4..4c6745f70d83dc 100644 --- a/src/sentry/incidents/endpoints/serializers/alert_rule.py +++ b/src/sentry/incidents/endpoints/serializers/alert_rule.py @@ -30,6 +30,7 @@ from sentry.sentry_apps.services.app import app_service from sentry.sentry_apps.services.app.model import RpcSentryAppComponentContext from sentry.snuba.models import SnubaQueryEventType +from sentry.uptime.models import ProjectUptimeSubscription from sentry.users.services.user import RpcUser from sentry.users.services.user.service import user_service @@ -395,6 +396,14 @@ def get_attrs( serialized_rule["id"]: serialized_rule for serialized_rule in serialized_issue_rules } + serialized_uptime_monitors = serialize( + [x for x in item_list if isinstance(x, ProjectUptimeSubscription)], + user=user, + ) + serialized_uptime_monitor_map_by_id = { + item["id"]: item for item in serialized_uptime_monitors + } + for item in item_list: item_id = str(item.id) if item_id in serialized_alert_rule_map_by_id: @@ -418,6 +427,9 @@ def get_attrs( elif item_id in serialized_issue_rule_map_by_id: # This is an issue alert rule results[item] = serialized_issue_rule_map_by_id[item_id] + elif item_id in serialized_uptime_monitor_map_by_id: + # This is an uptime monitor + results[item] = serialized_uptime_monitor_map_by_id[item_id] else: logger.error( "Alert Rule found but dropped during serialization", @@ -432,18 +444,18 @@ def get_attrs( def serialize( self, - obj: Rule | AlertRule, + obj: Rule | AlertRule | ProjectUptimeSubscription, attrs: Mapping[Any, Any], user: User | RpcUser, **kwargs: Any, ) -> MutableMapping[Any, Any]: + updated_attrs = {**attrs} if isinstance(obj, AlertRule): - alert_rule_attrs: MutableMapping[Any, Any] = {**attrs} - alert_rule_attrs["type"] = "alert_rule" - return alert_rule_attrs + updated_attrs["type"] = "alert_rule" elif isinstance(obj, Rule): - rule_attrs: MutableMapping[Any, Any] = {**attrs} - rule_attrs["type"] = "rule" - return rule_attrs + updated_attrs["type"] = "rule" + elif isinstance(obj, ProjectUptimeSubscription): + updated_attrs["type"] = "uptime" else: raise AssertionError(f"Invalid rule to serialize: {type(obj)}") + return updated_attrs diff --git a/src/sentry/models/activity.py b/src/sentry/models/activity.py index cfe925edc3ee89..32ceb5baba8921 100644 --- a/src/sentry/models/activity.py +++ b/src/sentry/models/activity.py @@ -115,7 +115,7 @@ class Activity(Model): # if the user is not set, it's assumed to be the system user_id = HybridCloudForeignKey(settings.AUTH_USER_MODEL, null=True, on_delete="SET_NULL") datetime = models.DateTimeField(default=timezone.now) - data: models.Field[dict[str, Any], dict[str, Any]] = GzippedDictField(null=True) + data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField(null=True) objects: ClassVar[ActivityManager] = ActivityManager() diff --git a/src/sentry/models/authidentity.py b/src/sentry/models/authidentity.py index 3554ba202438e7..a85b23f85225ee 100644 --- a/src/sentry/models/authidentity.py +++ b/src/sentry/models/authidentity.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Collection from typing import Any @@ -25,7 +27,7 @@ class AuthIdentity(ReplicatedControlModel): user = FlexibleForeignKey(settings.AUTH_USER_MODEL) auth_provider = FlexibleForeignKey("sentry.AuthProvider") ident = models.CharField(max_length=128) - data = JSONField() + data: models.Field[dict[str, Any], dict[str, Any]] = JSONField() last_verified = models.DateTimeField(default=timezone.now) last_synced = models.DateTimeField(default=timezone.now) date_added = models.DateTimeField(default=timezone.now) diff --git a/src/sentry/models/authprovider.py b/src/sentry/models/authprovider.py index ede445479b6e89..1b0dc92c7096de 100644 --- a/src/sentry/models/authprovider.py +++ b/src/sentry/models/authprovider.py @@ -54,7 +54,7 @@ class AuthProvider(ReplicatedControlModel): organization_id = HybridCloudForeignKey("sentry.Organization", on_delete="cascade", unique=True) provider = models.CharField(max_length=128) - config = JSONField() + config: models.Field[dict[str, Any], dict[str, Any]] = JSONField() date_added = models.DateTimeField(default=timezone.now) sync_time = BoundedPositiveIntegerField(null=True) diff --git a/src/sentry/models/dashboard.py b/src/sentry/models/dashboard.py index 53e00772fe95bc..7ab00fe22b271d 100644 --- a/src/sentry/models/dashboard.py +++ b/src/sentry/models/dashboard.py @@ -41,7 +41,7 @@ class Dashboard(Model): visits = BoundedBigIntegerField(null=True, default=1) last_visited = models.DateTimeField(null=True, default=timezone.now) projects = models.ManyToManyField("sentry.Project", through=DashboardProject) - filters = JSONField(null=True) + filters: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) MAX_WIDGETS = 30 diff --git a/src/sentry/models/debugfile.py b/src/sentry/models/debugfile.py index cdb46cf2370172..ea0d64793ac8b8 100644 --- a/src/sentry/models/debugfile.py +++ b/src/sentry/models/debugfile.py @@ -129,7 +129,7 @@ class ProjectDebugFile(Model): project_id = BoundedBigIntegerField(null=True) debug_id = models.CharField(max_length=64, db_column="uuid") code_id = models.CharField(max_length=64, null=True) - data = JSONField(null=True) + data: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) date_accessed = models.DateTimeField(default=timezone.now) objects: ClassVar[ProjectDebugFileManager] = ProjectDebugFileManager() diff --git a/src/sentry/models/files/abstractfile.py b/src/sentry/models/files/abstractfile.py index a963485cfd0382..946e659214413e 100644 --- a/src/sentry/models/files/abstractfile.py +++ b/src/sentry/models/files/abstractfile.py @@ -8,7 +8,7 @@ import tempfile from concurrent.futures import ThreadPoolExecutor from hashlib import sha1 -from typing import ClassVar +from typing import Any, ClassVar import sentry_sdk from django.core.files.base import ContentFile @@ -20,6 +20,7 @@ from sentry.celery import SentryTask from sentry.db.models import BoundedPositiveIntegerField, JSONField, Model from sentry.models.files.abstractfileblob import AbstractFileBlob +from sentry.models.files.abstractfileblobindex import AbstractFileBlobIndex from sentry.models.files.utils import DEFAULT_BLOB_SIZE, AssembleChecksumMismatch, nooplogger from sentry.utils import metrics from sentry.utils.db import atomic_transaction @@ -202,7 +203,7 @@ class AbstractFile(Model): name = models.TextField() type = models.CharField(max_length=64) timestamp = models.DateTimeField(default=timezone.now, db_index=True) - headers = JSONField() + headers: models.Field[dict[str, Any], dict[str, Any]] = JSONField() size = BoundedPositiveIntegerField(null=True) checksum = models.CharField(max_length=40, null=True, db_index=True) @@ -212,7 +213,7 @@ class Meta: # abstract # XXX: uses `builtins.type` to avoid clash with `type` local FILE_BLOB_MODEL: ClassVar[builtins.type[AbstractFileBlob]] - FILE_BLOB_INDEX_MODEL: ClassVar[builtins.type[Model]] + FILE_BLOB_INDEX_MODEL: ClassVar[builtins.type[AbstractFileBlobIndex]] DELETE_UNREFERENCED_BLOB_TASK: ClassVar[SentryTask] blobs: models.ManyToManyField diff --git a/src/sentry/models/group.py b/src/sentry/models/group.py index 42710910fb3bbc..54134b9646d6dd 100644 --- a/src/sentry/models/group.py +++ b/src/sentry/models/group.py @@ -565,7 +565,9 @@ class Group(Model): score = BoundedIntegerField(default=0) # deprecated, do not use. GroupShare has superseded is_public = models.BooleanField(default=False, null=True) - data: models.Field[dict[str, Any], dict[str, Any]] = GzippedDictField(blank=True, null=True) + data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField( + blank=True, null=True + ) short_id = BoundedBigIntegerField(null=True) type = BoundedPositiveIntegerField(default=ErrorGroupType.type_id, db_index=True) priority = models.PositiveSmallIntegerField(null=True) diff --git a/src/sentry/models/grouplink.py b/src/sentry/models/grouplink.py index 779d7699341a73..ff6f1cb52857e5 100644 --- a/src/sentry/models/grouplink.py +++ b/src/sentry/models/grouplink.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING, ClassVar +from typing import TYPE_CHECKING, Any, ClassVar from django.db import models from django.db.models import QuerySet @@ -71,7 +71,7 @@ class LinkedType: default=Relationship.references, choices=((Relationship.resolves, _("Resolves")), (Relationship.references, _("Linked"))), ) - data = JSONField() + data: models.Field[dict[str, Any], dict[str, Any]] = JSONField() datetime = models.DateTimeField(default=timezone.now, db_index=True) objects: ClassVar[GroupLinkManager] = GroupLinkManager() diff --git a/src/sentry/models/groupsnooze.py b/src/sentry/models/groupsnooze.py index c77d288490838a..d45e959fd7838f 100644 --- a/src/sentry/models/groupsnooze.py +++ b/src/sentry/models/groupsnooze.py @@ -1,7 +1,7 @@ from __future__ import annotations from datetime import timedelta -from typing import TYPE_CHECKING, ClassVar, Self +from typing import TYPE_CHECKING, Any, ClassVar, Self from django.db import models from django.db.models.signals import post_delete, post_save @@ -53,7 +53,7 @@ class GroupSnooze(Model): window = BoundedPositiveIntegerField(null=True) user_count = BoundedPositiveIntegerField(null=True) user_window = BoundedPositiveIntegerField(null=True) - state = JSONField(null=True) + state: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) actor_id = BoundedPositiveIntegerField(null=True) objects: ClassVar[BaseManager[Self]] = BaseManager(cache_fields=("group",)) @@ -87,6 +87,7 @@ def is_valid( return False else: times_seen = group.times_seen_with_pending if use_pending_data else group.times_seen + assert self.state is not None if self.count <= times_seen - self.state["times_seen"]: return False @@ -200,10 +201,10 @@ def test_user_rates(self) -> bool: def test_user_counts(self, group: Group) -> bool: cache_key = f"groupsnooze:v1:{self.id}:test_user_counts:events_seen_counter" - try: - users_seen = self.state["users_seen"] - except (KeyError, TypeError): + if self.state is None: users_seen = 0 + else: + users_seen = self.state.get("users_seen", 0) threshold = self.user_count + users_seen diff --git a/src/sentry/models/grouptombstone.py b/src/sentry/models/grouptombstone.py index 386b87e09f6ef7..892bd3a31c895d 100644 --- a/src/sentry/models/grouptombstone.py +++ b/src/sentry/models/grouptombstone.py @@ -1,4 +1,7 @@ +from __future__ import annotations + import logging +from typing import Any from django.db import models @@ -29,7 +32,9 @@ class GroupTombstone(Model): ) message = models.TextField() culprit = models.CharField(max_length=MAX_CULPRIT_LENGTH, blank=True, null=True) - data = GzippedDictField(blank=True, null=True) + data: models.Field[dict[str, Any] | None, dict[str, Any]] = GzippedDictField( + blank=True, null=True + ) actor_id = BoundedPositiveIntegerField(null=True) class Meta: diff --git a/src/sentry/models/identity.py b/src/sentry/models/identity.py index 4c7fe96d177f27..81b7b4fc42d95f 100644 --- a/src/sentry/models/identity.py +++ b/src/sentry/models/identity.py @@ -53,7 +53,7 @@ class IdentityProvider(Model): __relocation_scope__ = RelocationScope.Excluded type = models.CharField(max_length=64) - config = JSONField() + config: models.Field[dict[str, Any], dict[str, Any]] = JSONField() date_added = models.DateTimeField(default=timezone.now, null=True) external_id = models.CharField(max_length=64, null=True) @@ -197,7 +197,7 @@ class Identity(Model): idp = FlexibleForeignKey("sentry.IdentityProvider") user = FlexibleForeignKey(settings.AUTH_USER_MODEL) external_id = models.TextField() - data = JSONField() + data: models.Field[dict[str, Any], dict[str, Any]] = JSONField() status = BoundedPositiveIntegerField(default=IdentityStatus.UNKNOWN) scopes = ArrayField() date_verified = models.DateTimeField(default=timezone.now) diff --git a/src/sentry/models/integrations/integration.py b/src/sentry/models/integrations/integration.py index d7c886a7405cbd..9a69419e9b429b 100644 --- a/src/sentry/models/integrations/integration.py +++ b/src/sentry/models/integrations/integration.py @@ -55,7 +55,7 @@ class Integration(DefaultFieldsModel): # metadata might be used to store things like credentials, but it should NOT # be used to store organization-specific information, as an Integration # instance can be shared by multiple organizations - metadata = JSONField(default=dict) + metadata: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict) status = BoundedPositiveIntegerField( default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices(), null=True ) diff --git a/src/sentry/models/integrations/sentry_app_component.py b/src/sentry/models/integrations/sentry_app_component.py index 27ae523a5d45b2..1f832fc0faf4b0 100644 --- a/src/sentry/models/integrations/sentry_app_component.py +++ b/src/sentry/models/integrations/sentry_app_component.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import MutableMapping from typing import Any @@ -17,7 +19,7 @@ class SentryAppComponent(Model): uuid = UUIDField(unique=True, auto_add=True) sentry_app = FlexibleForeignKey("sentry.SentryApp", related_name="components") type = models.CharField(max_length=64) - schema = JSONField() + schema: models.Field[dict[str, Any], dict[str, Any]] = JSONField() class Meta: app_label = "sentry" diff --git a/src/sentry/models/notificationmessage.py b/src/sentry/models/notificationmessage.py index c7f0a2bcd1f270..4071c92f5c9eba 100644 --- a/src/sentry/models/notificationmessage.py +++ b/src/sentry/models/notificationmessage.py @@ -1,4 +1,8 @@ -from django.db.models import DateTimeField, IntegerField, Q +from __future__ import annotations + +from typing import Any + +from django.db.models import DateTimeField, Field, IntegerField, Q from django.db.models.constraints import CheckConstraint, UniqueConstraint from django.utils import timezone @@ -36,7 +40,7 @@ class NotificationMessage(Model): # Related information regarding failed notifications. # Leveraged to help give the user visibility into notifications that are consistently failing. - error_details = JSONField(null=True) + error_details: Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) error_code = IntegerField(null=True, db_index=True) # Resulting identifier from the vendor that can be leveraged for future interaction with the notification. diff --git a/src/sentry/models/organizationonboardingtask.py b/src/sentry/models/organizationonboardingtask.py index 1cf418fe39c3eb..c60905c71ce400 100644 --- a/src/sentry/models/organizationonboardingtask.py +++ b/src/sentry/models/organizationonboardingtask.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import ClassVar +from typing import Any, ClassVar from django.conf import settings from django.core.cache import cache @@ -101,9 +101,10 @@ class AbstractOnboardingTask(Model): completion_seen = models.DateTimeField(null=True) date_completed = models.DateTimeField(default=timezone.now) project = FlexibleForeignKey("sentry.Project", db_constraint=False, null=True) - data = JSONField() # INVITE_MEMBER { invited_member: user.id } + # INVITE_MEMBER { invited_member: user.id } + data: models.Field[dict[str, Any], dict[str, Any]] = JSONField() - # fields for typing + # abstract TASK_LOOKUP_BY_KEY: dict[str, int] SKIPPABLE_TASKS: frozenset[int] diff --git a/src/sentry/models/projectownership.py b/src/sentry/models/projectownership.py index 4dafe566821ac0..56026f480d928c 100644 --- a/src/sentry/models/projectownership.py +++ b/src/sentry/models/projectownership.py @@ -40,7 +40,7 @@ class ProjectOwnership(Model): project = FlexibleForeignKey("sentry.Project", unique=True) raw = models.TextField(null=True) - schema = JSONField(null=True) + schema: models.Field[dict[str, Any] | None, dict[str, Any] | None] = JSONField(null=True) fallthrough = models.BooleanField(default=True) # Auto Assignment through Ownership Rules & Code Owners auto_assignment = models.BooleanField(default=True) diff --git a/src/sentry/models/repository.py b/src/sentry/models/repository.py index 5a9b3111ee6688..eebfcd3c97834b 100644 --- a/src/sentry/models/repository.py +++ b/src/sentry/models/repository.py @@ -34,7 +34,7 @@ class Repository(Model, PendingDeletionMixin): provider = models.CharField(max_length=64, null=True) # The external_id is the id of the repo in the provider's system. (e.g. GitHub's repo id) external_id = models.CharField(max_length=64, null=True) - config = JSONField(default=dict) + config: models.Field[dict[str, Any], dict[str, Any]] = JSONField(default=dict) status = BoundedPositiveIntegerField( default=ObjectStatus.ACTIVE, choices=ObjectStatus.as_choices(), db_index=True ) diff --git a/src/sentry/models/rule.py b/src/sentry/models/rule.py index 4f58577c7a3e19..8939cfcc99bce3 100644 --- a/src/sentry/models/rule.py +++ b/src/sentry/models/rule.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from collections.abc import Sequence from enum import Enum, IntEnum from typing import Any, ClassVar, Self @@ -46,7 +48,7 @@ class Rule(Model): environment_id = BoundedPositiveIntegerField(null=True) label = models.CharField(max_length=256) # `data` contain all the specifics of the rule - conditions, actions, frequency, etc. - data = GzippedDictField() + data: models.Field[dict[str, Any], dict[str, Any]] = GzippedDictField() status = BoundedPositiveIntegerField( default=ObjectStatus.ACTIVE, choices=((ObjectStatus.ACTIVE, "Active"), (ObjectStatus.DISABLED, "Disabled")), diff --git a/src/sentry/ownership/grammar.py b/src/sentry/ownership/grammar.py index fe7dcd178bca3a..f9b4c04d238397 100644 --- a/src/sentry/ownership/grammar.py +++ b/src/sentry/ownership/grammar.py @@ -3,7 +3,7 @@ import re from collections import namedtuple from collections.abc import Callable, Iterable, Mapping, Sequence -from typing import Any +from typing import Any, NamedTuple from parsimonious.exceptions import ParseError from parsimonious.grammar import Grammar @@ -80,7 +80,7 @@ def __str__(self) -> str: ) return f"{self.matcher} {owners_str}" - def dump(self) -> Mapping[str, Sequence[Owner]]: + def dump(self) -> dict[str, Sequence[Owner]]: return {"matcher": self.matcher.dump(), "owners": [o.dump() for o in self.owners]} @classmethod @@ -109,7 +109,7 @@ class Matcher(namedtuple("Matcher", "type pattern")): def __str__(self) -> str: return f"{self.type}:{self.pattern}" - def dump(self) -> Mapping[str, str]: + def dump(self) -> dict[str, str]: return {"type": self.type, "pattern": self.pattern} @classmethod @@ -206,7 +206,7 @@ def test_tag(self, data: PathSearchable) -> bool: return False -class Owner(namedtuple("Owner", "type identifier")): +class Owner(NamedTuple): """ An Owner represents a User or Team who owns this Rule. @@ -217,7 +217,10 @@ class Owner(namedtuple("Owner", "type identifier")): #team """ - def dump(self) -> Mapping[str, str]: + type: str + identifier: str + + def dump(self) -> dict[str, str]: return {"type": self.type, "identifier": self.identifier} @classmethod @@ -228,7 +231,7 @@ def load(cls, data: Mapping[str, str]) -> Owner: class OwnershipVisitor(NodeVisitor): visit_comment = visit_empty = lambda *a: None - def visit_ownership(self, node: Node, children: Sequence[Rule | None]) -> Sequence[Rule]: + def visit_ownership(self, node: Node, children: Sequence[Rule | None]) -> list[Rule]: return [_f for _f in children if _f] def visit_line(self, node: Node, children: tuple[Node, Sequence[Rule | None], Any]) -> Any: @@ -252,7 +255,7 @@ def visit_matcher_tag(self, node: Node, children: Sequence[Any]) -> str: type, _ = tag return str(type[0].text) - def visit_owners(self, node: Node, children: tuple[Any, Sequence[Owner]]) -> Sequence[Owner]: + def visit_owners(self, node: Node, children: tuple[Any, Sequence[Owner]]) -> list[Owner]: _, owners = children return owners @@ -277,7 +280,7 @@ def visit_identifier(self, node: Node, children: Sequence[Any]) -> str: def visit_quoted_identifier(self, node: Node, children: Sequence[Any]) -> str: return str(node.text[1:-1].encode("ascii", "backslashreplace").decode("unicode-escape")) - def generic_visit(self, node: Node, children: Sequence[Any]) -> Sequence[Node] | Node: + def generic_visit(self, node: Node, children: Sequence[Any]) -> list[Node] | Node: return children or node @@ -287,12 +290,12 @@ def parse_rules(data: str) -> Any: return OwnershipVisitor().visit(tree) -def dump_schema(rules: Sequence[Rule]) -> Mapping[str, Any]: +def dump_schema(rules: Sequence[Rule]) -> dict[str, Any]: """Convert a Rule tree into a JSON schema""" return {"$version": VERSION, "rules": [r.dump() for r in rules]} -def load_schema(schema: Mapping[str, Any]) -> Sequence[Rule]: +def load_schema(schema: Mapping[str, Any]) -> list[Rule]: """Convert a JSON schema into a Rule tree""" if schema["$version"] != VERSION: raise RuntimeError("Invalid schema $version: %r" % schema["$version"]) @@ -420,7 +423,7 @@ def convert_codeowners_syntax( return result -def resolve_actors(owners: Iterable[Owner], project_id: int) -> Mapping[Owner, Actor]: +def resolve_actors(owners: Iterable[Owner], project_id: int) -> dict[Owner, Actor]: """Convert a list of Owner objects into a dictionary of {Owner: Actor} pairs. Actors not identified are returned as None.""" @@ -514,7 +517,7 @@ def create_schema_from_issue_owners( issue_owners: str | None, add_owner_ids: bool = False, remove_deleted_owners: bool = False, -) -> Mapping[str, Any] | None: +) -> dict[str, Any] | None: if issue_owners is None: return None diff --git a/src/sentry/rules/match.py b/src/sentry/rules/match.py index bbf97737eab675..6a55a43ff6a2fa 100644 --- a/src/sentry/rules/match.py +++ b/src/sentry/rules/match.py @@ -9,6 +9,7 @@ class MatchType: GREATER_OR_EQUAL = "gte" GREATER = "gt" IS_SET = "is" + IS_IN = "in" LESS_OR_EQUAL = "lte" LESS = "lt" NOT_CONTAINS = "nc" @@ -17,7 +18,6 @@ class MatchType: NOT_SET = "ns" NOT_STARTS_WITH = "nsw" STARTS_WITH = "sw" - IS_IN = "in" LEVEL_MATCH_CHOICES = { @@ -37,13 +37,13 @@ class MatchType: MatchType.ENDS_WITH: "ends with", MatchType.EQUAL: "equals", MatchType.IS_SET: "is set", + MatchType.IS_IN: "is in (comma separated)", MatchType.NOT_CONTAINS: "does not contain", MatchType.NOT_ENDS_WITH: "does not end with", MatchType.NOT_EQUAL: "does not equal", MatchType.NOT_SET: "is not set", MatchType.NOT_STARTS_WITH: "does not start with", MatchType.STARTS_WITH: "starts with", - MatchType.IS_IN: "is in (comma separated)", } diff --git a/src/sentry/rules/processing/delayed_processing.py b/src/sentry/rules/processing/delayed_processing.py index e8a41490b0aeda..0bb7c1fcd7c1c5 100644 --- a/src/sentry/rules/processing/delayed_processing.py +++ b/src/sentry/rules/processing/delayed_processing.py @@ -271,11 +271,8 @@ def get_rules_to_fire( if action_match == "any": rules_to_fire[alert_rule].add(group_id) break - conditions_matched += 1 - else: - if action_match == "all": - # We failed to match all conditions for this group, skip - break + elif action_match == "all": + conditions_matched += 1 if action_match == "all" and conditions_matched == len(slow_conditions): rules_to_fire[alert_rule].add(group_id) return rules_to_fire diff --git a/src/sentry/testutils/factories.py b/src/sentry/testutils/factories.py index 7771b94b71da38..954112b7d217a0 100644 --- a/src/sentry/testutils/factories.py +++ b/src/sentry/testutils/factories.py @@ -152,11 +152,13 @@ from sentry.testutils.outbox import outbox_runner from sentry.testutils.silo import assume_test_silo_mode from sentry.types.activity import ActivityType +from sentry.types.actor import Actor from sentry.types.region import Region, get_local_region, get_region_by_name from sentry.types.token import AuthTokenType from sentry.uptime.models import ( ProjectUptimeSubscription, ProjectUptimeSubscriptionMode, + UptimeStatus, UptimeSubscription, ) from sentry.users.services.user import RpcUser @@ -1762,7 +1764,7 @@ def create_organization_integration(**integration_params: Any) -> OrganizationIn @assume_test_silo_mode(SiloMode.CONTROL) def create_identity_provider( integration: Integration | None = None, - config: Mapping[str, Any] | None = None, + config: dict[str, Any] | None = None, **kwargs: Any, ) -> IdentityProvider: if integration is not None: @@ -1950,9 +1952,24 @@ def create_project_uptime_subscription( project: Project, uptime_subscription: UptimeSubscription, mode: ProjectUptimeSubscriptionMode, + name: str, + owner: Actor | None, + uptime_status: UptimeStatus, ): + owner_team_id = None + owner_user_id = None + if owner: + if owner.is_team: + owner_team_id = owner.id + elif owner.is_user: + owner_user_id = owner.id + return ProjectUptimeSubscription.objects.create( uptime_subscription=uptime_subscription, project=project, mode=mode, + name=name, + owner_team_id=owner_team_id, + owner_user_id=owner_user_id, + uptime_status=uptime_status, ) diff --git a/src/sentry/testutils/fixtures.py b/src/sentry/testutils/fixtures.py index 39a7352ba6a421..c0ff384cfb2056 100644 --- a/src/sentry/testutils/fixtures.py +++ b/src/sentry/testutils/fixtures.py @@ -22,6 +22,7 @@ from sentry.models.project import Project from sentry.models.projecttemplate import ProjectTemplate from sentry.models.rule import Rule +from sentry.models.team import Team from sentry.models.user import User from sentry.monitors.models import Monitor, MonitorType, ScheduleType from sentry.organizations.services.organization import RpcOrganization @@ -34,9 +35,11 @@ # all of the memoized fixtures are copypasta due to our inability to use pytest fixtures # on a per-class method basis from sentry.types.activity import ActivityType +from sentry.types.actor import Actor from sentry.uptime.models import ( ProjectUptimeSubscription, ProjectUptimeSubscriptionMode, + UptimeStatus, UptimeSubscription, ) from sentry.users.services.user import RpcUser @@ -593,7 +596,7 @@ def create_identity(self, *args, **kwargs): def create_identity_provider( self, integration: Integration | None = None, - config: Mapping[str, Any] | None = None, + config: dict[str, Any] | None = None, **kwargs: Any, ) -> IdentityProvider: return Factories.create_identity_provider(integration=integration, config=config, **kwargs) @@ -647,13 +650,23 @@ def create_project_uptime_subscription( project: Project | None = None, uptime_subscription: UptimeSubscription | None = None, mode=ProjectUptimeSubscriptionMode.AUTO_DETECTED_ACTIVE, + name="Test Name", + owner: User | Team | None = None, + uptime_status=UptimeStatus.OK, ) -> ProjectUptimeSubscription: if project is None: project = self.project if uptime_subscription is None: uptime_subscription = self.create_uptime_subscription() - return Factories.create_project_uptime_subscription(project, uptime_subscription, mode) + return Factories.create_project_uptime_subscription( + project, + uptime_subscription, + mode, + name, + Actor.from_object(owner) if owner else None, + uptime_status, + ) @pytest.fixture(autouse=True) def _init_insta_snapshot(self, insta_snapshot): diff --git a/src/sentry/types/actor.py b/src/sentry/types/actor.py index 5fea404e3c91f7..f10c03576db1d7 100644 --- a/src/sentry/types/actor.py +++ b/src/sentry/types/actor.py @@ -40,7 +40,9 @@ class InvalidActor(ObjectDoesNotExist): pass @classmethod - def resolve_many(cls, actors: Sequence["Actor"]) -> list["Team | RpcUser"]: + def resolve_many( + cls, actors: Sequence["Actor"], filter_none: bool = True + ) -> list["Team | RpcUser | None"]: """ Resolve a list of actors in a batch to the Team/User the Actor references. @@ -64,7 +66,10 @@ def resolve_many(cls, actors: Sequence["Actor"]) -> list["Team | RpcUser"]: for team in Team.objects.filter(id__in=[t.id for t in actor_list]): results[(actor_type, team.id)] = team - return list(filter(None, [results.get((actor.actor_type, actor.id)) for actor in actors])) + final_results = [results.get((actor.actor_type, actor.id)) for actor in actors] + if filter_none: + final_results = list(filter(None, final_results)) + return final_results @classmethod def many_from_object(cls, objects: Iterable[ActorTarget]) -> list["Actor"]: diff --git a/src/sentry/uptime/apps.py b/src/sentry/uptime/apps.py index 85e532542b698e..a36058cb3931a4 100644 --- a/src/sentry/uptime/apps.py +++ b/src/sentry/uptime/apps.py @@ -5,4 +5,4 @@ class Config(AppConfig): name = "sentry.uptime" def ready(self): - pass + from sentry.uptime.endpoints import serializers # NOQA diff --git a/src/sentry/uptime/consumers/results_consumer.py b/src/sentry/uptime/consumers/results_consumer.py index 0d18033e61c25d..4fe9e60e3c2d47 100644 --- a/src/sentry/uptime/consumers/results_consumer.py +++ b/src/sentry/uptime/consumers/results_consumer.py @@ -10,6 +10,7 @@ CheckResult, ) +from sentry import features from sentry.conf.types.kafka_definition import Topic from sentry.remote_subscriptions.consumers.result_consumer import ( ResultProcessor, @@ -175,13 +176,19 @@ def handle_result_for_project_active_mode( project_subscription.uptime_status == UptimeStatus.OK and result["status"] == CHECKSTATUS_FAILURE ): - create_issue_platform_occurrence(result, project_subscription) + if features.has( + "organizations:uptime-create-issues", project_subscription.project.organization + ): + create_issue_platform_occurrence(result, project_subscription) project_subscription.update(uptime_status=UptimeStatus.FAILED) elif ( project_subscription.uptime_status == UptimeStatus.FAILED and result["status"] == CHECKSTATUS_SUCCESS ): - resolve_uptime_issue(project_subscription) + if features.has( + "organizations:uptime-create-issues", project_subscription.project.organization + ): + resolve_uptime_issue(project_subscription) project_subscription.update(uptime_status=UptimeStatus.OK) diff --git a/src/sentry/uptime/detectors/detector.py b/src/sentry/uptime/detectors/detector.py index 58e70ffb668184..d0d9d999423647 100644 --- a/src/sentry/uptime/detectors/detector.py +++ b/src/sentry/uptime/detectors/detector.py @@ -3,7 +3,11 @@ from typing import TYPE_CHECKING from sentry import features -from sentry.uptime.detectors.ranking import add_base_url_to_rank, should_detect_for_project +from sentry.uptime.detectors.ranking import ( + add_base_url_to_rank, + should_detect_for_organization, + should_detect_for_project, +) from sentry.uptime.detectors.url_extraction import extract_base_url from sentry.utils import metrics @@ -14,9 +18,11 @@ def detect_base_url_for_project(project: Project, url: str) -> None: # Note: We might end up removing the `should_detect_for_project` check here if/when we decide to use detected # urls as suggestions as well. - if not features.has( - "organizations:uptime-automatic-hostname-detection", project.organization - ) or not should_detect_for_project(project): + if ( + not features.has("organizations:uptime-automatic-hostname-detection", project.organization) + or not should_detect_for_project(project) + or not should_detect_for_organization(project.organization) + ): return base_url = extract_base_url(url) diff --git a/src/sentry/uptime/detectors/ranking.py b/src/sentry/uptime/detectors/ranking.py index 65a2b06bd0086a..eaee986a3228f0 100644 --- a/src/sentry/uptime/detectors/ranking.py +++ b/src/sentry/uptime/detectors/ranking.py @@ -8,6 +8,7 @@ from redis.client import StrictRedis from rediscluster import RedisCluster +from sentry.constants import UPTIME_AUTODETECTION from sentry.utils import redis if TYPE_CHECKING: @@ -162,7 +163,8 @@ def delete_organization_bucket(bucket: datetime) -> None: def should_detect_for_organization(organization: Organization) -> bool: - # TODO: Check setting here + if not organization.get_option("sentry:uptime_autodetection", UPTIME_AUTODETECTION): + return False return True diff --git a/src/sentry/uptime/endpoints/__init__.py b/src/sentry/uptime/endpoints/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/src/sentry/uptime/endpoints/serializers.py b/src/sentry/uptime/endpoints/serializers.py new file mode 100644 index 00000000000000..e2df2237665012 --- /dev/null +++ b/src/sentry/uptime/endpoints/serializers.py @@ -0,0 +1,59 @@ +from collections.abc import MutableMapping, Sequence +from typing import Any, TypedDict + +from django.db.models import prefetch_related_objects + +from sentry.api.serializers import Serializer, register, serialize +from sentry.api.serializers.models.actor import ActorSerializer, ActorSerializerResponse +from sentry.types.actor import Actor +from sentry.uptime.models import ProjectUptimeSubscription + + +class ProjectUptimeSubscriptionSerializerResponse(TypedDict): + id: str + projectSlug: str + name: str + status: int + mode: int + url: str + intervalSeconds: int + timeoutMs: int + owner: ActorSerializerResponse + + +@register(ProjectUptimeSubscription) +class ProjectUptimeSubscriptionSerializer(Serializer): + def __init__(self, expand=None): + self.expand = expand + + def get_attrs( + self, item_list: Sequence[ProjectUptimeSubscription], user: Any, **kwargs: Any + ) -> MutableMapping[Any, Any]: + prefetch_related_objects(item_list, "uptime_subscription", "project") + owners = list(filter(None, [item.owner for item in item_list])) + owners_serialized = serialize( + Actor.resolve_many(owners, filter_none=False), user, ActorSerializer() + ) + serialized_owner_lookup = { + owner: serialized_owner for owner, serialized_owner in zip(owners, owners_serialized) + } + + return { + item: {"owner": serialized_owner_lookup.get(item.owner) if item.owner else None} + for item in item_list + } + + def serialize( + self, obj: ProjectUptimeSubscription, attrs, user, **kwargs + ) -> ProjectUptimeSubscriptionSerializerResponse: + return { + "id": str(obj.id), + "projectSlug": obj.project.slug, + "name": obj.name, + "status": obj.uptime_status, + "mode": obj.mode, + "url": obj.uptime_subscription.url, + "intervalSeconds": obj.uptime_subscription.interval_seconds, + "timeoutMs": obj.uptime_subscription.timeout_ms, + "owner": attrs["owner"], + } diff --git a/src/sentry/uptime/models.py b/src/sentry/uptime/models.py index 44d782007a1b29..6ebfdd1356c4d3 100644 --- a/src/sentry/uptime/models.py +++ b/src/sentry/uptime/models.py @@ -11,6 +11,7 @@ from sentry.db.models.fields.hybrid_cloud_foreign_key import HybridCloudForeignKey from sentry.db.models.manager.base import BaseManager from sentry.remote_subscriptions.models import BaseRemoteSubscription +from sentry.types.actor import Actor @region_silo_model @@ -104,3 +105,7 @@ class Meta: ), ), ] + + @property + def owner(self) -> Actor | None: + return Actor.from_id(user_id=self.owner_user_id, team_id=self.owner_team_id) diff --git a/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx b/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx index 09dfd0db1f1bdd..08aa45f01383a3 100644 --- a/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx +++ b/static/app/components/devtoolbar/components/feedback/feedbackPanel.tsx @@ -142,7 +142,7 @@ function FeedbackListItem({item}: {item: FeedbackIssueListItem}) { -
+
({ + return useFetchInfiniteApiData({ queryKey: useMemo( () => [ `/organizations/${organizationSlug}/issues/`, diff --git a/static/app/components/devtoolbar/components/issues/issuesPanel.tsx b/static/app/components/devtoolbar/components/issues/issuesPanel.tsx index 8ee2d33249efb7..206023ad4de86e 100644 --- a/static/app/components/devtoolbar/components/issues/issuesPanel.tsx +++ b/static/app/components/devtoolbar/components/issues/issuesPanel.tsx @@ -111,7 +111,7 @@ function IssueListItem({item}: {item: Group}) { {item.metadata.value}
-
+
({ + return useFetchInfiniteApiData({ queryKey: useMemo( () => [ `/organizations/${organizationSlug}/issues/`, diff --git a/static/app/components/devtoolbar/hooks/useInfiniteApiData.tsx b/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx similarity index 87% rename from static/app/components/devtoolbar/hooks/useInfiniteApiData.tsx rename to static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx index 570e92d0b90c6f..8223a4211f5835 100644 --- a/static/app/components/devtoolbar/hooks/useInfiniteApiData.tsx +++ b/static/app/components/devtoolbar/hooks/useFetchInfiniteApiData.tsx @@ -8,7 +8,7 @@ interface Props { queryKey: ApiQueryKey; } -export default function useInfiniteApiData>({ +export default function useFetchInfiniteApiData>({ queryKey, }: Props) { const {fetchInfiniteFn, getNextPageParam, getPreviousPageParam} = useApiEndpoint(); diff --git a/static/app/components/devtoolbar/styles/listItem.ts b/static/app/components/devtoolbar/styles/listItem.ts index 9b41ff4c4fa2d7..0684ed082e62cd 100644 --- a/static/app/components/devtoolbar/styles/listItem.ts +++ b/static/app/components/devtoolbar/styles/listItem.ts @@ -13,7 +13,7 @@ export const listItemGridCss = css` grid-template-areas: 'name time' 'message message' - 'project icons'; + 'owner icons'; grid-template-columns: 1fr max-content; gap: var(--space50); diff --git a/static/app/components/eventOrGroupExtraDetails.tsx b/static/app/components/eventOrGroupExtraDetails.tsx index bb6ca083e2e222..a7d13273aed1bb 100644 --- a/static/app/components/eventOrGroupExtraDetails.tsx +++ b/static/app/components/eventOrGroupExtraDetails.tsx @@ -7,6 +7,7 @@ import TimesTag from 'sentry/components/group/inboxBadges/timesTag'; import UnhandledTag from 'sentry/components/group/inboxBadges/unhandledTag'; import IssueReplayCount from 'sentry/components/group/issueReplayCount'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; +import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import Placeholder from 'sentry/components/placeholder'; import {IconChat} from 'sentry/icons'; @@ -94,14 +95,20 @@ function EventOrGroupExtraDetails({data, showAssignee, organization}: Props) { )} - {annotations?.map((annotation, key) => ( - - ))} + {annotations?.map((annotation, key) => + typeof annotation === 'string' ? ( + + ) : ( + + {annotation.displayName} + + ) + )} {showAssignee && assignedTo && (
{tct('Assigned to [name]', {name: assignedTo.name})}
diff --git a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx index d949e5378350c4..38ea74d12b6ae9 100644 --- a/static/app/components/feedback/feedbackOnboarding/sidebar.tsx +++ b/static/app/components/feedback/feedbackOnboarding/sidebar.tsx @@ -270,7 +270,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { ) : ( newDocs?.platformOptions && widgetPlatform && - !crashReportOnboarding && ( + !crashReportOnboarding && + !isLoading && ( {tct("I'm using [platformSelect]", { platformSelect: ( diff --git a/static/app/components/issueLink.tsx b/static/app/components/issueLink.tsx deleted file mode 100644 index 7b80a1c0d55406..00000000000000 --- a/static/app/components/issueLink.tsx +++ /dev/null @@ -1,142 +0,0 @@ -import {Fragment} from 'react'; -import styled from '@emotion/styled'; -import classNames from 'classnames'; - -import Count from 'sentry/components/count'; -import EventOrGroupTitle from 'sentry/components/eventOrGroupTitle'; -import EventAnnotation from 'sentry/components/events/eventAnnotation'; -import EventMessage from 'sentry/components/events/eventMessage'; -import {Hovercard} from 'sentry/components/hovercard'; -import Link from 'sentry/components/links/link'; -import TimeSince from 'sentry/components/timeSince'; -import {t} from 'sentry/locale'; -import {space} from 'sentry/styles/space'; -import type {Group} from 'sentry/types/group'; -import {getMessage} from 'sentry/utils/events'; - -type Props = { - card: boolean; - children: React.ReactNode; - issue: Group; - orgId: string; - to: string; -}; - -function IssueLink({children, orgId, issue, to, card = true}: Props) { - if (!card) { - return {children}; - } - - const message = getMessage(issue); - - const className = classNames({ - isBookmarked: issue.isBookmarked, - hasSeen: issue.hasSeen, - isResolved: issue.status === 'resolved', - }); - - const streamPath = `/organizations/${orgId}/issues/`; - - const hovercardBody = ( -
-
- - <StyledEventOrGroupTitle data={issue} /> - - - - {issue.logger && ( - - - {issue.logger} - - - )} - {issue.annotations.map((annotation, i) => ( - - ))} - - } - type={issue.type} - /> -
- - -
- {t('First Seen')} - -
-
- {t('Last Seen')} - -
-
- {t('Occurrences')} - -
-
- {t('Users Affected')} - -
-
-
- ); - - return ( - - {children} - - ); -} - -export default IssueLink; - -const Title = styled('div')` - ${p => p.theme.overflowEllipsis}; - margin: 0 0 ${space(0.5)}; -`; - -const StyledEventOrGroupTitle = styled(EventOrGroupTitle)` - font-weight: ${p => p.theme.fontWeightBold}; - font-size: ${p => p.theme.fontSizeMedium}; - - em { - font-style: normal; - font-weight: ${p => p.theme.fontWeightNormal}; - font-size: ${p => p.theme.fontSizeSmall}; - } -`; - -const Section = styled('section')` - margin-bottom: ${space(2)}; -`; - -const Grid = styled('div')` - display: grid; - grid-template-columns: 1fr 1fr; - gap: ${space(2)}; -`; -const HovercardEventMessage = styled(EventMessage)` - font-size: 12px; -`; - -const GridHeader = styled('h5')` - color: ${p => p.theme.gray300}; - font-size: 11px; - margin-bottom: ${space(0.5)}; - text-transform: uppercase; -`; - -const StyledTimeSince = styled(TimeSince)` - color: inherit; -`; diff --git a/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx b/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx index ad98e25719fc33..fa9911f2eae393 100644 --- a/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx +++ b/static/app/components/onboarding/gettingStartedDoc/utils/index.tsx @@ -1,3 +1,4 @@ +import Alert from 'sentry/components/alert'; import ExternalLink from 'sentry/components/links/externalLink'; import {t, tct} from 'sentry/locale'; import type {Organization, PlatformKey} from 'sentry/types'; @@ -68,3 +69,16 @@ export function getUploadSourceMapsStep({ ], }; } + +export function MobileBetaBanner({link}: {link: string}) { + return ( + + {tct( + `Currently, Mobile Replay is in beta. You can [link:read our docs] to learn how to set it up for your project.`, + { + link: , + } + )} + + ); +} diff --git a/static/app/components/replays/contextIcon.tsx b/static/app/components/replays/contextIcon.tsx index 68b938e1131f5e..897aae3f7e5f11 100644 --- a/static/app/components/replays/contextIcon.tsx +++ b/static/app/components/replays/contextIcon.tsx @@ -1,12 +1,12 @@ -import {lazy, Suspense} from 'react'; import styled from '@emotion/styled'; +import {PlatformIcon} from 'platformicons'; -import {generateIconName} from 'sentry/components/events/contexts/utils'; -import LoadingMask from 'sentry/components/loadingMask'; import CountTooltipContent from 'sentry/components/replays/countTooltipContent'; import {Tooltip} from 'sentry/components/tooltip'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import {generatePlatformIconName} from 'sentry/utils/replays/generatePlatformIconName'; +import commonTheme from 'sentry/utils/theme'; type Props = { name: string; @@ -16,20 +16,17 @@ type Props = { showVersion?: boolean; }; -const LazyContextIcon = lazy( - () => import('sentry/components/events/contexts/contextIcon') -); +const iconSize = '16px'; +const iconStyle = { + border: '1px solid ' + commonTheme.translucentGray100, +}; const ContextIcon = styled( ({className, name, version, showVersion, showTooltip}: Props) => { - const icon = generateIconName(name, version); + const icon = generatePlatformIconName(name, version); if (!showTooltip) { - return ( - }> - - - ); + return ; } const title = ( @@ -42,9 +39,7 @@ const ContextIcon = styled( ); return ( - }> - - + {showVersion ? (version ? version : null) : undefined} ); diff --git a/static/app/components/replays/replayContext.tsx b/static/app/components/replays/replayContext.tsx index 9bf559d8f5efdf..9d2f547a020762 100644 --- a/static/app/components/replays/replayContext.tsx +++ b/static/app/components/replays/replayContext.tsx @@ -246,9 +246,7 @@ export function Provider({ const startTimeOffsetMs = replay?.getStartOffsetMs() ?? 0; const videoEvents = replay?.getVideoEvents(); const startTimestampMs = replay?.getStartTimestampMs(); - const isVideoReplay = Boolean( - organization.features.includes('session-replay-mobile-player') && videoEvents?.length - ); + const isVideoReplay = Boolean(videoEvents?.length); const forceDimensions = (dimension: Dimensions) => { setDimensions(dimension); diff --git a/static/app/components/replaysOnboarding/sidebar.tsx b/static/app/components/replaysOnboarding/sidebar.tsx index 43395102cde697..8b8e11bc1e7f52 100644 --- a/static/app/components/replaysOnboarding/sidebar.tsx +++ b/static/app/components/replaysOnboarding/sidebar.tsx @@ -10,6 +10,7 @@ import {CompactSelect} from 'sentry/components/compactSelect'; import RadioGroup from 'sentry/components/forms/controls/radioGroup'; import IdBadge from 'sentry/components/idBadge'; import LoadingIndicator from 'sentry/components/loadingIndicator'; +import {MobileBetaBanner} from 'sentry/components/onboarding/gettingStartedDoc/utils'; import useCurrentProjectState from 'sentry/components/onboarding/gettingStartedDoc/utils/useCurrentProjectState'; import {useLoadGettingStarted} from 'sentry/components/onboarding/gettingStartedDoc/utils/useLoadGettingStarted'; import {PlatformOptionDropdown} from 'sentry/components/replaysOnboarding/platformOptionDropdown'; @@ -43,6 +44,7 @@ function ReplaysOnboardingSidebar(props: CommonSidebarProps) { const hasProjectAccess = organization.access.includes('project:read'); const { + hasDocs, projects, allProjects, currentProject, @@ -147,13 +149,19 @@ function ReplaysOnboardingSidebar(props: CommonSidebarProps) { />
- + ); } -function OnboardingContent({currentProject}: {currentProject: Project}) { +function OnboardingContent({ + currentProject, + hasDocs, +}: { + currentProject: Project; + hasDocs: boolean; +}) { const jsFrameworkSelectOptions = replayJsFrameworkOptions().map(platform => { return { value: platform.id, @@ -276,7 +284,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { onChange={setSetupMode} /> ) : ( - docs?.platformOptions && ( + docs?.platformOptions && + !isProjKeysLoading && ( {tct("I'm using [platformSelect]", { platformSelect: ( @@ -298,6 +307,22 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { ); } + // TODO: remove once we have mobile replay onboarding + if (['android', 'react-native'].includes(currentPlatform.language)) { + return ( + + ); + } + if (currentPlatform.language === 'apple') { + return ( + + ); + } + const doesNotSupportReplay = currentProject.platform ? !replayPlatforms.includes(currentProject.platform) : true; @@ -324,8 +349,8 @@ function OnboardingContent({currentProject}: {currentProject: Project}) { ); } - // No platform or no docs - if (!currentPlatform || !docs || !dsn) { + // No platform, docs import failed, no DSN, or the platform doesn't have onboarding yet + if (!currentPlatform || !docs || !dsn || !hasDocs) { return (
diff --git a/static/app/data/forms/projectIssueGrouping.tsx b/static/app/data/forms/projectIssueGrouping.tsx index 1765c1bbd1f857..8caf430eb3378e 100644 --- a/static/app/data/forms/projectIssueGrouping.tsx +++ b/static/app/data/forms/projectIssueGrouping.tsx @@ -160,23 +160,6 @@ stack.function:mylibrary_* +app`} 'Changing the expiration date will affect how many new issues are created.' ), }, - groupingAutoUpdate: { - name: 'groupingAutoUpdate', - type: 'boolean', - label: t('Automatically Update Grouping'), - saveOnBlur: false, - help: t( - 'When enabled projects will in the future automatically update to the latest grouping algorithm. Right now this setting does nothing.' - ), - saveMessage: ({value}) => - value - ? t( - 'Enabling automatic upgrading will take effect on the next incoming event once auto updating has been rolled out.' - ) - : t( - 'Disabling auto updates will cause you to no longer receive improvements to the grouping algorithm.' - ), - }, }; const RuleDescription = styled('div')` diff --git a/static/app/data/platformCategories.tsx b/static/app/data/platformCategories.tsx index 4bd2ca81ac29ff..9c6f6af97997f0 100644 --- a/static/app/data/platformCategories.tsx +++ b/static/app/data/platformCategories.tsx @@ -364,17 +364,23 @@ export const replayFrontendPlatforms: readonly PlatformKey[] = [ 'javascript-vue', ]; +// These are the mobile platforms that can set up replay. +export const replayMobilePlatforms: PlatformKey[] = [ + 'android', + 'apple-ios', + 'react-native', +]; + // These are all the platforms that can set up replay. export const replayPlatforms: readonly PlatformKey[] = [ ...replayFrontendPlatforms, ...replayBackendPlatforms, + ...replayMobilePlatforms, ]; /** * The list of platforms for which we have created onboarding instructions. * Should be a subset of the list of `replayPlatforms`. - * This should match sentry-docs: `/src/wizard/${platform}/replay-onboarding/${subPlatform}/`. - * See: https://github.com/getsentry/sentry-docs/tree/master/src/wizard/javascript/replay-onboarding */ export const replayOnboardingPlatforms: readonly PlatformKey[] = [ ...replayFrontendPlatforms.filter(p => !['javascript-backbone'].includes(p)), diff --git a/static/app/types/group.tsx b/static/app/types/group.tsx index af12bb69b4ca94..5735107c1e5fa1 100644 --- a/static/app/types/group.tsx +++ b/static/app/types/group.tsx @@ -299,6 +299,11 @@ export type TagWithTopValues = { /** * Inbox, issue owners and Activity */ +export type Annotation = { + displayName: string; + url: string; +}; + export type InboxReasonDetails = { count?: number | null; until?: string | null; @@ -777,7 +782,7 @@ export const enum PriorityLevel { // TODO(ts): incomplete export interface BaseGroup { activity: GroupActivity[]; - annotations: string[]; + annotations: string[] | Annotation[]; assignedTo: Actor | null; culprit: string; firstSeen: string; diff --git a/static/app/types/project.tsx b/static/app/types/project.tsx index 43c139e686585e..d1556296fd33e6 100644 --- a/static/app/types/project.tsx +++ b/static/app/types/project.tsx @@ -27,7 +27,6 @@ export type Project = { features: string[]; firstEvent: string | null; firstTransactionEvent: boolean; - groupingAutoUpdate: boolean; groupingConfig: string; hasAccess: boolean; hasCustomMetrics: boolean; diff --git a/static/app/utils/dashboards/issueFieldRenderers.spec.tsx b/static/app/utils/dashboards/issueFieldRenderers.spec.tsx index bccb13a7923667..fcdc8ff92f654a 100644 --- a/static/app/utils/dashboards/issueFieldRenderers.spec.tsx +++ b/static/app/utils/dashboards/issueFieldRenderers.spec.tsx @@ -52,7 +52,7 @@ describe('getIssueFieldRenderer', function () { filteredEvents: 3000, events: 6000, period: '7d', - links: ['ANNO-123'], + links: 'ANNO-123', }; MockApiClient.addMockResponse({ @@ -152,10 +152,7 @@ describe('getIssueFieldRenderer', function () { { data, ...{ - links: [ - 'ANNO-123', - 'ANNO-456', - ], + links: 'ANNO-123, ANNO-456', }, }, { diff --git a/static/app/utils/dashboards/issueFieldRenderers.tsx b/static/app/utils/dashboards/issueFieldRenderers.tsx index fac978e0e4900c..991b1c7366ed17 100644 --- a/static/app/utils/dashboards/issueFieldRenderers.tsx +++ b/static/app/utils/dashboards/issueFieldRenderers.tsx @@ -5,6 +5,7 @@ import type {Location} from 'history'; import Count from 'sentry/components/count'; import DeprecatedAssigneeSelector from 'sentry/components/deprecatedAssigneeSelector'; +import ExternalLink from 'sentry/components/links/externalLink'; import Link from 'sentry/components/links/link'; import {getRelativeSummary} from 'sentry/components/timeRangeSelector/utils'; import {Tooltip} from 'sentry/components/tooltip'; @@ -12,6 +13,7 @@ import {DEFAULT_STATS_PERIOD} from 'sentry/constants'; import {t} from 'sentry/locale'; import MemberListStore from 'sentry/stores/memberListStore'; import {space} from 'sentry/styles/space'; +import type {Annotation} from 'sentry/types/group'; import type {Organization} from 'sentry/types/organization'; import type {EventData} from 'sentry/utils/discover/eventView'; import EventView from 'sentry/utils/discover/eventView'; @@ -141,7 +143,23 @@ const SPECIAL_FIELDS: SpecialFields = { }, links: { sortField: null, - renderFunc: ({links}) => , + renderFunc: ({links}) => { + if (typeof links === 'string') { + return ; + } + if (isLinkAnnotation(links)) { + return ( + + {links.map((link, index) => ( + + {link.displayName} + + ))} + + ); + } + return ; + }, }, }; @@ -241,6 +259,10 @@ export function getSortField(field: string): string | null { } } +function isLinkAnnotation(value: unknown): value is Annotation[] { + return Array.isArray(value) && value.every(v => typeof v === 'object'); +} + const contentStyle = css` width: 100%; justify-content: space-between; diff --git a/static/app/utils/replays/generatePlatformIconName.tsx b/static/app/utils/replays/generatePlatformIconName.tsx new file mode 100644 index 00000000000000..325bffcb0eabb6 --- /dev/null +++ b/static/app/utils/replays/generatePlatformIconName.tsx @@ -0,0 +1,32 @@ +import {generateIconName} from 'sentry/components/events/contexts/utils'; + +const PLATFORM_ALIASES = { + ios: 'apple', + mac: 'apple', + macos: 'apple', + 'mac-os-x': 'apple', + darwin: 'apple', + tvos: 'apple-tv', + watchos: 'apple-watch', + iphone: 'apple-iphone', + ipad: 'apple-ipad', + 'legacy-edge': 'edge-legacy', + 'mobile-safari': 'safari', + 'chrome-mobile-ios': 'chrome', + 'google-chrome': 'chrome', + 'chrome-os': 'chrome', + net: 'dotnet', + 'net-core': 'dotnetcore', + 'net-framework': 'dotnetframework', +}; + +/** + * Generates names used for PlatformIcon. Translates ContextIcon names (https://sentry.sentry.io/stories/?name=app/components/events/contexts/contextIcon.stories.tsx) to PlatformIcon (https://www.npmjs.com/package/platformicons) names + */ +export function generatePlatformIconName( + name: string, + version: string | undefined +): string { + const contextName = generateIconName(name, version); + return contextName in PLATFORM_ALIASES ? PLATFORM_ALIASES[contextName] : contextName; +} diff --git a/static/app/utils/replays/projectSupportsReplay.tsx b/static/app/utils/replays/projectSupportsReplay.tsx index 82c1f49b63a434..009d3152a080d5 100644 --- a/static/app/utils/replays/projectSupportsReplay.tsx +++ b/static/app/utils/replays/projectSupportsReplay.tsx @@ -1,4 +1,4 @@ -import {backend, mobile, replayPlatforms} from 'sentry/data/platformCategories'; +import {backend, replayPlatforms} from 'sentry/data/platformCategories'; import type {Organization} from 'sentry/types/organization'; import type {MinimalProject} from 'sentry/types/project'; @@ -17,17 +17,13 @@ function projectSupportsReplay(project: MinimalProject) { * Basically: is this a backend or frontend project */ export function projectCanLinkToReplay( - organization: Organization, + _organization: Organization, project: undefined | MinimalProject ) { if (!project || !project.platform) { return false; } - - const hasMobileReplay = organization.features.includes('session-replay-mobile-player'); - const supportedPlatforms = hasMobileReplay - ? replayPlatforms.concat(mobile) - : replayPlatforms; + const supportedPlatforms = replayPlatforms; return ( supportedPlatforms.includes(project.platform) || backend.includes(project.platform) diff --git a/static/app/views/alerts/list/rules/activatedRuleRow.tsx b/static/app/views/alerts/list/rules/activatedRuleRow.tsx index 88df87c7722337..606abc205dd5ea 100644 --- a/static/app/views/alerts/list/rules/activatedRuleRow.tsx +++ b/static/app/views/alerts/list/rules/activatedRuleRow.tsx @@ -1,4 +1,4 @@ -import {useMemo, useState} from 'react'; +import {useState} from 'react'; import styled from '@emotion/styled'; import Access from 'sentry/components/acl/access'; @@ -18,21 +18,15 @@ import LoadingIndicator from 'sentry/components/loadingIndicator'; import TextOverflow from 'sentry/components/textOverflow'; import TimeSince from 'sentry/components/timeSince'; import {Tooltip} from 'sentry/components/tooltip'; -import {IconArrow, IconChevron, IconEllipsis, IconMute, IconUser} from 'sentry/icons'; +import {IconChevron, IconEllipsis, IconUser} from 'sentry/icons'; import {t, tct} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import type {Actor, Project} from 'sentry/types'; -import type {ColorOrAlias} from 'sentry/utils/theme'; import {useUserTeams} from 'sentry/utils/useUserTeams'; -import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants'; -import { - AlertRuleComparisonType, - AlertRuleThresholdType, - AlertRuleTriggerType, -} from 'sentry/views/alerts/rules/metric/types'; +import AlertRuleStatus from 'sentry/views/alerts/list/rules/alertRuleStatus'; import type {CombinedMetricIssueAlerts, MetricAlert} from '../../types'; -import {ActivationStatus, CombinedAlertType, IncidentStatus} from '../../types'; +import {ActivationStatus, CombinedAlertType} from '../../types'; type Props = { hasEditAccess: boolean; @@ -59,12 +53,9 @@ function ActivatedRuleListRow({ }: Props) { const {teams: userTeams} = useUserTeams(); const [assignee, setAssignee] = useState(''); - const isWaiting = useMemo( - () => - !rule.activations?.length || - (rule.activations?.length && rule.activations[0].isComplete), - [rule] - ); + const isWaiting = + !rule.activations?.length || + (rule.activations?.length && rule.activations[0].isComplete); function renderLatestActivation(): React.ReactNode { if (!rule.activations?.length) { @@ -79,69 +70,6 @@ function ActivatedRuleListRow({ ); } - function renderSnoozeStatus(): React.ReactNode { - return ( - - - {t('Muted')} - - ); - } - - function renderAlertRuleStatus(): React.ReactNode { - if (rule.snooze) { - return renderSnoozeStatus(); - } - - const isUnhealthy = - rule.latestIncident?.status !== undefined && - [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes( - rule.latestIncident.status - ); - - let iconColor: ColorOrAlias = 'successText'; - let iconDirection: 'up' | 'down' = - rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up'; - let thresholdTypeText = - rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above'); - if (isUnhealthy) { - iconColor = - rule.latestIncident?.status === IncidentStatus.CRITICAL - ? 'errorText' - : 'warningText'; - // if unhealthy, swap icon direction - iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down'; - thresholdTypeText = - rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Above') : t('Below'); - } - - let threshold = rule.triggers.find( - ({label}) => label === AlertRuleTriggerType.CRITICAL - )?.alertThreshold; - if (isUnhealthy && rule.latestIncident?.status === IncidentStatus.WARNING) { - threshold = rule.triggers.find( - ({label}) => label === AlertRuleTriggerType.WARNING - )?.alertThreshold; - } else if (!isUnhealthy && rule.latestIncident && rule.resolveThreshold) { - threshold = rule.resolveThreshold; - } - - return ( - - - - {`${thresholdTypeText} ${threshold}`} - {getThresholdUnits( - rule.aggregate, - rule.comparisonDelta - ? AlertRuleComparisonType.CHANGE - : AlertRuleComparisonType.COUNT - )} - - - ); - } - const slug = rule.projects[0]; const editLink = `/organizations/${orgId}/alerts/metric-rules/${slug}/${rule.id}/`; @@ -282,7 +210,9 @@ function ActivatedRuleListRow({ /> - {renderAlertRuleStatus()} + + + @@ -355,13 +285,6 @@ const FlexCenter = styled('div')` align-items: center; `; -const IssueAlertStatusWrapper = styled('div')` - display: flex; - align-items: center; - gap: ${space(1)}; - line-height: 2; -`; - const AlertNameWrapper = styled('div')<{isIssueAlert?: boolean}>` ${p => p.theme.overflowEllipsis} display: flex; @@ -392,12 +315,6 @@ const ProjectBadge = styled(IdBadge)` flex-shrink: 0; `; -const TriggerText = styled('div')` - margin-left: ${space(1)}; - white-space: nowrap; - font-variant-numeric: tabular-nums; -`; - const ActionsColumn = styled('div')` display: flex; align-items: center; diff --git a/static/app/views/alerts/list/rules/alertRuleStatus.tsx b/static/app/views/alerts/list/rules/alertRuleStatus.tsx new file mode 100644 index 00000000000000..b12af63a233131 --- /dev/null +++ b/static/app/views/alerts/list/rules/alertRuleStatus.tsx @@ -0,0 +1,99 @@ +import type {ReactNode} from 'react'; +import styled from '@emotion/styled'; + +import {IconArrow, IconMute} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {ColorOrAlias} from 'sentry/utils/theme'; +import {getThresholdUnits} from 'sentry/views/alerts/rules/metric/constants'; +import { + AlertRuleComparisonType, + AlertRuleThresholdType, + AlertRuleTriggerType, +} from 'sentry/views/alerts/rules/metric/types'; + +import type {MetricAlert} from '../../types'; +import {IncidentStatus} from '../../types'; + +interface Props { + rule: MetricAlert; +} + +export default function AlertRuleStatus({rule}: Props): ReactNode { + if (rule.snooze) { + return ( + + + {t('Muted')} + + ); + } + + const isUnhealthy = + rule.latestIncident?.status !== undefined && + [IncidentStatus.CRITICAL, IncidentStatus.WARNING].includes( + rule.latestIncident.status + ); + + let iconColor: ColorOrAlias = 'successText'; + let iconDirection: 'up' | 'down' = + rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'down' : 'up'; + let thresholdTypeText = + rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Below') : t('Above'); + if (isUnhealthy) { + iconColor = + rule.latestIncident?.status === IncidentStatus.CRITICAL + ? 'errorText' + : 'warningText'; + // if unhealthy, swap icon direction + iconDirection = rule.thresholdType === AlertRuleThresholdType.ABOVE ? 'up' : 'down'; + thresholdTypeText = + rule.thresholdType === AlertRuleThresholdType.ABOVE ? t('Above') : t('Below'); + } + + let threshold = rule.triggers.find( + ({label}) => label === AlertRuleTriggerType.CRITICAL + )?.alertThreshold; + if (isUnhealthy && rule.latestIncident?.status === IncidentStatus.WARNING) { + threshold = rule.triggers.find( + ({label}) => label === AlertRuleTriggerType.WARNING + )?.alertThreshold; + } else if (!isUnhealthy && rule.latestIncident && rule.resolveThreshold) { + threshold = rule.resolveThreshold; + } + + return ( + + + + {`${thresholdTypeText} ${threshold}`} + {getThresholdUnits( + rule.aggregate, + rule.comparisonDelta + ? AlertRuleComparisonType.CHANGE + : AlertRuleComparisonType.COUNT + )} + + + ); +} + +// TODO: see static/app/components/profiling/flex.tsx and utilize the FlexContainer styled component +const FlexCenter = styled('div')` + display: flex; + flex-direction: row; + align-items: center; +`; + +const IssueAlertStatusWrapper = styled('div')` + display: flex; + align-items: center; + gap: ${space(1)}; + line-height: 2; +`; + +const TriggerText = styled('div')` + margin-left: ${space(1)}; + white-space: nowrap; + font-variant-numeric: tabular-nums; +`; diff --git a/static/app/views/dashboards/datasetConfig/issues.tsx b/static/app/views/dashboards/datasetConfig/issues.tsx index 11932ec05e33f0..f8002a917c3d57 100644 --- a/static/app/views/dashboards/datasetConfig/issues.tsx +++ b/static/app/views/dashboards/datasetConfig/issues.tsx @@ -122,7 +122,9 @@ export function transformIssuesResponseToTable( issue: shortId, title, project: project.slug, - links: annotations?.join(', '), + links: annotations?.every(a => typeof a === 'string') + ? annotations.join(', ') + : ((annotations ?? []) as any), }; // Get lifetime stats diff --git a/static/app/views/issueDetails/header.spec.tsx b/static/app/views/issueDetails/header.spec.tsx index ae5611f67fa9c8..e80ec7bea783e8 100644 --- a/static/app/views/issueDetails/header.spec.tsx +++ b/static/app/views/issueDetails/header.spec.tsx @@ -118,7 +118,7 @@ describe('GroupHeader', () => { }); const mobileProjectWithSimilarityView = ProjectFixture({ features: ['similarity-view'], - platform: 'apple-ios', + platform: 'unity', }); const MOCK_GROUP = GroupFixture(); diff --git a/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx b/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx index 82e3f253d0e124..03cd55c3f19f37 100644 --- a/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx +++ b/static/app/views/performance/transactionSummary/transactionProfiles/index.tsx @@ -447,6 +447,7 @@ const RequestStateMessageContainer = styled('div')` justify-content: center; align-items: center; color: ${p => p.theme.subText}; + pointer-events: none; `; const AggregateFlamegraphToolbarContainer = styled('div')` diff --git a/static/app/views/replays/detail/useAllMobileProj.tsx b/static/app/views/replays/detail/useAllMobileProj.tsx index f2105815a126e7..efcb0d1dea3d7b 100644 --- a/static/app/views/replays/detail/useAllMobileProj.tsx +++ b/static/app/views/replays/detail/useAllMobileProj.tsx @@ -1,10 +1,8 @@ import {mobile} from 'sentry/data/platformCategories'; -import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; export default function useAllMobileProj() { - const organization = useOrganization(); const { selection: {projects: projectIds}, } = usePageFilters(); @@ -15,9 +13,7 @@ export default function useAllMobileProj() { // if no projects selected, look through all projects const proj = projectsSelected.length ? projectsSelected : projects; - const allMobileProj = - organization.features.includes('session-replay-mobile-player') && - proj.every(p => mobile.includes(p.platform ?? 'other')); + const allMobileProj = proj.every(p => mobile.includes(p.platform ?? 'other')); return {allMobileProj}; } diff --git a/static/app/views/replays/details.tsx b/static/app/views/replays/details.tsx index c94e038cb44335..6a95d80e723925 100644 --- a/static/app/views/replays/details.tsx +++ b/static/app/views/replays/details.tsx @@ -177,10 +177,7 @@ function ReplayDetails({params: {replaySlug}}: Props) { ); } - const isVideoReplay = Boolean( - organization.features.includes('session-replay-mobile-player') && - replay?.isVideoReplay() - ); + const isVideoReplay = replay?.isVideoReplay(); return ( { expect(mockFetchReplayListRequest).toHaveBeenCalled(); }); - it('should fetch the replay table and show selector tables when the org is on AM2, has sent some replays, and has a newer SDK version', async () => { + it('should fetch the replay table when the org is on AM2, has sent some replays, and has a newer SDK version', async () => { const mockOrg = getMockOrganizationFixture({features: AM2_FEATURES}); mockUseHaveSelectedProjectsSentAnyReplayEvents.mockReturnValue({ fetching: false, @@ -154,21 +154,13 @@ describe('ReplayList', () => { isFetching: false, needsUpdate: false, }); - mockUseDeadRageSelectors.mockReturnValue({ - isLoading: false, - isError: false, - data: [], - pageLinks: undefined, - }); render(, { organization: mockOrg, }); await waitFor(() => expect(screen.queryAllByTestId('replay-table')).toHaveLength(1)); - await waitFor(() => - expect(screen.queryAllByTestId('selector-widget')).toHaveLength(2) - ); + expect(mockFetchReplayListRequest).toHaveBeenCalled(); }); }); diff --git a/static/app/views/replays/list/replayOnboardingPanel.tsx b/static/app/views/replays/list/replayOnboardingPanel.tsx index 45c7112259bfaa..a24fba3face50d 100644 --- a/static/app/views/replays/list/replayOnboardingPanel.tsx +++ b/static/app/views/replays/list/replayOnboardingPanel.tsx @@ -12,7 +12,7 @@ import QuestionTooltip from 'sentry/components/questionTooltip'; import Accordion from 'sentry/components/replays/accordion'; import ReplayUnsupportedAlert from 'sentry/components/replays/alerts/replayUnsupportedAlert'; import {Tooltip} from 'sentry/components/tooltip'; -import {mobile, replayPlatforms} from 'sentry/data/platformCategories'; +import {replayPlatforms} from 'sentry/data/platformCategories'; import {t, tct} from 'sentry/locale'; import PreferencesStore from 'sentry/stores/preferencesStore'; import {useLegacyStore} from 'sentry/stores/useLegacyStore'; @@ -22,6 +22,7 @@ import useOrganization from 'sentry/utils/useOrganization'; import usePageFilters from 'sentry/utils/usePageFilters'; import useProjects from 'sentry/utils/useProjects'; import {HeaderContainer, WidgetContainer} from 'sentry/views/profiling/landing/styles'; +import useAllMobileProj from 'sentry/views/replays/detail/useAllMobileProj'; import ReplayPanel from 'sentry/views/replays/list/replayPanel'; type Breakpoints = { @@ -47,11 +48,8 @@ export default function ReplayOnboardingPanel() { const projects = useProjects(); const organization = useOrganization(); const {canCreateProject} = useProjectCreationAccess({organization}); - const hasMobileReplays = organization.features.includes('session-replay-mobile-player'); - const supportedPlatforms = hasMobileReplays - ? replayPlatforms.concat(mobile) - : replayPlatforms; + const supportedPlatforms = replayPlatforms; const selectedProjects = projects.projects.filter(p => pageFilters.selection.projects.includes(Number(p.id)) @@ -124,6 +122,8 @@ export function SetupReplaysCTA({ }: SetupReplaysCTAProps) { const {activateSidebar} = useReplayOnboardingSidebarPanel(); const [expanded, setExpanded] = useState(-1); + const {allMobileProj} = useAllMobileProj(); + const FAQ = [ { header: ( @@ -276,7 +276,11 @@ export function SetupReplaysCTA({ {renderCTA()} -
- - - - ); -} - -export default UpgradeGrouping; diff --git a/tests/js/fixtures/project.ts b/tests/js/fixtures/project.ts index c3b3352978295c..9dad90143c60c7 100644 --- a/tests/js/fixtures/project.ts +++ b/tests/js/fixtures/project.ts @@ -27,7 +27,6 @@ export function ProjectFixture(params: Partial = {}): Project { dynamicSamplingBiases: null, firstEvent: null, firstTransactionEvent: false, - groupingAutoUpdate: false, groupingConfig: '', hasCustomMetrics: false, hasFeedbacks: false, diff --git a/tests/sentry/api/endpoints/test_organization_dashboard_details.py b/tests/sentry/api/endpoints/test_organization_dashboard_details.py index 35a9fc976ccc7a..a0e2e1a2a2fd34 100644 --- a/tests/sentry/api/endpoints/test_organization_dashboard_details.py +++ b/tests/sentry/api/endpoints/test_organization_dashboard_details.py @@ -216,6 +216,52 @@ def test_response_truncates_with_retention(self): expected_adjusted_retention_start.replace(second=0) ) + def test_dashboard_widget_type_returns_split_decision(self): + dashboard = Dashboard.objects.create( + title="Dashboard With Split Widgets", + created_by_id=self.user.id, + organization=self.organization, + ) + DashboardWidget.objects.create( + dashboard=dashboard, + order=0, + title="error widget", + display_type=DashboardWidgetDisplayTypes.LINE_CHART, + widget_type=DashboardWidgetTypes.DISCOVER, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + discover_widget_split=DashboardWidgetTypes.ERROR_EVENTS, + ) + DashboardWidget.objects.create( + dashboard=dashboard, + order=1, + title="transaction widget", + display_type=DashboardWidgetDisplayTypes.LINE_CHART, + widget_type=DashboardWidgetTypes.DISCOVER, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + discover_widget_split=DashboardWidgetTypes.TRANSACTION_LIKE, + ) + DashboardWidget.objects.create( + dashboard=dashboard, + order=2, + title="no split", + display_type=DashboardWidgetDisplayTypes.LINE_CHART, + widget_type=DashboardWidgetTypes.DISCOVER, + interval="1d", + detail={"layout": {"x": 0, "y": 0, "w": 1, "h": 1, "minH": 2}}, + ) + + with self.feature({"organizations:performance-discover-dataset-selector": True}): + response = self.do_request( + "get", + self.url(dashboard.id), + ) + assert response.status_code == 200, response.content + assert response.data["widgets"][0]["widgetType"] == "error-events" + assert response.data["widgets"][1]["widgetType"] == "transaction-like" + assert response.data["widgets"][2]["widgetType"] == "discover" + class OrganizationDashboardDetailsDeleteTest(OrganizationDashboardDetailsTestCase): def test_delete(self): diff --git a/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py b/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py index 9655abd206f786..81c06a4dfae096 100644 --- a/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py +++ b/tests/sentry/incidents/endpoints/serializers/test_alert_rule.py @@ -326,9 +326,11 @@ def test_combined_serializer(self): } ) other_alert_rule = self.create_alert_rule() + uptime_monitor = self.create_project_uptime_subscription() result = serialize( - [alert_rule, issue_rule, other_alert_rule], serializer=CombinedRuleSerializer() + [alert_rule, issue_rule, other_alert_rule, uptime_monitor], + serializer=CombinedRuleSerializer(), ) self.assert_alert_rule_serialized(alert_rule, result[0]) @@ -336,6 +338,9 @@ def test_combined_serializer(self): assert result[1]["status"] == "active" assert not result[1]["snooze"] self.assert_alert_rule_serialized(other_alert_rule, result[2]) + serialized_uptime_monitor = serialize(uptime_monitor) + serialized_uptime_monitor["type"] = "uptime" + assert result[3] == serialized_uptime_monitor def test_alert_snoozed(self): projects = [self.project, self.create_project()] diff --git a/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py b/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py index 393c17e858d9f9..583855f3ce12da 100644 --- a/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py +++ b/tests/sentry/incidents/endpoints/test_organization_combined_rule_index_endpoint.py @@ -11,6 +11,7 @@ from sentry.testutils.cases import APITestCase from sentry.testutils.helpers.datetime import before_now, freeze_time from sentry.types.actor import Actor +from sentry.uptime.models import UptimeStatus from sentry.utils import json from tests.sentry.incidents.endpoints.serializers.test_alert_rule import BaseAlertRuleSerializerTest @@ -370,30 +371,55 @@ def test_filter_by_project(self): self.three_alert_rule = self.create_alert_rule( organization=self.org, projects=[self.project2] ) + proj_uptime_monitor = self.create_project_uptime_subscription(project=self.project) + proj2_uptime_monitor = self.create_project_uptime_subscription( + uptime_subscription=self.create_uptime_subscription(url="http://santry.io"), + project=self.project2, + ) - with self.feature(["organizations:incidents", "organizations:performance-view"]): - request_data = {"per_page": "2", "project": [self.project.id]} + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): + request_data = {"project": [self.project.id]} response = self.client.get( path=self.combined_rules_url, data=request_data, content_type="application/json" ) assert response.status_code == 200 result = json.loads(response.content) - assert len(result) == 2 - self.assert_alert_rule_serialized(self.one_alert_rule, result[0], skip_dates=True) - self.assert_alert_rule_serialized(self.two_alert_rule, result[1], skip_dates=True) + assert [r["id"] for r in result] == [ + f"{proj_uptime_monitor.id}", + f"{self.one_alert_rule.id}", + f"{self.two_alert_rule.id}", + f"{self.yet_another_alert_rule.id}", + f"{self.issue_rule.id}", + f"{self.alert_rule.id}", + ] - with self.feature(["organizations:incidents", "organizations:performance-view"]): - request_data = {"per_page": "2", "project": [self.project2.id]} + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): + request_data = {"project": [self.project2.id]} response = self.client.get( path=self.combined_rules_url, data=request_data, content_type="application/json" ) assert response.status_code == 200 result = json.loads(response.content) - assert len(result) == 2 - self.assert_alert_rule_serialized(self.three_alert_rule, result[0], skip_dates=True) - self.assert_alert_rule_serialized(self.one_alert_rule, result[1], skip_dates=True) + assert [r["id"] for r in result] == [ + f"{proj2_uptime_monitor.id}", + f"{self.three_alert_rule.id}", + f"{self.one_alert_rule.id}", + f"{self.other_alert_rule.id}", + ] other_org = self.create_organization() other_project = self.create_project(organization=other_org) @@ -539,6 +565,54 @@ def test_team_filter(self): result = json.loads(response.content) assert len(result) == 2 + team_uptime_monitor = self.create_project_uptime_subscription( + owner=self.team, name="Uptime owned" + ) + unowned_uptime_monitor = self.create_project_uptime_subscription( + name="Uptime unowned", + uptime_subscription=self.create_uptime_subscription(url="http://santry.io"), + ) + + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): + request_data = { + "per_page": "10", + "project": [self.project.id], + "team": [self.team.id], + "name": "Uptime", + } + response = self.client.get( + path=self.combined_rules_url, data=request_data, content_type="application/json" + ) + assert response.status_code == 200 + result = json.loads(response.content) + assert [r["id"] for r in result] == [f"{team_uptime_monitor.id}"] + + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): + request_data = { + "per_page": "10", + "project": [self.project.id], + "team": ["unassigned"], + "name": "Uptime", + } + response = self.client.get( + path=self.combined_rules_url, data=request_data, content_type="application/json" + ) + assert response.status_code == 200 + result = json.loads(response.content) + assert [r["id"] for r in result] == [f"{unowned_uptime_monitor.id}"] + def test_myteams_filter_superuser(self): superuser = self.create_user(is_superuser=True) another_org = self.create_organization(owner=superuser, name="Rowdy Tiger") @@ -609,7 +683,18 @@ def test_team_filter_no_access(self): def test_name_filter(self): self.setup_project_and_rules() - with self.feature(["organizations:incidents", "organizations:performance-view"]): + uptime_monitor = self.create_project_uptime_subscription(name="Uptime") + another_uptime_monitor = self.create_project_uptime_subscription( + uptime_subscription=self.create_uptime_subscription(url="https://santry.io"), + name="yet another Uptime", + ) + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "10", "project": [self.project.id], @@ -620,10 +705,18 @@ def test_name_filter(self): ) assert response.status_code == 200 result = json.loads(response.content) - assert len(result) == 1 - assert result[0]["name"] == "yet another alert rule" + assert [r["id"] for r in result] == [ + f"{another_uptime_monitor.id}", + f"{self.yet_another_alert_rule.id}", + ] - with self.feature(["organizations:incidents", "organizations:performance-view"]): + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "10", "project": [self.project.id], @@ -634,10 +727,15 @@ def test_name_filter(self): ) assert response.status_code == 200 result = json.loads(response.content) - assert len(result) == 1 - assert result[0]["name"] == "Issue Rule Test" - - with self.feature(["organizations:incidents", "organizations:performance-view"]): + assert [r["id"] for r in result] == [f"{self.issue_rule.id}"] + + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "10", "project": [self.project.id, self.project2.id], @@ -650,7 +748,13 @@ def test_name_filter(self): result = json.loads(response.content) assert len(result) == 3 - with self.feature(["organizations:incidents", "organizations:performance-view"]): + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "10", "project": [self.project.id, self.project2.id], @@ -663,7 +767,13 @@ def test_name_filter(self): result = json.loads(response.content) assert len(result) == 0 - with self.feature(["organizations:incidents", "organizations:performance-view"]): + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "10", "project": [self.project.id, self.project2.id], @@ -676,6 +786,25 @@ def test_name_filter(self): result = json.loads(response.content) assert len(result) == 4 + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): + request_data = { + "per_page": "10", + "project": [self.project.id, self.project2.id], + "name": "uptime", + } + response = self.client.get( + path=self.combined_rules_url, data=request_data, content_type="application/json" + ) + assert response.status_code == 200 + result = json.loads(response.content) + assert [r["id"] for r in result] == [f"{another_uptime_monitor.id}", f"{uptime_monitor.id}"] + def test_status_and_date_triggered_sort_order(self): self.setup_project_and_rules() @@ -733,7 +862,18 @@ def test_status_and_date_triggered_sort_order(self): alert_rule_trigger=trigger3, status=TriggerStatus.ACTIVE.value, ) - with self.feature(["organizations:incidents", "organizations:performance-view"]): + uptime_monitor = self.create_project_uptime_subscription() + failed_uptime_monitor = self.create_project_uptime_subscription( + uptime_subscription=self.create_uptime_subscription(url="https://santry.io"), + uptime_status=UptimeStatus.FAILED, + ) + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "10", "project": [self.project.id, self.project2.id], @@ -744,9 +884,10 @@ def test_status_and_date_triggered_sort_order(self): ) assert response.status_code == 200, response.content result = json.loads(response.content) - assert len(result) == 7 - # Assert critical rule is first, warnings are next (sorted by triggered date), and issue rules are last. + # Assert failed uptime monitor is first, critical rule is next, then warnings (sorted by triggered date), + # then issue rules and finally uptime monitors in ok status. assert [r["id"] for r in result] == [ + f"{failed_uptime_monitor.id}", f"{alert_rule_critical.id}", f"{another_alert_rule_warning.id}", f"{alert_rule_warning.id}", @@ -754,10 +895,17 @@ def test_status_and_date_triggered_sort_order(self): f"{self.other_alert_rule.id}", f"{self.yet_another_alert_rule.id}", f"{self.issue_rule.id}", + f"{uptime_monitor.id}", ] # Test paging with the status setup: - with self.feature(["organizations:incidents", "organizations:performance-view"]): + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "per_page": "2", "project": [self.project.id, self.project2.id], @@ -768,15 +916,23 @@ def test_status_and_date_triggered_sort_order(self): ) assert response.status_code == 200, response.content result = json.loads(response.content) - assert len(result) == 2 - self.assert_alert_rule_serialized(alert_rule_critical, result[0], skip_dates=True) - self.assert_alert_rule_serialized(another_alert_rule_warning, result[1], skip_dates=True) + assert [r["id"] for r in result] == [ + f"{failed_uptime_monitor.id}", + f"{alert_rule_critical.id}", + ] + links = requests.utils.parse_header_links( response.get("link", "").rstrip(">").replace(">,<", ",<") ) next_cursor = links[1]["cursor"] # Get next page, we should be between the two status': - with self.feature(["organizations:incidents", "organizations:performance-view"]): + with self.feature( + [ + "organizations:incidents", + "organizations:performance-view", + "organizations:uptime-rule-api", + ] + ): request_data = { "cursor": next_cursor, "per_page": "2", @@ -788,8 +944,37 @@ def test_status_and_date_triggered_sort_order(self): ) assert response.status_code == 200, response.content result = json.loads(response.content) - assert len(result) == 2 - self.assert_alert_rule_serialized(alert_rule_warning, result[0], skip_dates=True) + assert [r["id"] for r in result] == [ + f"{another_alert_rule_warning.id}", + f"{alert_rule_warning.id}", + ] + + def test_uptime_feature(self): + self.setup_project_and_rules() + uptime_monitor = self.create_project_uptime_subscription(name="Uptime Monitor") + other_uptime_monitor = self.create_project_uptime_subscription( + name="Other Uptime Monitor", + uptime_subscription=self.create_uptime_subscription(url="https://santry.io"), + ) + + request_data = {"name": "Uptime", "project": [self.project.id]} + response = self.client.get( + path=self.combined_rules_url, data=request_data, content_type="application/json" + ) + assert response.status_code == 200, response.content + assert [r["id"] for r in json.loads(response.content)] == [] + + with self.feature("organizations:uptime-rule-api"): + request_data = {"name": "Uptime", "project": [self.project.id]} + response = self.client.get( + path=self.combined_rules_url, data=request_data, content_type="application/json" + ) + assert response.status_code == 200, response.content + result = json.loads(response.content) + assert [r["id"] for r in result] == [ + f"{other_uptime_monitor.id}", + f"{uptime_monitor.id}", + ] def test_expand_latest_incident(self): self.setup_project_and_rules() diff --git a/tests/sentry/rules/processing/test_delayed_processing.py b/tests/sentry/rules/processing/test_delayed_processing.py index d3f6976ac1360a..756a2ef86b9da3 100644 --- a/tests/sentry/rules/processing/test_delayed_processing.py +++ b/tests/sentry/rules/processing/test_delayed_processing.py @@ -2,7 +2,7 @@ from collections.abc import Sequence from copy import deepcopy from datetime import datetime, timedelta -from typing import cast +from typing import DefaultDict, cast from unittest.mock import Mock, patch from uuid import uuid4 @@ -10,7 +10,9 @@ from sentry import buffer from sentry.eventstore.models import GroupEvent +from sentry.models.group import Group from sentry.models.project import Project +from sentry.models.rule import Rule from sentry.models.rulefirehistory import RuleFireHistory from sentry.rules.conditions.event_frequency import ( ComparisonType, @@ -26,6 +28,7 @@ get_condition_group_results, get_condition_query_groups, get_group_to_groupevent, + get_rules_to_fire, get_rules_to_groups, get_rules_to_slow_conditions, get_slow_conditions, @@ -43,8 +46,17 @@ pytestmark = pytest.mark.sentry_metrics FROZEN_TIME = before_now(days=1).replace(hour=1, minute=30, second=0, microsecond=0) -TEST_RULE_SLOW_CONDITION = {"id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition"} -TEST_RULE_FAST_CONDITION = {"id": "sentry.rules.conditions.every_event.EveryEventCondition"} +TEST_RULE_SLOW_CONDITION: EventFrequencyConditionData = { + "id": "sentry.rules.conditions.event_frequency.EventFrequencyCondition", + "value": 1, + "interval": "1h", +} + +TEST_RULE_FAST_CONDITION: EventFrequencyConditionData = { + "id": "sentry.rules.conditions.every_event.EveryEventCondition", + "value": 1, + "interval": "1h", +} def test_bucket_num_groups(): @@ -366,8 +378,117 @@ def test_invalid_group_ids(self): class GetRulesToFireTest(TestCase): - def test_get_rules_to_fire(self): - pass + def setUp(self): + self.organization = self.create_organization() + self.project = self.create_project() + self.environment = self.create_environment() + + self.rule1: Rule = self.create_project_rule( + project=self.project, + condition_match=[TEST_RULE_SLOW_CONDITION], + environment_id=self.environment.id, + ) + self.group1: Group = self.create_group(self.project) + self.group2: Group = self.create_group(self.project) + + self.condition_group_results: dict[UniqueConditionQuery, dict[int, int]] = { + UniqueConditionQuery( + cls_id=TEST_RULE_SLOW_CONDITION["id"], + interval=TEST_RULE_SLOW_CONDITION["interval"], + environment_id=self.environment.id, + ): {self.group1.id: 2, self.group2.id: 1} + } + + self.rules_to_slow_conditions: DefaultDict[ + Rule, list[EventFrequencyConditionData] + ] = defaultdict(list) + self.rules_to_slow_conditions[self.rule1].append(TEST_RULE_SLOW_CONDITION) + + self.rules_to_groups: DefaultDict[int, set[int]] = defaultdict(set) + self.rules_to_groups[self.rule1.id].add(self.group1.id) + self.rules_to_groups[self.rule1.id].add(self.group2.id) + + # Mock _passes_comparison function + self.patcher = patch("sentry.rules.processing.delayed_processing._passes_comparison") + self.mock_passes_comparison = self.patcher.start() + + def tearDown(self): + self.patcher.stop() + + def test_comparison(self): + self.mock_passes_comparison.return_value = True + + result = get_rules_to_fire( + self.condition_group_results, + self.rules_to_slow_conditions, + self.rules_to_groups, + self.project.id, + ) + + assert result[self.rule1] == {self.group1.id, self.group2.id} + + def test_comparison_fail_all(self): + self.mock_passes_comparison.return_value = False + + result = get_rules_to_fire( + self.condition_group_results, + self.rules_to_slow_conditions, + self.rules_to_groups, + self.project.id, + ) + + assert self.rule1 not in result + + def test_comparison_any(self): + self.rule1.data["action_match"] = "any" + self.mock_passes_comparison.return_value = True + + result = get_rules_to_fire( + self.condition_group_results, + self.rules_to_slow_conditions, + self.rules_to_groups, + self.project.id, + ) + + assert result[self.rule1] == {self.group1.id, self.group2.id} + + def test_comparison_any_fail(self): + self.rule1.data["action_match"] = "any" + self.mock_passes_comparison.return_value = False + + result = get_rules_to_fire( + self.condition_group_results, + self.rules_to_slow_conditions, + self.rules_to_groups, + self.project.id, + ) + + assert self.rule1 not in result + + def test_empty_input(self): + result = get_rules_to_fire({}, defaultdict(list), defaultdict(set), self.project.id) + assert len(result) == 0 + + @patch("sentry.rules.processing.delayed_processing._passes_comparison", return_value=True) + def test_multiple_rules_and_groups(self, mock_passes): + rule2 = self.create_project_rule( + project=self.project, + condition_match=[TEST_RULE_SLOW_CONDITION], + environment_id=self.environment.id, + ) + self.rules_to_slow_conditions[rule2].append(TEST_RULE_SLOW_CONDITION) + self.rules_to_groups[rule2.id].add(self.group2.id) + + result = get_rules_to_fire( + self.condition_group_results, + self.rules_to_slow_conditions, + self.rules_to_groups, + self.project.id, + ) + + assert len(result) == 2 + assert result[self.rule1] == {self.group1.id, self.group2.id} + assert result[rule2] == {self.group2.id} class GetRulesToGroupsTest(TestCase): diff --git a/tests/sentry/test_mypy_stronglist.py b/tests/sentry/test_mypy_stronglist.py new file mode 100644 index 00000000000000..36294a52eadd5a --- /dev/null +++ b/tests/sentry/test_mypy_stronglist.py @@ -0,0 +1,20 @@ +from __future__ import annotations + +import os.path +import subprocess +import sys + + +def test_stronglist() -> None: + pyproject_path = os.path.join(os.path.dirname(__file__), "../../pyproject.toml") + pyproject_path = os.path.relpath(pyproject_path) + + proc = subprocess.run( + (sys.executable, "-uSm", "tools.mypy_helpers.check_stronglist", pyproject_path), + check=False, + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + if proc.returncode: + raise AssertionError(f"\n\n{proc.stdout}") diff --git a/tests/sentry/uptime/consumers/test_results_consumers.py b/tests/sentry/uptime/consumers/test_results_consumers.py index 896f569faf7bff..195f53b3f0fa06 100644 --- a/tests/sentry/uptime/consumers/test_results_consumers.py +++ b/tests/sentry/uptime/consumers/test_results_consumers.py @@ -64,7 +64,9 @@ def send_result(self, result: CheckResult): def test(self): result = self.create_uptime_result(self.subscription.subscription_id) - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ @@ -81,12 +83,33 @@ def test(self): self.project_subscription.refresh_from_db() assert self.project_subscription.uptime_status == UptimeStatus.FAILED + def test_no_create_issues_feature(self): + result = self.create_uptime_result(self.subscription.subscription_id) + with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + self.send_result(result) + metrics.incr.assert_has_calls( + [ + call( + "uptime.result_processor.handle_result_for_project", + tags={"status": CHECKSTATUS_FAILURE, "mode": "auto_detected_active"}, + ), + ] + ) + + hashed_fingerprint = md5(str(self.project_subscription.id).encode("utf-8")).hexdigest() + with pytest.raises(Group.DoesNotExist): + Group.objects.get(grouphash__hash=hashed_fingerprint) + self.project_subscription.refresh_from_db() + assert self.project_subscription.uptime_status == UptimeStatus.FAILED + def test_resolve(self): result = self.create_uptime_result( self.subscription.subscription_id, scheduled_check_time=datetime.now() - timedelta(minutes=5), ) - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ @@ -109,7 +132,9 @@ def test_resolve(self): status=CHECKSTATUS_SUCCESS, scheduled_check_time=datetime.now() - timedelta(minutes=4), ) - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ @@ -127,7 +152,9 @@ def test_resolve(self): def test_no_subscription(self): subscription_id = uuid.uuid4().hex result = self.create_uptime_result(subscription_id) - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls([call("uptime.result_processor.subscription_not_found")]) @@ -137,7 +164,9 @@ def test_skip_already_processed(self): build_last_update_key(self.project_subscription), int(result["scheduled_check_time_ms"]), ) - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ @@ -163,6 +192,7 @@ def test_missed(self): with ( mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics, mock.patch("sentry.uptime.consumers.results_consumer.logger") as logger, + self.feature("organizations:uptime-create-issues"), ): self.send_result(result) metrics.incr.assert_called_once_with( @@ -189,7 +219,9 @@ def test_onboarding_failure(self): redis = _get_cluster() key = build_onboarding_failure_key(self.project_subscription) assert redis.get(key) is None - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ @@ -216,6 +248,7 @@ def test_onboarding_failure(self): "sentry.uptime.consumers.results_consumer.ONBOARDING_FAILURE_THRESHOLD", new=2 ), self.tasks(), + self.feature("organizations:uptime-create-issues"), ): self.send_result(result) metrics.incr.assert_has_calls( @@ -251,7 +284,9 @@ def test_onboarding_success_ongoing(self): redis = _get_cluster() key = build_onboarding_failure_key(self.project_subscription) assert redis.get(key) is None - with mock.patch("sentry.uptime.consumers.results_consumer.metrics") as metrics: + with mock.patch( + "sentry.uptime.consumers.results_consumer.metrics" + ) as metrics, self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ @@ -284,7 +319,7 @@ def test_onboarding_success_graduate(self): assert redis.get(key) is None with mock.patch( "sentry.uptime.consumers.results_consumer.metrics" - ) as metrics, self.tasks(): + ) as metrics, self.tasks(), self.feature("organizations:uptime-create-issues"): self.send_result(result) metrics.incr.assert_has_calls( [ diff --git a/tests/sentry/uptime/detectors/test_detector.py b/tests/sentry/uptime/detectors/test_detector.py index 0a835b8af06f8a..c371a31c9a2ff6 100644 --- a/tests/sentry/uptime/detectors/test_detector.py +++ b/tests/sentry/uptime/detectors/test_detector.py @@ -25,3 +25,9 @@ def test_disabled_for_project(self): self.project.update_option("sentry:uptime_autodetection", False) detect_base_url_for_project(self.project, "https://sentry.io") self.assert_organization_key(self.organization, False) + + @with_feature("organizations:uptime-automatic-hostname-detection") + def test_disabled_for_organization(self): + self.organization.update_option("sentry:uptime_autodetection", False) + detect_base_url_for_project(self.project, "https://sentry.io") + self.assert_organization_key(self.organization, False) diff --git a/tests/sentry/uptime/detectors/test_ranking.py b/tests/sentry/uptime/detectors/test_ranking.py index 3f9c10e2534be4..120f6251588d61 100644 --- a/tests/sentry/uptime/detectors/test_ranking.py +++ b/tests/sentry/uptime/detectors/test_ranking.py @@ -14,6 +14,7 @@ get_candidate_urls_for_project, get_organization_bucket, get_project_base_url_rank_key, + should_detect_for_organization, should_detect_for_project, ) @@ -174,3 +175,12 @@ def test(self): assert not should_detect_for_project(self.project) self.project.update_option("sentry:uptime_autodetection", True) assert should_detect_for_project(self.project) + + +class ShouldDetectForOrgTest(TestCase): + def test(self): + assert should_detect_for_organization(self.organization) + self.organization.update_option("sentry:uptime_autodetection", False) + assert not should_detect_for_organization(self.organization) + self.organization.update_option("sentry:uptime_autodetection", True) + assert should_detect_for_organization(self.organization) diff --git a/tests/sentry/uptime/detectors/test_tasks.py b/tests/sentry/uptime/detectors/test_tasks.py index baf88d51fc2f4d..871134be2c0252 100644 --- a/tests/sentry/uptime/detectors/test_tasks.py +++ b/tests/sentry/uptime/detectors/test_tasks.py @@ -18,6 +18,7 @@ _get_cluster, add_base_url_to_rank, get_organization_bucket, + get_project_base_url_rank_key, ) from sentry.uptime.detectors.tasks import ( LAST_PROCESSED_KEY, @@ -144,17 +145,38 @@ def test(self): ] ) - def test_should_not_detect(self): + def test_should_not_detect_project(self): with mock.patch( - # TODO: Replace this mock with real tests when we implement this function properly - "sentry.uptime.detectors.tasks.should_detect_for_project", - return_value=False, - ), mock.patch( "sentry.uptime.detectors.tasks.get_candidate_urls_for_project" ) as mock_get_candidate_urls_for_project: + self.project.update_option("sentry:uptime_autodetection", False) assert not process_project_url_ranking(self.project, 5) mock_get_candidate_urls_for_project.assert_not_called() + def test_should_not_detect_organization(self): + url_1 = "https://sentry.io" + url_2 = "https://sentry.sentry.io" + project_2 = self.create_project() + add_base_url_to_rank(self.project, url_2) + add_base_url_to_rank(self.project, url_1) + add_base_url_to_rank(self.project, url_1) + add_base_url_to_rank(project_2, url_1) + + keys = [ + get_project_base_url_rank_key(self.project), + get_project_base_url_rank_key(project_2), + ] + redis = _get_cluster() + assert all(redis.exists(key) for key in keys) + + with mock.patch( + "sentry.uptime.detectors.tasks.get_candidate_urls_for_project" + ) as mock_get_candidate_urls_for_project: + self.organization.update_option("sentry:uptime_autodetection", False) + assert not process_organization_url_ranking(self.organization.id) + mock_get_candidate_urls_for_project.assert_not_called() + assert all(not redis.exists(key) for key in keys) + @freeze_time() class ProcessProjectUrlRankingTest(TestCase): diff --git a/tests/sentry/uptime/endpoints/__init__.py b/tests/sentry/uptime/endpoints/__init__.py new file mode 100644 index 00000000000000..e69de29bb2d1d6 diff --git a/tests/sentry/uptime/endpoints/test_serializers.py b/tests/sentry/uptime/endpoints/test_serializers.py new file mode 100644 index 00000000000000..ce9d321d587e62 --- /dev/null +++ b/tests/sentry/uptime/endpoints/test_serializers.py @@ -0,0 +1,41 @@ +from sentry.api.serializers import serialize +from sentry.testutils.cases import TestCase + + +class ProjectUptimeSubscriptionSerializerTest(TestCase): + def test(self): + uptime_monitor = self.create_project_uptime_subscription() + result = serialize(uptime_monitor) + + assert result == { + "id": str(uptime_monitor.id), + "projectSlug": self.project.slug, + "name": uptime_monitor.name, + "status": uptime_monitor.uptime_status, + "mode": uptime_monitor.mode, + "url": uptime_monitor.uptime_subscription.url, + "intervalSeconds": uptime_monitor.uptime_subscription.interval_seconds, + "timeoutMs": uptime_monitor.uptime_subscription.timeout_ms, + "owner": None, + } + + def test_owner(self): + uptime_monitor = self.create_project_uptime_subscription(owner=self.user) + result = serialize(uptime_monitor) + + assert result == { + "id": str(uptime_monitor.id), + "projectSlug": self.project.slug, + "name": uptime_monitor.name, + "status": uptime_monitor.uptime_status, + "mode": uptime_monitor.mode, + "url": uptime_monitor.uptime_subscription.url, + "intervalSeconds": uptime_monitor.uptime_subscription.interval_seconds, + "timeoutMs": uptime_monitor.uptime_subscription.timeout_ms, + "owner": { + "email": self.user.email, + "id": str(self.user.id), + "name": self.user.get_username(), + "type": "user", + }, + } diff --git a/yarn.lock b/yarn.lock index 163a946b54eb95..d02a448abd486f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9565,10 +9565,10 @@ platform@^1.3.3: resolved "https://registry.yarnpkg.com/platform/-/platform-1.3.6.tgz#48b4ce983164b209c2d45a107adb31f473a6e7a7" integrity sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg== -platformicons@^5.10.9: - version "5.10.9" - resolved "https://registry.yarnpkg.com/platformicons/-/platformicons-5.10.9.tgz#ca0ceb6309321f11e8ee0317c4193b77448948f3" - integrity sha512-QzDUFxOIK8VPpj+uHXbps7q+VgQmI3uAt+hlMZl5fSVWR0guOEJTaNXQFwCsgrOCz82udIUX8F4jRNDrsZPHzg== +platformicons@^6.0.1: + version "6.0.1" + resolved "https://registry.yarnpkg.com/platformicons/-/platformicons-6.0.1.tgz#69e2cc1a1fe1533804e8223f9382295379db8bca" + integrity sha512-RaT4+NfxuV2nqFxAZNaksa1UXKZS5QXdtvFc2y9A4Sp5Blt888jbSQJ7mUcmjw+Aqoq6oyz2cQvOCf4uymSizA== dependencies: "@types/node" "*" "@types/react" "*"