diff --git a/engine/apps/api/tests/test_alert_group.py b/engine/apps/api/tests/test_alert_group.py index a22ebdb3..8ae86d5b 100644 --- a/engine/apps/api/tests/test_alert_group.py +++ b/engine/apps/api/tests/test_alert_group.py @@ -863,6 +863,46 @@ def test_get_filter_escalation_chain( assert len(response.data["results"]) == 2 +@pytest.mark.django_db +def test_get_filter_labels( + make_organization_and_user_with_plugin_token, + make_user_for_organization, + make_alert_receive_channel, + make_channel_filter, + make_alert_group, + make_alert, + make_alert_group_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + + alert_receive_channel = make_alert_receive_channel(organization) + channel_filter = make_channel_filter(alert_receive_channel, is_default=True) + + alert_groups = [] + for _ in range(3): + alert_group = make_alert_group(alert_receive_channel, channel_filter=channel_filter) + make_alert(alert_group=alert_group, raw_request_data=alert_raw_request_data) + alert_groups.append(alert_group) + + make_alert_group_label_association(organization, alert_groups[0], key_name="a", value_name="b") + make_alert_group_label_association(organization, alert_groups[0], key_name="c", value_name="d") + make_alert_group_label_association(organization, alert_groups[1], key_name="a", value_name="b") + make_alert_group_label_association(organization, alert_groups[2], key_name="c", value_name="d") + + client = APIClient() + url = reverse("api-internal:alertgroup-list") + + response = client.get( + url + "?label=a:b&label=c:d", + format="json", + **make_user_auth_headers(user, token), + ) + assert response.status_code == status.HTTP_200_OK + assert len(response.json()["results"]) == 1 + assert response.json()["results"][0]["pk"] == alert_groups[0].public_primary_key + + @pytest.mark.django_db @pytest.mark.parametrize( "role,expected_status", diff --git a/engine/apps/api/tests/test_labels.py b/engine/apps/api/tests/test_labels.py index a4c314bc..e47b7218 100644 --- a/engine/apps/api/tests/test_labels.py +++ b/engine/apps/api/tests/test_labels.py @@ -299,3 +299,47 @@ def test_labels_permissions_create_update_actions( url = reverse("api-internal:create_label") response = client.post(url, format="json", data={}, **make_user_auth_headers(user, token)) assert response.status_code == expected_status + + +@pytest.mark.django_db +def test_alert_group_labels_get_keys( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_alert_group, + make_alert_group_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + + alert_receive_channel = make_alert_receive_channel(user.organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert_group_label_association(organization, alert_group, key_name="a", value_name="b") + + client = APIClient() + url = reverse("api-internal:alert_group_labels-get_keys") + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [{"id": "a", "name": "a"}] + + +@pytest.mark.django_db +def test_alert_group_labels_get_key( + make_organization_and_user_with_plugin_token, + make_alert_receive_channel, + make_alert_group, + make_alert_group_label_association, + make_user_auth_headers, +): + organization, user, token = make_organization_and_user_with_plugin_token() + + alert_receive_channel = make_alert_receive_channel(user.organization) + alert_group = make_alert_group(alert_receive_channel) + make_alert_group_label_association(organization, alert_group, key_name="a", value_name="b") + + client = APIClient() + url = reverse("api-internal:alert_group_labels-get_key", kwargs={"key_id": "a"}) + response = client.get(url, format="json", **make_user_auth_headers(user, token)) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == {"key": {"id": "a", "name": "a"}, "values": [{"id": "b", "name": "b"}]} diff --git a/engine/apps/api/urls.py b/engine/apps/api/urls.py index 7bd3b703..c8c79a1e 100644 --- a/engine/apps/api/urls.py +++ b/engine/apps/api/urls.py @@ -13,7 +13,7 @@ from .views.escalation_chain import EscalationChainViewSet from .views.escalation_policy import EscalationPolicyView from .views.features import FeaturesAPIView from .views.integration_heartbeat import IntegrationHeartBeatView -from .views.labels import LabelsViewSet +from .views.labels import AlertGroupLabelsViewSet, LabelsViewSet from .views.live_setting import LiveSettingViewSet from .views.on_call_shifts import OnCallShiftView from .views.organization import ( @@ -129,3 +129,17 @@ urlpatterns += [ ), re_path(r"^labels/?$", LabelsViewSet.as_view({"post": "create_label"}), name="create_label"), ] + +# Alert group labels +urlpatterns += [ + re_path( + r"^alertgroups/labels/keys/?$", + AlertGroupLabelsViewSet.as_view({"get": "get_keys"}), + name="alert_group_labels-get_keys", + ), + re_path( + r"^alertgroups/labels/id/(?P.+/?$)", + AlertGroupLabelsViewSet.as_view({"get": "get_key"}), + name="alert_group_labels-get_key", + ), +] diff --git a/engine/apps/api/views/alert_group.py b/engine/apps/api/views/alert_group.py index f022010e..a1cadb0c 100644 --- a/engine/apps/api/views/alert_group.py +++ b/engine/apps/api/views/alert_group.py @@ -23,6 +23,7 @@ from apps.api.serializers.alert_group import AlertGroupListSerializer, AlertGrou from apps.api.serializers.team import TeamSerializer from apps.auth_token.auth import PluginAuthentication from apps.base.models.user_notification_policy_log_record import UserNotificationPolicyLogRecord +from apps.labels.utils import is_labels_feature_enabled from apps.mobile_app.auth import MobileAppAuthTokenAuthentication from apps.user_management.models import Team, User from common.api_helpers.exceptions import BadRequest @@ -331,10 +332,22 @@ class AlertGroupView( alert_receive_channels_qs = alert_receive_channels_qs.filter(*self.available_teams_lookup_args) alert_receive_channels_ids = list(alert_receive_channels_qs.values_list("id", flat=True)) + queryset = AlertGroup.objects.filter(channel__in=alert_receive_channels_ids) - queryset = AlertGroup.objects.filter( - channel__in=alert_receive_channels_ids, - ) + # filter by labels + labels = self.request.query_params.getlist("label") + for label in labels: + label_split = label.split(":") + if len(label_split) != 2: + continue + key_name, value_name = label_split + + # Utilize (organization, key_name, value_name, alert_group) index on AlertGroupAssociatedLabel + queryset = queryset.filter( + labels__organization=self.request.auth.organization, + labels__key_name=key_name, + labels__value_name=value_name, + ) queryset = queryset.only("id") @@ -745,6 +758,15 @@ class AlertGroupView( }, ] + if is_labels_feature_enabled(self.request.auth.organization): + filter_options.append( + { + "name": "label", + "display_name": "Label", + "type": "alert_group_labels", + } + ) + if filter_name is not None: filter_options = list(filter(lambda f: filter_name in f["name"], filter_options)) diff --git a/engine/apps/api/views/labels.py b/engine/apps/api/views/labels.py index b912fc83..9b3cd36c 100644 --- a/engine/apps/api/views/labels.py +++ b/engine/apps/api/views/labels.py @@ -23,7 +23,14 @@ from common.api_helpers.exceptions import BadRequest logger = logging.getLogger(__name__) -class LabelsViewSet(ViewSet): +class LabelsFeatureFlagViewSet(ViewSet): + def initial(self, request, *args, **kwargs): + super().initial(request, *args, **kwargs) + if not is_labels_feature_enabled(self.request.auth.organization): + raise NotFound + + +class LabelsViewSet(LabelsFeatureFlagViewSet): """ Proxy requests to labels-app to create/update labels """ @@ -40,11 +47,6 @@ class LabelsViewSet(ViewSet): "rename_value": LegacyAccessControlRole.EDITOR, } - def initial(self, request, *args, **kwargs): - super().initial(request, *args, **kwargs) - if not is_labels_feature_enabled(self.request.auth.organization): - raise NotFound - @extend_schema(responses=LabelKeySerializer(many=True)) def get_keys(self, request): """List of labels keys""" @@ -135,6 +137,41 @@ class LabelsViewSet(ViewSet): update_labels_cache.apply_async((label_data,)) +class AlertGroupLabelsViewSet(LabelsFeatureFlagViewSet): + """ + This viewset is similar to LabelsViewSet, but it works with alert group labels. + Alert group labels are stored in the database, not in the label repo. + """ + + permission_classes = (IsAuthenticated, BasicRolePermission) + authentication_classes = (PluginAuthentication,) + basic_role_permissions = { + "get_keys": LegacyAccessControlRole.VIEWER, + "get_key": LegacyAccessControlRole.VIEWER, + } + + @extend_schema(responses=LabelKeySerializer(many=True)) + def get_keys(self, request): + """ + List of alert group label keys. + IDs are the same as names to keep the response format consistent with LabelsViewSet.get_keys(). + """ + names = self.request.auth.organization.alert_group_labels.values_list("key_name", flat=True).distinct() + return Response([{"id": name, "name": name} for name in names]) + + @extend_schema(responses=LabelKeyValuesSerializer) + def get_key(self, request, key_id): + """Key with the list of values. IDs and names are interchangeable (see get_keys() for more details).""" + values = ( + self.request.auth.organization.alert_group_labels.filter(key_name=key_id) + .values_list("value_name", flat=True) + .distinct() + ) + return Response( + {"key": {"id": key_id, "name": key_id}, "values": [{"id": value, "name": value} for value in values]} + ) + + class LabelsAssociatingMixin: # use for labelable objects views (ex. AlertReceiveChannelView) def filter_by_labels(self, queryset): """Call this method in `get_queryset()` to add filtering by labels""" diff --git a/engine/apps/labels/migrations/0002_alertgroupassociatedlabel_and_more.py b/engine/apps/labels/migrations/0002_alertgroupassociatedlabel_and_more.py new file mode 100644 index 00000000..0e74eebc --- /dev/null +++ b/engine/apps/labels/migrations/0002_alertgroupassociatedlabel_and_more.py @@ -0,0 +1,30 @@ +# Generated by Django 4.2.6 on 2023-11-01 14:42 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('alerts', '0035_alter_alertreceivechannel_maintenance_author'), + ('user_management', '0017_alter_organization_maintenance_author'), + ('labels', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='AlertGroupAssociatedLabel', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('key_name', models.CharField(max_length=200)), + ('value_name', models.CharField(max_length=200)), + ('alert_group', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='labels', to='alerts.alertgroup')), + ('organization', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='alert_group_labels', to='user_management.organization')), + ], + ), + migrations.AddConstraint( + model_name='alertgroupassociatedlabel', + constraint=models.UniqueConstraint(fields=('organization', 'key_name', 'value_name', 'alert_group'), name='unique_alert_group_label'), + ), + ] diff --git a/engine/apps/labels/models.py b/engine/apps/labels/models.py index 6d753572..233d47b4 100644 --- a/engine/apps/labels/models.py +++ b/engine/apps/labels/models.py @@ -112,3 +112,27 @@ class AlertReceiveChannelAssociatedLabel(AssociatedLabel): def get_associating_label_field_name() -> str: """Returns ForeignKey field name for the associated model""" return "alert_receive_channel" + + +class AlertGroupAssociatedLabel(models.Model): + """ + A model for alert group labels (similar to AlertReceiveChannelAssociatedLabel for integrations). + The key difference is that alert group labels do not use label IDs, but rather key and value names explicitly. + This is done to make alert group labels "static" (so they don't change when the labels are updated in label repo). + """ + + alert_group = models.ForeignKey("alerts.AlertGroup", on_delete=models.CASCADE, related_name="labels") + organization = models.ForeignKey( + "user_management.Organization", on_delete=models.CASCADE, related_name="alert_group_labels" + ) + + key_name = models.CharField(max_length=200) + value_name = models.CharField(max_length=200) + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["organization", "key_name", "value_name", "alert_group"], + name="unique_alert_group_label", + ) + ] diff --git a/engine/apps/labels/tests/factories.py b/engine/apps/labels/tests/factories.py index db5aa8bd..163cf972 100644 --- a/engine/apps/labels/tests/factories.py +++ b/engine/apps/labels/tests/factories.py @@ -1,6 +1,11 @@ import factory -from apps.labels.models import AlertReceiveChannelAssociatedLabel, LabelKeyCache, LabelValueCache +from apps.labels.models import ( + AlertGroupAssociatedLabel, + AlertReceiveChannelAssociatedLabel, + LabelKeyCache, + LabelValueCache, +) from common.utils import UniqueFaker @@ -23,3 +28,8 @@ class LabelValueFactory(factory.DjangoModelFactory): class AlertReceiveChannelAssociatedLabelFactory(factory.DjangoModelFactory): class Meta: model = AlertReceiveChannelAssociatedLabel + + +class AlertGroupAssociatedLabelFactory(factory.DjangoModelFactory): + class Meta: + model = AlertGroupAssociatedLabel diff --git a/engine/conftest.py b/engine/conftest.py index e7acc97e..6951c38b 100644 --- a/engine/conftest.py +++ b/engine/conftest.py @@ -57,7 +57,12 @@ from apps.base.tests.factories import ( ) from apps.email.tests.factories import EmailMessageFactory from apps.heartbeat.tests.factories import IntegrationHeartBeatFactory -from apps.labels.tests.factories import AlertReceiveChannelAssociatedLabelFactory, LabelKeyFactory, LabelValueFactory +from apps.labels.tests.factories import ( + AlertGroupAssociatedLabelFactory, + AlertReceiveChannelAssociatedLabelFactory, + LabelKeyFactory, + LabelValueFactory, +) from apps.mobile_app.models import MobileAppAuthToken, MobileAppVerificationToken from apps.phone_notifications.phone_backend import PhoneBackend from apps.phone_notifications.tests.factories import PhoneCallRecordFactory, SMSRecordFactory @@ -974,3 +979,11 @@ def make_integration_label_association(make_label_key_and_value): ) return _make_integration_label_association + + +@pytest.fixture +def make_alert_group_label_association(): + def _make_alert_group_label_association(organization, alert_group, **kwargs): + return AlertGroupAssociatedLabelFactory(alert_group=alert_group, organization=organization, **kwargs) + + return _make_alert_group_label_association diff --git a/grafana-plugin/src/containers/Labels/LabelsFilter.tsx b/grafana-plugin/src/containers/Labels/LabelsFilter.tsx index e3d61f4d..5c099987 100644 --- a/grafana-plugin/src/containers/Labels/LabelsFilter.tsx +++ b/grafana-plugin/src/containers/Labels/LabelsFilter.tsx @@ -12,6 +12,7 @@ import styles from './Labels.module.css'; const cx = cn.bind(styles); interface LabelsFilterProps { + filterType: 'labels' | 'alert_group_labels'; autoFocus: boolean; className: string; value: string[]; @@ -19,22 +20,32 @@ interface LabelsFilterProps { } const LabelsFilter = observer((props: LabelsFilterProps) => { - const { className, autoFocus, value: propsValue, onChange } = props; + const { filterType, className, autoFocus, value: propsValue, onChange } = props; const [value, setValue] = useState([]); const [keys, setKeys] = useState([]); - const { labelsStore } = useStore(); + const { alertGroupStore, labelsStore } = useStore(); + + const loadKeys = + filterType === 'alert_group_labels' + ? alertGroupStore.loadLabelsKeys.bind(alertGroupStore) + : labelsStore.loadKeys.bind(labelsStore); + + const loadValuesForKey = + filterType === 'alert_group_labels' + ? alertGroupStore.loadValuesForLabelKey.bind(alertGroupStore) + : labelsStore.loadValuesForKey.bind(labelsStore); useEffect(() => { - labelsStore.loadKeys().then(setKeys); + loadKeys().then(setKeys); }, []); useEffect(() => { const keyValuePairs = (propsValue || []).map((k) => k.split(':')); - const promises = keyValuePairs.map(([keyId]) => labelsStore.loadValuesForKey(keyId)); + const promises = keyValuePairs.map(([keyId]) => loadValuesForKey(keyId)); const fetchKeyValues = async () => await Promise.all(promises); @@ -56,7 +67,7 @@ const LabelsFilter = observer((props: LabelsFilterProps) => { return new Promise((resolve) => { const keysFiltered = keys.filter((k) => k.name.toLowerCase().includes(search.toLowerCase())); - const promises = keysFiltered.map((key) => labelsStore.loadValuesForKey(key.id)); + const promises = keysFiltered.map((key) => loadValuesForKey(key.id)); Promise.all(promises).then((list) => { const options = list.reduce((memo, { key, values }) => { diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts index e0addb9f..5d71fc50 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.helpers.ts @@ -22,7 +22,12 @@ export function parseFilters( let value: any = rawValue; - if (filterOption.type === 'options' || filterOption.type === 'team_select' || filterOption.type === 'labels') { + if ( + filterOption.type === 'options' || + filterOption.type === 'team_select' || + filterOption.type === 'labels' || + filterOption.type === 'alert_group_labels' + ) { if (!Array.isArray(rawValue)) { value = [rawValue]; } diff --git a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx index bfcdc2eb..936ec10c 100644 --- a/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx +++ b/grafana-plugin/src/containers/RemoteFilters/RemoteFilters.tsx @@ -330,8 +330,10 @@ class RemoteFilters extends Component { ); case 'labels': + case 'alert_group_labels': return ( v.name.toLowerCase().includes(search.toLowerCase())); // TODO remove after backend search implementation + + return { ...result, values: filteredValues }; + } }