Pārlūkot izejas kodu

use new config endpoint and add alert notifications (#4286)

d-g-town 2 gadi atpakaļ
vecāks
revīzija
0ad8618b2d

+ 45 - 24
api/server/handlers/notifications/get_notification_config.go

@@ -1,6 +1,8 @@
 package notifications
 
 import (
+	"encoding/json"
+	"fmt"
 	"net/http"
 
 	"connectrpc.com/connect"
@@ -84,26 +86,55 @@ func (n *GetNotificationConfigHandler) ServeHTTP(w http.ResponseWriter, r *http.
 		return
 	}
 
+	config, err := configFromProto(ccpResp.Msg.Config)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error getting config from proto")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	response := &GetNotificationConfigResponse{
-		Config: configFromProto(ccpResp.Msg.Config),
+		Config: config,
 	}
 
 	n.WriteResult(w, r, response)
 }
 
-func configFromProto(proto *porterv1.NotificationConfig) Config {
-	if proto == nil {
-		return Config{}
+func configFromProto(proto *porterv1.NotificationConfig) (Config, error) {
+	// initializing the map to true for all statuses and types
+	// ensures that the default behavior is to notify for missing statuses and types
+	statuses := trueMap(allStatuses)
+	types := trueMap(allTypes)
+
+	for _, protoStatus := range proto.EnabledStatuses {
+		if status, ok := transformProtoToStatusString[protoStatus.Status]; ok {
+			statuses[status] = protoStatus.Enabled
+		}
+	}
+	for _, protoType := range proto.EnabledTypes {
+		if t, ok := transformProtoToTypeString[protoType.Type]; ok {
+			types[t] = protoType.Enabled
+		}
 	}
 
-	var statuses []Status
-	for _, status := range proto.Statuses {
-		statuses = append(statuses, Status{transformProtoToStatusString[status]})
+	statusesStruct := StatusesEnabled{}
+	by, err := json.Marshal(statuses)
+	if err != nil {
+		return Config{}, fmt.Errorf("error marshalling statuses: %s", err)
+	}
+	err = json.Unmarshal(by, &statusesStruct)
+	if err != nil {
+		return Config{}, fmt.Errorf("error unmarshalling statuses: %s", err)
 	}
 
-	var types []Type
-	for _, t := range proto.EventTypes {
-		types = append(types, Type{transformProtoToTypeString[t]})
+	typesStruct := TypesEnabled{}
+	by, err = json.Marshal(types)
+	if err != nil {
+		return Config{}, fmt.Errorf("error marshalling types: %s", err)
+	}
+	err = json.Unmarshal(by, &typesStruct)
+	if err != nil {
+		return Config{}, fmt.Errorf("error unmarshalling types: %s", err)
 	}
 
 	var mention string
@@ -111,21 +142,11 @@ func configFromProto(proto *porterv1.NotificationConfig) Config {
 		mention = proto.SlackConfig.Mentions[0]
 	}
 
-	return Config{
-		Statuses: statuses,
+	config := Config{
+		Statuses: statusesStruct,
 		Mention:  mention,
-		Types:    types,
+		Types:    typesStruct,
 	}
-}
-
-var transformProtoToStatusString = map[porterv1.EnumNotificationStatus]string{
-	porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_SUCCESSFUL:  "successful",
-	porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_FAILED:      "failed",
-	porterv1.EnumNotificationStatus_ENUM_NOTIFICATION_STATUS_PROGRESSING: "progressing",
-}
 
-var transformProtoToTypeString = map[porterv1.EnumNotificationEventType]string{
-	porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_DEPLOY:    "deploy",
-	porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_PREDEPLOY: "pre-deploy",
-	porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_BUILD:     "build",
+	return config, nil
 }

+ 109 - 24
api/server/handlers/notifications/update_notification_config.go

@@ -1,6 +1,8 @@
 package notifications
 
 import (
+	"encoding/json"
+	"fmt"
 	"net/http"
 
 	"connectrpc.com/connect"
@@ -42,19 +44,24 @@ type UpdateNotificationConfigRequest struct {
 
 // Config is the config object for the /notifications endpoint
 type Config struct {
-	Mention  string   `json:"mention"`
-	Statuses []Status `json:"statuses"`
-	Types    []Type   `json:"types"`
+	Mention  string          `json:"mention"`
+	Statuses StatusesEnabled `json:"statuses"`
+	Types    TypesEnabled    `json:"types"`
 }
 
-// Status is a wrapper object over a string for zod validation
-type Status struct {
-	Status string `json:"status"`
+// StatusesEnabled is a struct that signifies whether a status is enabled or not
+type StatusesEnabled struct {
+	Successful  bool `json:"successful"`
+	Failed      bool `json:"failed"`
+	Progressing bool `json:"progressing"`
 }
 
-// Type is a wrapper object over a string for zod validation
-type Type struct {
-	Type string `json:"type"`
+// TypesEnabled is a struct that signifies whether a type is enabled or not
+type TypesEnabled struct {
+	Deploy    bool `json:"deploy"`
+	Build     bool `json:"build"`
+	PreDeploy bool `json:"predeploy"`
+	Alert     bool `json:"alert"`
 }
 
 // UpdateNotificationConfigResponse is the response object for the /notifications/config/{notification_config_id} endpoint
@@ -91,10 +98,17 @@ func (n *UpdateNotificationConfigHandler) ServeHTTP(w http.ResponseWriter, r *ht
 		return
 	}
 
+	configProto, err := configToProto(request.Config)
+	if err != nil {
+		err := telemetry.Error(ctx, span, err, "error converting config to proto")
+		n.HandleAPIError(w, r, apierrors.NewErrPassThroughToClient(err, http.StatusInternalServerError))
+		return
+	}
+
 	updateReq := connect.NewRequest(&porterv1.UpdateNotificationConfigRequest{
 		ProjectId:            int64(project.ID),
 		NotificationConfigId: int64(notificationConfigID),
-		Config:               configToProto(request.Config),
+		Config:               configProto,
 		SlackIntegrationId:   int64(request.SlackIntegrationID),
 	})
 	updateResp, err := n.Config().ClusterControlPlaneClient.UpdateNotificationConfig(ctx, updateReq)
@@ -117,22 +131,58 @@ func (n *UpdateNotificationConfigHandler) ServeHTTP(w http.ResponseWriter, r *ht
 	n.WriteResult(w, r, response)
 }
 
-func configToProto(config Config) *porterv1.NotificationConfig {
-	var statuses []porterv1.EnumNotificationStatus
-	for _, status := range config.Statuses {
-		statuses = append(statuses, transformStatusStringToProto[status.Status])
+func configToProto(config Config) (*porterv1.NotificationConfig, error) {
+	statusMap := map[string]bool{}
+
+	by, err := json.Marshal(config.Statuses)
+	if err != nil {
+		return nil, fmt.Errorf("error marshalling statuses: %s", err)
+	}
+
+	err = json.Unmarshal(by, &statusMap)
+	if err != nil {
+		return nil, fmt.Errorf("error unmarshalling statuses: %s", err)
+	}
+
+	var statuses []*porterv1.NotificationStatusEnabled
+	for status, enabled := range statusMap {
+		if protoStatus, ok := transformStatusStringToProto[status]; ok {
+			statuses = append(statuses, &porterv1.NotificationStatusEnabled{
+				Status:  protoStatus,
+				Enabled: enabled,
+			})
+		}
 	}
 
-	var types []porterv1.EnumNotificationEventType
-	for _, t := range config.Types {
-		types = append(types, transformTypeStringToProto[t.Type])
+	typeMap := map[string]bool{}
+
+	by, err = json.Marshal(config.Types)
+	if err != nil {
+		return nil, fmt.Errorf("error marshalling types: %s", err)
 	}
 
-	return &porterv1.NotificationConfig{
-		Statuses:    statuses,
-		EventTypes:  types,
-		SlackConfig: &porterv1.SlackConfig{Mentions: []string{config.Mention}},
+	err = json.Unmarshal(by, &typeMap)
+	if err != nil {
+		return nil, fmt.Errorf("error unmarshalling types: %s", err)
 	}
+
+	var types []*porterv1.NotificationTypeEnabled
+	for t, enabled := range typeMap {
+		if protoType, ok := transformTypeStringToProto[t]; ok {
+			types = append(types, &porterv1.NotificationTypeEnabled{
+				Type:    protoType,
+				Enabled: enabled,
+			})
+		}
+	}
+
+	protoConfig := &porterv1.NotificationConfig{
+		EnabledStatuses: statuses,
+		EnabledTypes:    types,
+		SlackConfig:     &porterv1.SlackConfig{Mentions: []string{config.Mention}},
+	}
+
+	return protoConfig, nil
 }
 
 var transformStatusStringToProto = map[string]porterv1.EnumNotificationStatus{
@@ -142,7 +192,42 @@ var transformStatusStringToProto = map[string]porterv1.EnumNotificationStatus{
 }
 
 var transformTypeStringToProto = map[string]porterv1.EnumNotificationEventType{
-	"deploy":     porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_DEPLOY,
-	"build":      porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_BUILD,
-	"pre-deploy": porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_PREDEPLOY,
+	"deploy":    porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_DEPLOY,
+	"build":     porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_BUILD,
+	"predeploy": porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_PREDEPLOY,
+	"alert":     porterv1.EnumNotificationEventType_ENUM_NOTIFICATION_EVENT_TYPE_ALERT,
 }
+
+// reverseMap returns a map with the keys and values swapped
+func reverseMap[K comparable, V comparable](m map[K]V) map[V]K {
+	result := map[V]K{}
+	for k, v := range m {
+		result[v] = k
+	}
+	return result
+}
+
+// mapKeys returns the keys of a map as a slice
+func mapKeys[K comparable, V any](m map[K]V) []K {
+	var keys []K
+	for k := range m {
+		keys = append(keys, k)
+	}
+	return keys
+}
+
+// trueMap returns a map with the keys set to true
+func trueMap[K comparable](keys []K) map[K]bool {
+	m := map[K]bool{}
+	for _, k := range keys {
+		m[k] = true
+	}
+	return m
+}
+
+var (
+	transformProtoToStatusString = reverseMap(transformStatusStringToProto)
+	transformProtoToTypeString   = reverseMap(transformTypeStringToProto)
+	allStatuses                  = mapKeys(transformStatusStringToProto)
+	allTypes                     = mapKeys(transformTypeStringToProto)
+)

+ 3 - 0
dashboard/src/assets/alert_square.svg

@@ -0,0 +1,3 @@
+<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
+<path d="M12 11.9999V7.1999M12 15.5577V15.5999M21.6 5.9999L21.6 18C21.6 19.9882 19.9882 21.6 18 21.6H6.00002C4.0118 21.6 2.40002 19.9882 2.40002 18V5.9999C2.40002 4.01168 4.0118 2.3999 6.00002 2.3999H18C19.9882 2.3999 21.6 4.01168 21.6 5.9999Z" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
+</svg>

+ 19 - 15
dashboard/src/components/porter/Checkbox.tsx

@@ -1,5 +1,7 @@
-import React, { useEffect, useState } from "react";
+import React from "react";
 import styled from "styled-components";
+
+import Container from "./Container";
 import Tooltip from "./Tooltip";
 
 type Props = {
@@ -8,6 +10,7 @@ type Props = {
   children: React.ReactNode;
   disabled?: boolean;
   disabledTooltip?: string;
+  height?: string;
 };
 
 const Checkbox: React.FC<Props> = ({
@@ -17,29 +20,32 @@ const Checkbox: React.FC<Props> = ({
   disabled = false,
   disabledTooltip,
 }) => {
-  return (
-    disabled && disabledTooltip ?
-      <Tooltip content={disabledTooltip} position="right">
-        <StyledCheckbox 
-          onClick={disabled ? () => { } : toggleChecked}
+  return disabled && disabledTooltip ? (
+    <Tooltip content={disabledTooltip} position="right">
+      <Container row>
+        <StyledCheckbox
+          onClick={disabled ? () => {} : toggleChecked}
           disabled={disabled}
         >
           <Box checked={checked}>
             <i className="material-icons">done</i>
           </Box>
-          {children}
         </StyledCheckbox>
-      </Tooltip>
-      :
-      <StyledCheckbox 
-        onClick={disabled ? () => { } : toggleChecked}
+        {children}
+      </Container>
+    </Tooltip>
+  ) : (
+    <Container row>
+      <StyledCheckbox
+        onClick={disabled ? () => {} : toggleChecked}
         disabled={disabled}
       >
         <Box checked={checked}>
           <i className="material-icons">done</i>
         </Box>
-        {children}
       </StyledCheckbox>
+      {children}
+    </Container>
   );
 };
 
@@ -48,8 +54,6 @@ export default Checkbox;
 const StyledCheckbox = styled.div<{
   disabled?: boolean;
 }>`
-  display: flex;
-  align-items: center;
   cursor: ${(props) => (props.disabled ? "not-allowed" : "pointer")};
 `;
 
@@ -71,4 +75,4 @@ const Box = styled.div<{
     padding-left: 0px;
     display: ${(props) => (props.checked ? "" : "none")};
   }
-`;
+`;

+ 24 - 4
dashboard/src/components/porter/SelectableList.tsx

@@ -3,6 +3,8 @@ import styled, { css } from "styled-components";
 
 import Icon from "components/porter/Icon";
 
+import Checkbox from "./Checkbox";
+
 type SelectableRowProps = {
   selectable: React.ReactNode;
   onSelect?: () => void;
@@ -46,17 +48,35 @@ type ListProps = {
   }>;
   scroll?: boolean;
   selectedIcon?: string;
+  checkBox?: boolean;
+  gap?: string;
 };
 
 const SelectableList: React.FC<ListProps> = ({
   listItems,
   scroll = true,
   selectedIcon,
+  checkBox = false,
+  gap = "15px",
 }) => {
   return (
-    <StyledSelectableList scroll={scroll}>
+    <StyledSelectableList scroll={scroll} gap={gap}>
       {listItems.map((li) => {
-        return (
+        return checkBox ? (
+          <Checkbox
+            key={li.key}
+            checked={li.isSelected ? li.isSelected : false}
+            toggleChecked={() => {
+              if (li.isSelected) {
+                li.onDeselect?.();
+              } else {
+                li.onSelect?.();
+              }
+            }}
+          >
+            {li.selectable}
+          </Checkbox>
+        ) : (
           <SelectableRow
             key={li.key}
             selectable={li.selectable}
@@ -73,9 +93,9 @@ const SelectableList: React.FC<ListProps> = ({
 
 export default SelectableList;
 
-const StyledSelectableList = styled.div<{ scroll?: boolean }>`
+const StyledSelectableList = styled.div<{ scroll?: boolean; gap: string }>`
   display: flex;
-  row-gap: 15px;
+  row-gap: ${(props) => props.gap};
   flex-direction: column;
   overflow-y: auto;
   ${(props) =>

+ 22 - 16
dashboard/src/lib/notifications/types.ts

@@ -4,20 +4,17 @@ export const notificationConfigFormValidator = z.object({
   mention: z.string().regex(/^[a-z0-9-]*$/, {
     message: "Lowercase letters, numbers, and “-” only.",
   }),
-  statuses: z
-    .object({
-      status: z.string(),
-    })
-    .array()
-    .nullish()
-    .default([]),
-  types: z
-    .object({
-      type: z.string(),
-    })
-    .array()
-    .nullish()
-    .default([]),
+  statuses: z.object({
+    successful: z.boolean(),
+    failed: z.boolean(),
+    progressing: z.boolean(),
+  }),
+  types: z.object({
+    deploy: z.boolean(),
+    predeploy: z.boolean(),
+    build: z.boolean(),
+    alert: z.boolean(),
+  }),
 });
 export type NotificationConfigFormData = z.infer<
   typeof notificationConfigFormValidator
@@ -25,6 +22,15 @@ export type NotificationConfigFormData = z.infer<
 
 export const emptyNotificationConfig: NotificationConfigFormData = {
   mention: "",
-  statuses: [],
-  types: [],
+  statuses: {
+    successful: true,
+    failed: true,
+    progressing: true,
+  },
+  types: {
+    deploy: true,
+    predeploy: true,
+    build: true,
+    alert: true,
+  },
 };

+ 191 - 116
dashboard/src/main/home/integrations/SlackIntegrationList.tsx

@@ -2,10 +2,9 @@ import React, { useContext, useMemo, useRef, useState } from "react";
 import { zodResolver } from "@hookform/resolvers/zod";
 import { useQuery, useQueryClient } from "@tanstack/react-query";
 import axios from "axios";
-import { useFieldArray, useForm } from "react-hook-form";
+import { useForm } from "react-hook-form";
 import styled from "styled-components";
 import { match } from "ts-pattern";
-import type { IterableElement } from "type-fest";
 import { z } from "zod";
 
 import ConfirmOverlay from "components/ConfirmOverlay";
@@ -28,6 +27,7 @@ import {
 
 import api from "shared/api";
 import { Context } from "shared/Context";
+import alert_square from "assets/alert_square.svg";
 import build from "assets/build.png";
 import deploy from "assets/deploy.png";
 import hash from "assets/hash-02.svg";
@@ -38,18 +38,6 @@ type SlackIntegrationListProps = {
   slackData: any[];
 };
 
-const statusOptions = [
-  { value: "successful", emoji: "✅", label: "Successful" },
-  { value: "failed", emoji: "⚠️", label: "Failed" },
-  { value: "progressing", emoji: "🚀", label: "Progressing" },
-];
-
-const typeOptions = [
-  { value: "deploy", icon: deploy, label: "Deploy" },
-  { value: "pre-deploy", icon: pre_deploy, label: "Pre-deploy" },
-  { value: "build", icon: build, label: "Build" },
-];
-
 const SlackIntegrationList: React.FC<SlackIntegrationListProps> = (props) => {
   const [isDelete, setIsDelete] = useState(false);
   const [deleteIndex, setDeleteIndex] = useState(-1); // guaranteed to be set when used
@@ -199,51 +187,12 @@ const NotificationConfigContainer: React.FC<
   });
 
   const {
-    control,
     formState: { isSubmitting: isValidating, errors },
     register,
+    watch,
+    setValue,
   } = notificationForm;
 
-  const {
-    append: statusAppend,
-    remove: statusRemove,
-    fields: statusFields,
-  } = useFieldArray({
-    control,
-    name: "statuses",
-  });
-
-  const onAddStatuses = (
-    inp: IterableElement<NotificationConfigFormData["statuses"]>
-  ): void => {
-    const previouslyAdded = statusFields.findIndex(
-      (s) => s.status === inp.status
-    );
-
-    if (previouslyAdded === -1) {
-      statusAppend(inp);
-    }
-  };
-
-  const {
-    append: typeAppend,
-    remove: typeRemove,
-    fields: typeFields,
-  } = useFieldArray({
-    control,
-    name: "types",
-  });
-
-  const onAddTypes = (
-    inp: IterableElement<NotificationConfigFormData["types"]>
-  ): void => {
-    const previouslyAdded = typeFields.findIndex((s) => s.type === inp.type);
-
-    if (previouslyAdded === -1) {
-      typeAppend(inp);
-    }
-  };
-
   const submitBtnStatus = useMemo(() => {
     if (isValidating || isUpdating) {
       return "loading";
@@ -290,71 +239,197 @@ const NotificationConfigContainer: React.FC<
     }
   });
 
+  const typesEnabled = watch("types");
+  const statusesEnabled = watch("statuses");
+
+  const statusOptions = [
+    {
+      value: "successful",
+      emoji: "✅",
+      label: "Successful",
+      field: statusesEnabled.successful,
+      setValue: (value: boolean) => {
+        setValue("statuses.successful", value);
+      },
+    },
+    {
+      value: "failed",
+      emoji: "⚠️",
+      label: "Failed",
+      field: statusesEnabled.failed,
+      setValue: (value: boolean) => {
+        setValue("statuses.failed", value);
+      },
+    },
+    {
+      value: "progressing",
+      emoji: "🚀",
+      label: "Progressing",
+      field: statusesEnabled.progressing,
+      setValue: (value: boolean) => {
+        setValue("statuses.progressing", value);
+      },
+    },
+  ];
+
+  const deploymentOptions = [
+    {
+      value: "deploy",
+      icon: deploy,
+      label: "Deploy",
+      field: typesEnabled.deploy,
+      setValue: (value: boolean) => {
+        setValue("types.deploy", value);
+      },
+    },
+    {
+      value: "pre-deploy",
+      icon: pre_deploy,
+      label: "Pre-deploy",
+      field: typesEnabled.predeploy,
+      setValue: (value: boolean) => {
+        setValue("types.predeploy", value);
+      },
+    },
+    {
+      value: "build",
+      icon: build,
+      label: "Build",
+      field: typesEnabled.build,
+      setValue: (value: boolean) => {
+        setValue("types.build", value);
+      },
+    },
+  ];
+
+  const monitoringOptions = [
+    {
+      value: "alert",
+      icon: alert_square,
+      label: "App Alerts",
+      field: typesEnabled.alert,
+      setValue: (value: boolean) => {
+        setValue("types.alert", value);
+      },
+    },
+  ];
+
   return (
     <>
-      <Text>Filter notification types:</Text>
-      <Spacer y={0.5} />
-      <SelectableList
-        scroll={false}
-        listItems={typeOptions.map((option) => {
-          const selectedOptionsIdx = typeFields.findIndex(
-            (s) => s.type === option.value
-          );
-          return {
-            selectable: (
-              <Container row>
-                <Spacer inline width="1px" />
-                <Icon src={option.icon} width={"8px"} />
-                <Spacer inline width="10px" />
-                <Text size={12}>{option.label}</Text>
-                <Spacer inline x={1} />
-              </Container>
-            ),
-            key: option.value,
-            onSelect: () => {
-              onAddTypes({ type: option.value });
-            },
-            onDeselect: () => {
-              typeRemove(selectedOptionsIdx);
-            },
-            isSelected: selectedOptionsIdx !== -1,
-          };
-        })}
-      />
+      <Text size={16}>App Deployments</Text>
+      <Spacer y={0.2} />
+      <Text size={13} color={"helper"}>
+        Choose which stages you would like to get notified on:
+      </Text>
+      <Spacer y={0.3} />
+      <Container row>
+        <Spacer inline x={0.5} />
+        <SelectableList
+          scroll={false}
+          listItems={deploymentOptions.map((option) => {
+            return {
+              selectable: (
+                <Container row>
+                  <Spacer inline width="1px" />
+                  <img src={option.icon} width={"18px"} />
+                  <Spacer inline width="10px" />
+                  <Text size={12}>{option.label}</Text>
+                  <Spacer inline x={1} />
+                </Container>
+              ),
+              key: option.value,
+              onSelect: () => {
+                option.setValue(true);
+              },
+              onDeselect: () => {
+                option.setValue(false);
+              },
+              isSelected: option.field,
+            };
+          })}
+          checkBox={true}
+          gap={"8px"}
+        />
+      </Container>
+      <Spacer y={0.4} />
+      <Text size={13} color={"helper"}>
+        Choose which statuses you would like to get notified on:
+      </Text>
+      <Spacer y={0.3} />
+      <Container row>
+        <Spacer inline x={0.5} />
+        <SelectableList
+          scroll={false}
+          listItems={statusOptions.map((option) => {
+            return {
+              selectable: (
+                <Container row>
+                  <Spacer inline width="1px" />
+                  <Text size={12}>
+                    <Text size={10}>{option.emoji}</Text>
+                    <Spacer inline x={0.7} />
+                    {option.label}
+                  </Text>
+                  <Spacer inline x={1} />
+                </Container>
+              ),
+              key: option.value,
+              onSelect: () => {
+                option.setValue(true);
+              },
+              onDeselect: () => {
+                option.setValue(false);
+              },
+              isSelected: option.field,
+            };
+          })}
+          checkBox={true}
+          gap={"8px"}
+        />
+      </Container>
       <Spacer y={0.75} />
-      <Text>Filter notification statuses:</Text>
-      <Spacer y={0.5} />
-      <SelectableList
-        scroll={false}
-        listItems={statusOptions.map((option) => {
-          const selectedOptionsIdx = statusFields.findIndex(
-            (s) => s.status === option.value
-          );
-          return {
-            selectable: (
-              <Container row>
-                <Spacer inline width="1px" />
-                <Text size={12}>
-                  {option.emoji}
-                  <Spacer inline x={0.7} />
-                  {option.label}
-                </Text>
-                <Spacer inline x={1} />
-              </Container>
-            ),
-            key: option.value,
-            onSelect: () => {
-              onAddStatuses({ status: option.value });
-            },
-            onDeselect: () => {
-              statusRemove(selectedOptionsIdx);
-            },
-            isSelected: selectedOptionsIdx !== -1,
-          };
-        })}
-      />
+      <Text size={16}>Ongoing Monitoring</Text>
+      <Spacer y={0.2} />
+      <Text size={13} color={"helper"}>
+        Enable alerts for your apps:
+      </Text>
+      <Spacer y={0.2} />
+      <Container row>
+        <Spacer inline x={0.5} />
+        <SelectableList
+          scroll={false}
+          listItems={monitoringOptions.map((option) => {
+            return {
+              selectable: (
+                <Container row>
+                  <Spacer inline width="1px" />
+                  <img src={option.icon} width={"18px"} />
+                  <Spacer inline width="10px" />
+                  <Text size={12}>{option.label}</Text>
+                  <Spacer inline x={1} />
+                </Container>
+              ),
+              key: option.value,
+              onSelect: () => {
+                option.setValue(true);
+              },
+              onDeselect: () => {
+                option.setValue(false);
+              },
+              isSelected: option.field,
+            };
+          })}
+          checkBox={true}
+          gap={"8px"}
+        />
+        <Spacer inline x={0.5} />
+      </Container>
       <Spacer y={0.75} />
-      <Text>@ Mention (only on failure):</Text>
+      <Text size={16}>Additional Configuration</Text>
+      <Spacer y={0.2} />
+      <Text color={"helper"} size={13}>
+        Include an @ mention on failures and alerts:
+      </Text>
       <Spacer y={0.5} />
       <ControlledInput
         placeholder="ex: oncall"

+ 11 - 1
dashboard/src/shared/api.tsx

@@ -698,7 +698,17 @@ const updateNotificationConfig = baseApi<
     slack_integration_id: number;
     config: {
       mention: string;
-      statuses: Array<{ status: string }>;
+      statuses: {
+        successful: boolean;
+        failed: boolean;
+        progressing: boolean;
+      };
+      types: {
+        deploy: boolean;
+        predeploy: boolean;
+        build: boolean;
+        alert: boolean;
+      };
     };
   },
   {

+ 1 - 1
go.mod

@@ -83,7 +83,7 @@ require (
 	github.com/matryer/is v1.4.0
 	github.com/nats-io/nats.go v1.24.0
 	github.com/open-policy-agent/opa v0.44.0
-	github.com/porter-dev/api-contracts v0.2.106
+	github.com/porter-dev/api-contracts v0.2.108
 	github.com/riandyrn/otelchi v0.5.1
 	github.com/santhosh-tekuri/jsonschema/v5 v5.0.1
 	github.com/stefanmcshane/helm v0.0.0-20221213002717-88a4a2c6e77d

+ 2 - 0
go.sum

@@ -1525,6 +1525,8 @@ github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZN
 github.com/polyfloyd/go-errorlint v0.0.0-20210722154253-910bb7978349/go.mod h1:wi9BfjxjF/bwiZ701TzmfKu6UKC357IOAtNr0Td0Lvw=
 github.com/porter-dev/api-contracts v0.2.106 h1:VDDPGZod38rXGmmOC1rVN7Dw9Qzy38EvNrhv8DFtcKw=
 github.com/porter-dev/api-contracts v0.2.106/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
+github.com/porter-dev/api-contracts v0.2.108 h1:HLJUiabAOJdnLtHDGwqFnJ9LQIpSYCufZR1100vaLbU=
+github.com/porter-dev/api-contracts v0.2.108/go.mod h1:fX6JmP5QuzxDLvqP3evFOTXjI4dHxsG0+VKNTjImZU8=
 github.com/porter-dev/switchboard v0.0.3 h1:dBuYkiVLa5Ce7059d6qTe9a1C2XEORFEanhbtV92R+M=
 github.com/porter-dev/switchboard v0.0.3/go.mod h1:xSPzqSFMQ6OSbp42fhCi4AbGbQbsm6nRvOkrblFeXU4=
 github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=