diff --git a/static/app/components/workflowEngine/form/control/priorityControl.tsx b/static/app/components/workflowEngine/form/control/priorityControl.tsx index a50f600c2a5aad..4004f0af6e4106 100644 --- a/static/app/components/workflowEngine/form/control/priorityControl.tsx +++ b/static/app/components/workflowEngine/form/control/priorityControl.tsx @@ -76,6 +76,7 @@ export default function PriorityControl({ flexibleControlStateSize size="sm" suffix="s" + placeholder="0" // empty string required to keep this as a controlled input value={thresholds[PriorityLevel.MEDIUM] ?? ''} onChange={threshold => setMediumThreshold(Number(threshold))} @@ -96,6 +97,7 @@ export default function PriorityControl({ flexibleControlStateSize size="sm" suffix="s" + placeholder="0" // empty string required to keep this as a controlled input value={thresholds[PriorityLevel.HIGH] ?? ''} onChange={threshold => setHighThreshold(Number(threshold))} diff --git a/static/app/components/workflowEngine/gridCell/actionCell.tsx b/static/app/components/workflowEngine/gridCell/actionCell.tsx index 5ef0e281397436..bfcae7178bc4dd 100644 --- a/static/app/components/workflowEngine/gridCell/actionCell.tsx +++ b/static/app/components/workflowEngine/gridCell/actionCell.tsx @@ -31,12 +31,10 @@ export function ActionCell({actions, disabled}: ActionCellProps) { ); } - const actionsList = actions .map(action => ActionMetadata[action]?.name) .filter(x => x) .join(', '); - return ( diff --git a/static/app/components/workflowEngine/gridCell/connectionCell.tsx b/static/app/components/workflowEngine/gridCell/connectionCell.tsx index c7f06722d7c3e7..4edf617482674e 100644 --- a/static/app/components/workflowEngine/gridCell/connectionCell.tsx +++ b/static/app/components/workflowEngine/gridCell/connectionCell.tsx @@ -32,7 +32,7 @@ const links: Record< }; export function ConnectionCell({ - ids: items, + ids: items = [], type, disabled = false, className, diff --git a/static/app/components/workflowEngine/gridCell/index.stories.tsx b/static/app/components/workflowEngine/gridCell/index.stories.tsx index a55f46da8bb7b1..4b84ceae394e7f 100644 --- a/static/app/components/workflowEngine/gridCell/index.stories.tsx +++ b/static/app/components/workflowEngine/gridCell/index.stories.tsx @@ -50,7 +50,7 @@ export default storyBook('Grid Cell Components', story => { }, openIssues: 3, creator: '1', - type: 'trace', + type: 'uptime', }, { title: { @@ -95,7 +95,7 @@ export default storyBook('Grid Cell Components', story => { }, actions: [ActionType.SLACK, ActionType.DISCORD, ActionType.EMAIL], creator: 'sentry', - type: 'errors', + type: 'uptime', timeAgo: null, linkedItems: { ids: [], diff --git a/static/app/components/workflowEngine/gridCell/timeAgoCell.tsx b/static/app/components/workflowEngine/gridCell/timeAgoCell.tsx index 89b28ad23bead6..b936f25fec1831 100644 --- a/static/app/components/workflowEngine/gridCell/timeAgoCell.tsx +++ b/static/app/components/workflowEngine/gridCell/timeAgoCell.tsx @@ -2,7 +2,7 @@ import TimeSince from 'sentry/components/timeSince'; import {EmptyCell} from 'sentry/components/workflowEngine/gridCell/emptyCell'; type TimeAgoCellProps = { - date?: Date; + date?: string | Date; }; export function TimeAgoCell({date}: TimeAgoCellProps) { diff --git a/static/app/components/workflowEngine/layout/actions.tsx b/static/app/components/workflowEngine/layout/actions.tsx index 822de1f4e48d7e..16b422b3fd32d0 100644 --- a/static/app/components/workflowEngine/layout/actions.tsx +++ b/static/app/components/workflowEngine/layout/actions.tsx @@ -1,7 +1,8 @@ import {createContext, useContext} from 'react'; -import {ButtonBar} from 'sentry/components/core/button/buttonBar'; +import {Flex} from 'sentry/components/container/flex'; import {HeaderActions} from 'sentry/components/layouts/thirds'; +import {space} from 'sentry/styles/space'; const ActionContext = createContext(undefined); @@ -26,9 +27,7 @@ export function ActionsFromContext() { } return ( - - {actions} - + {actions} ); } diff --git a/static/app/components/workflowEngine/layout/detail.tsx b/static/app/components/workflowEngine/layout/detail.tsx index 89dbac347111f1..f6e2f45d4b8cb4 100644 --- a/static/app/components/workflowEngine/layout/detail.tsx +++ b/static/app/components/workflowEngine/layout/detail.tsx @@ -25,7 +25,7 @@ function DetailLayout({children, project}: WorkflowEngineDetailLayoutProps) { const title = useDocumentTitle(); return ( - + {title} diff --git a/static/app/components/workflowEngine/layout/edit.tsx b/static/app/components/workflowEngine/layout/edit.tsx index 4916ea35e15094..f05e7f506ee383 100644 --- a/static/app/components/workflowEngine/layout/edit.tsx +++ b/static/app/components/workflowEngine/layout/edit.tsx @@ -24,7 +24,7 @@ function EditLayout({children, onTitleChange}: WorkflowEngineEditLayoutProps) { const title = useDocumentTitle(); return ( - + @@ -37,16 +37,12 @@ function EditLayout({children, onTitleChange}: WorkflowEngineEditLayoutProps) { - + {children} ); } -const StyledHeader = styled(Layout.Header)` - background: ${p => p.theme.background}; -`; - const Body = styled('div')` display: flex; flex-direction: column; diff --git a/static/app/components/workflowEngine/layout/list.tsx b/static/app/components/workflowEngine/layout/list.tsx index 2fd1c8d2336122..3f76bfb2a9e2ea 100644 --- a/static/app/components/workflowEngine/layout/list.tsx +++ b/static/app/components/workflowEngine/layout/list.tsx @@ -17,7 +17,7 @@ function WorkflowEngineListLayout({children}: WorkflowEngineListLayoutProps) { const title = useDocumentTitle(); return ( - + {title} diff --git a/static/app/components/workflowEngine/ui/container.tsx b/static/app/components/workflowEngine/ui/container.tsx index e148b7baab8705..26d1a329d36f1b 100644 --- a/static/app/components/workflowEngine/ui/container.tsx +++ b/static/app/components/workflowEngine/ui/container.tsx @@ -7,12 +7,13 @@ export const Container = styled('div')` flex-direction: column; gap: ${space(2)}; justify-content: flex-start; - background-color: ${p => p.theme.backgroundSecondary}; + background-color: ${p => p.theme.background}; border: 1px solid ${p => p.theme.translucentBorder}; border-radius: ${p => p.theme.borderRadius}; padding: ${space(1.5)}; @media (max-width: ${p => p.theme.breakpoints.large}) { - width: fit-content; + min-width: fit-content; + flex: 1; } `; diff --git a/static/app/components/workflowEngine/ui/footer.tsx b/static/app/components/workflowEngine/ui/footer.tsx index 7e55d27edfcf80..f2bc96e3a31c26 100644 --- a/static/app/components/workflowEngine/ui/footer.tsx +++ b/static/app/components/workflowEngine/ui/footer.tsx @@ -5,6 +5,7 @@ import {space} from 'sentry/styles/space'; export const StickyFooter = styled('div')` position: sticky; margin-top: auto; + margin-bottom: -56px; bottom: 0; right: 0; width: 100%; diff --git a/static/app/components/workflowEngine/ui/section.tsx b/static/app/components/workflowEngine/ui/section.tsx index f8f3c851b2ea2a..92e67a5df4e9fe 100644 --- a/static/app/components/workflowEngine/ui/section.tsx +++ b/static/app/components/workflowEngine/ui/section.tsx @@ -6,18 +6,28 @@ import {space} from 'sentry/styles/space'; type SectionProps = { children: React.ReactNode; title: string; + description?: string; }; -export default function Section({children, title}: SectionProps) { +export default function Section({children, title, description}: SectionProps) { return ( {title} + {description && {description}} {children} ); } -const SectionHeading = styled('h4')` +export const SectionHeading = styled('h4')` + font-size: ${p => p.theme.fontSizeLarge}; + font-weight: ${p => p.theme.fontWeightBold}; + margin: 0; +`; + +export const SectionDescription = styled('p')` font-size: ${p => p.theme.fontSizeMedium}; + font-weight: ${p => p.theme.fontWeightNormal}; + color: ${p => p.theme.subText}; margin: 0; `; diff --git a/static/app/plugins/components/pluginIcon.tsx b/static/app/plugins/components/pluginIcon.tsx index b2344e9167c85d..fdd8bd77d6d40d 100644 --- a/static/app/plugins/components/pluginIcon.tsx +++ b/static/app/plugins/components/pluginIcon.tsx @@ -69,7 +69,7 @@ const PLUGIN_ICONS = { } satisfies Record; export interface PluginIconProps extends React.RefAttributes { - pluginId: string | keyof typeof PLUGIN_ICONS; + pluginId: keyof typeof PLUGIN_ICONS | (string & {}); /** * @default 20 */ diff --git a/static/app/types/workflowEngine/automations.tsx b/static/app/types/workflowEngine/automations.tsx index 728801f7bb6cd1..f5f6fc67063258 100644 --- a/static/app/types/workflowEngine/automations.tsx +++ b/static/app/types/workflowEngine/automations.tsx @@ -1,6 +1,6 @@ import type {DataConditionGroup} from 'sentry/types/workflowEngine/dataConditions'; -interface NewAutomation { +export interface NewAutomation { actionFilters: DataConditionGroup[]; detectorIds: string[]; name: string; @@ -10,5 +10,5 @@ interface NewAutomation { export interface Automation extends Readonly { readonly id: string; - readonly lastTriggered: Date; + readonly lastTriggered: string; } diff --git a/static/app/types/workflowEngine/dataConditions.tsx b/static/app/types/workflowEngine/dataConditions.tsx index bfd62ed54d9381..f34aee4a376c10 100644 --- a/static/app/types/workflowEngine/dataConditions.tsx +++ b/static/app/types/workflowEngine/dataConditions.tsx @@ -73,6 +73,10 @@ export interface NewDataCondition { condition_result?: any; } +export interface DataCondition extends Readonly { + readonly id: string; +} + export interface DataConditionGroup { conditions: NewDataCondition[]; id: string; diff --git a/static/app/types/workflowEngine/detectors.tsx b/static/app/types/workflowEngine/detectors.tsx index 15de579d255d6e..334310a240efd8 100644 --- a/static/app/types/workflowEngine/detectors.tsx +++ b/static/app/types/workflowEngine/detectors.tsx @@ -4,11 +4,12 @@ import type { } from 'sentry/types/workflowEngine/dataConditions'; export type DetectorType = - | 'metric' + | 'crons' | 'errors' + | 'metric' | 'performance' - | 'trace' | 'replay' + | 'trace' | 'uptime'; interface NewDetector { @@ -24,8 +25,8 @@ interface NewDetector { export interface Detector extends Readonly { readonly createdBy: string; - readonly dateCreated: Date; - readonly dateUpdated: Date; + readonly dateCreated: string; + readonly dateUpdated: string; readonly id: string; - readonly lastTriggered: Date; + readonly lastTriggered: string; } diff --git a/static/app/utils/useParams.tsx b/static/app/utils/useParams.tsx index ac0add99d69a47..a6bc199570d237 100644 --- a/static/app/utils/useParams.tsx +++ b/static/app/utils/useParams.tsx @@ -24,6 +24,7 @@ type ParamKeys = | 'groupId' | 'id' | 'installationId' + | 'detectorId' | 'integrationSlug' | 'issueId' | 'memberId' diff --git a/static/app/views/automations/components/automationListRow.tsx b/static/app/views/automations/components/automationListRow.tsx index dbbb2cb370c94b..07c4a90bfd01d9 100644 --- a/static/app/views/automations/components/automationListRow.tsx +++ b/static/app/views/automations/components/automationListRow.tsx @@ -4,14 +4,19 @@ import styled from '@emotion/styled'; import {Flex} from 'sentry/components/container/flex'; import {Checkbox} from 'sentry/components/core/checkbox'; import InteractionStateLayer from 'sentry/components/interactionStateLayer'; +import {ProjectList} from 'sentry/components/projectList'; import {ActionCell} from 'sentry/components/workflowEngine/gridCell/actionCell'; import AutomationTitleCell from 'sentry/components/workflowEngine/gridCell/automationTitleCell'; import {ConnectionCell} from 'sentry/components/workflowEngine/gridCell/connectionCell'; import {TimeAgoCell} from 'sentry/components/workflowEngine/gridCell/timeAgoCell'; +import ProjectsStore from 'sentry/stores/projectsStore'; import {space} from 'sentry/styles/space'; import type {Automation} from 'sentry/types/workflowEngine/automations'; import useOrganization from 'sentry/utils/useOrganization'; -import {useAutomationActions} from 'sentry/views/automations/hooks/utils'; +import { + useAutomationActions, + useAutomationProjectIds, +} from 'sentry/views/automations/hooks/utils'; import {makeAutomationDetailsPathname} from 'sentry/views/automations/pathnames'; type AutomationListRowProps = { @@ -28,6 +33,10 @@ export function AutomationListRow({ const organization = useOrganization(); const actions = useAutomationActions(automation); const {id, name, disabled, lastTriggered, detectorIds = []} = automation; + const projectIds = useAutomationProjectIds(automation); + const projectSlugs = projectIds.map( + projectId => ProjectsStore.getById(projectId)?.slug + ) as string[]; return ( @@ -51,6 +60,9 @@ export function AutomationListRow({ + + + diff --git a/static/app/views/automations/components/automationListTable.tsx b/static/app/views/automations/components/automationListTable.tsx index e2f83265b06528..24445a88ceb78a 100644 --- a/static/app/views/automations/components/automationListTable.tsx +++ b/static/app/views/automations/components/automationListTable.tsx @@ -43,6 +43,10 @@ function AutomationListTable() { {t('Actions')} + + + {t('Projects')} + {t('Monitors')} @@ -50,7 +54,6 @@ function AutomationListTable() { {isLoading ? : null} - {automations.map(automation => ( + {t('Last Triggered')} @@ -78,7 +78,7 @@ function Details() { - + ); } diff --git a/static/app/views/automations/hooks/index.tsx b/static/app/views/automations/hooks/index.tsx index 5db656790c6d56..a27818e8ab17ca 100644 --- a/static/app/views/automations/hooks/index.tsx +++ b/static/app/views/automations/hooks/index.tsx @@ -14,3 +14,8 @@ export function useAutomationsQuery(_options: UseAutomationsQueryOptions = {}) { retry: false, }); } + +export const makeAutomationQueryKey = ( + orgSlug: string, + automationId = '' +): [url: string] => [`/organizations/${orgSlug}/workflows/${automationId}/`]; diff --git a/static/app/views/automations/hooks/utils.tsx b/static/app/views/automations/hooks/utils.tsx index 99a7cb871c6706..ed4818b5c4acfa 100644 --- a/static/app/views/automations/hooks/utils.tsx +++ b/static/app/views/automations/hooks/utils.tsx @@ -2,6 +2,7 @@ import {useState} from 'react'; import type {ActionType} from 'sentry/types/workflowEngine/actions'; import type {Automation} from 'sentry/types/workflowEngine/automations'; +import {useDetectorQueriesByIds} from 'sentry/views/detectors/hooks'; export function useAutomationActions(automation: Automation): ActionType[] { return [ @@ -47,3 +48,9 @@ export function useConnectedIds({storageKey, initialIds = []}: UseConnectedIdsPr } export const NEW_AUTOMATION_CONNECTED_IDS_KEY = 'new-automation-connected-ids'; +export function useAutomationProjectIds(automation: Automation): string[] { + const queries = useDetectorQueriesByIds(automation.detectorIds); + return [ + ...new Set(queries.map(query => query.data?.projectId).filter(x => x)), + ] as string[]; +} diff --git a/static/app/views/automations/list.tsx b/static/app/views/automations/list.tsx index ef1af8bc5d1e1c..9bd7dae88e8586 100644 --- a/static/app/views/automations/list.tsx +++ b/static/app/views/automations/list.tsx @@ -36,7 +36,7 @@ export default function AutomationsList() { function TableHeader() { return ( - +
diff --git a/static/app/views/detectors/components/detectorListRow.tsx b/static/app/views/detectors/components/detectorListRow.tsx index 0c6c0d8a2f9a24..8965b0ccade67e 100644 --- a/static/app/views/detectors/components/detectorListRow.tsx +++ b/static/app/views/detectors/components/detectorListRow.tsx @@ -23,7 +23,7 @@ interface DetectorListRowProps { } export function DetectorListRow({ - detector: {workflowIds, id, name, disabled, projectId}, + detector: {workflowIds, createdBy, id, projectId, name, disabled, type}, handleSelect, selected, }: DetectorListRowProps) { @@ -51,7 +51,7 @@ export function DetectorListRow({
- + - - + + diff --git a/static/app/views/detectors/components/detectorListTable.tsx b/static/app/views/detectors/components/detectorListTable.tsx index 36a06e470ea4cf..a7698c37ee6fb3 100644 --- a/static/app/views/detectors/components/detectorListTable.tsx +++ b/static/app/views/detectors/components/detectorListTable.tsx @@ -40,7 +40,7 @@ function DetectorListTable({detectors}: DetectorListTableProps) { {t('Type')}
- + {t('Last Issue')} @@ -86,9 +86,10 @@ const StyledPanelHeader = styled(PanelHeader)` min-height: 40px; align-items: center; display: grid; + text-transform: none; .type, - .owner, + .creator, .last-issue, .connected-automations { display: none; @@ -113,7 +114,7 @@ const StyledPanelHeader = styled(PanelHeader)` @media (min-width: ${p => p.theme.breakpoints.medium}) { grid-template-columns: 3fr 0.8fr 1.5fr 0.8fr; - .owner { + .creator { display: flex; } } diff --git a/static/app/views/detectors/components/detectorTypeForm.tsx b/static/app/views/detectors/components/detectorTypeForm.tsx index fc9ab07b6428d0..6ace886117d8c4 100644 --- a/static/app/views/detectors/components/detectorTypeForm.tsx +++ b/static/app/views/detectors/components/detectorTypeForm.tsx @@ -9,7 +9,6 @@ import SentryProjectSelectorField from 'sentry/components/forms/fields/sentryPro import Form from 'sentry/components/forms/form'; import FormModel from 'sentry/components/forms/model'; import {useDocumentTitle} from 'sentry/components/sentryDocumentTitle'; -import {DebugForm} from 'sentry/components/workflowEngine/form/debug'; import {useFormField} from 'sentry/components/workflowEngine/form/hooks'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; @@ -74,7 +73,6 @@ export function DetectorTypeForm() {

-
diff --git a/static/app/views/detectors/components/forms/metric.tsx b/static/app/views/detectors/components/forms/metric.tsx new file mode 100644 index 00000000000000..5e64a18bf71dbe --- /dev/null +++ b/static/app/views/detectors/components/forms/metric.tsx @@ -0,0 +1,503 @@ +import {useMemo} from 'react'; +import styled from '@emotion/styled'; + +import {Flex} from 'sentry/components/container/flex'; +import {Button} from 'sentry/components/core/button'; +import NumberField from 'sentry/components/forms/fields/numberField'; +import SegmentedRadioField from 'sentry/components/forms/fields/segmentedRadioField'; +import SelectField from 'sentry/components/forms/fields/selectField'; +import SentryMemberTeamSelectorField from 'sentry/components/forms/fields/sentryMemberTeamSelectorField'; +import Form from 'sentry/components/forms/form'; +import type FormModel from 'sentry/components/forms/model'; +import Spinner from 'sentry/components/forms/spinner'; +import {SearchQueryBuilder} from 'sentry/components/searchQueryBuilder'; +import type {FilterKeySection} from 'sentry/components/searchQueryBuilder/types'; +import PriorityControl from 'sentry/components/workflowEngine/form/control/priorityControl'; +import {useFormField} from 'sentry/components/workflowEngine/form/hooks'; +import {Container} from 'sentry/components/workflowEngine/ui/container'; +import Section from 'sentry/components/workflowEngine/ui/section'; +import {IconAdd} from 'sentry/icons'; +import {t} from 'sentry/locale'; +import {space} from 'sentry/styles/space'; +import type {TagCollection} from 'sentry/types/group'; +import { + ALLOWED_EXPLORE_VISUALIZE_AGGREGATES, + FieldKey, + FieldKind, + MobileVital, + WebVital, +} from 'sentry/utils/fields'; +import { + AlertRuleSensitivity, + AlertRuleThresholdType, +} from 'sentry/views/alerts/rules/metric/types'; + +type MetricDetectorKind = 'threshold' | 'change' | 'dynamic'; + +export function MetricDetectorForm({model}: {model: FormModel}) { + return ( +
+ + + + + + + + + + +
+ ); +} + +function ResolveSection() { + const kind = useFormField('kind')!; + + return ( + +
+ {kind !== 'dynamic' && ( + + )} +
+
+ ); +} + +function AutomateSection() { + return ( + +
+ +
+
+ ); +} + +function AssignSection() { + return ( + +
+ +
+
+ ); +} + +function PrioritizeSection() { + const kind = useFormField('kind')!; + return ( + +
+ {kind !== 'dynamic' && } +
+
+ ); +} + +function DetectSection() { + const kind = useFormField('kind')!; + const aggregateOptions: Array<[string, string]> = useMemo(() => { + return ALLOWED_EXPLORE_VISUALIZE_AGGREGATES.map(aggregate => { + return [aggregate, aggregate]; + }); + }, []); + + return ( + +
+ + + + + + + + + + + + {(!kind || kind === 'threshold') && ( + + + {t('An issue will be created when query value exceeds:')} + + + + )} + {kind === 'change' && ( + + {t('An issue will be created when query value is:')} + + + {t('percent')} + + {t('than the previous')} + + + + )} + {kind === 'dynamic' && ( + + + + + )} + +
+
+ ); +} + +function OwnerField() { + return ( + + ); +} + +const FormStack = styled(Flex)` + max-width: ${p => p.theme.breakpoints.xlarge}; + flex-direction: column; + gap: ${space(4)}; + padding: ${space(4)}; +`; + +const FirstRow = styled('div')` + display: grid; + grid-template-columns: 1fr 2fr; + gap: ${space(1)}; + border-bottom: 1px solid ${p => p.theme.border}; +`; + +function DetectColumn(props: React.ComponentProps) { + return ; +} + +const StyledSelectField = styled(SelectField)` + width: 180px; + padding: 0; + margin: 0; + + > div { + padding-left: 0; + } +`; + +const StyledMemberTeamSelectorField = styled(SentryMemberTeamSelectorField)` + padding-left: 0; +`; + +const VisualizeField = styled(SelectField)` + flex: 2; + padding-left: 0; + padding-right: 0; + margin-left: 0; + border-bottom: none; +`; + +const ChartContainer = styled('div')` + background: ${p => p.theme.background}; + width: 100%; + border-bottom: 1px solid ${p => p.theme.border}; + padding: 24px 32px 16px 32px; +`; + +const AggregateField = styled(SelectField)` + width: 120px; + margin-top: auto; + padding-top: 0; + padding-left: 0; + padding-right: 0; + border-bottom: none; + + > div { + padding-left: 0; + } +`; + +const DirectionField = styled(SelectField)` + width: 16ch; + padding: 0; + margin: 0; + border-bottom: none; + + > div { + padding-left: 0; + } +`; + +const MonitorKindField = styled(SegmentedRadioField)` + padding-left: 0; + padding-block: ${space(1)}; + border-bottom: none; + max-width: 840px; + + > div { + padding: 0; + } +`; +const ThresholdField = styled(NumberField)` + padding: 0; + margin: 0; + border: none; + + > div { + padding: 0; + width: 10ch; + } +`; + +const ChangePercentField = styled(NumberField)` + padding: 0; + margin: 0; + border: none; + + > div { + padding: 0; + max-width: 10ch; + } +`; + +const MutedText = styled('p')` + color: ${p => p.theme.text}; + padding-top: ${space(1)}; + margin-bottom: ${space(1)}; + border-top: 1px solid ${p => p.theme.border}; +`; + +function FilterField() { + return ( + + Filter + + + ); +} + +const getTagValues = (): Promise => { + return new Promise(resolve => { + setTimeout(() => { + resolve(['foo', 'bar', 'baz']); + }, 500); + }); +}; + +// TODO: replace hardcoded tags with data from API +const FILTER_KEYS: TagCollection = { + [FieldKey.ASSIGNED]: { + key: FieldKey.ASSIGNED, + name: 'Assigned To', + kind: FieldKind.FIELD, + predefined: true, + values: [ + { + title: 'Suggested', + type: 'header', + icon: null, + children: [{value: 'me'}, {value: 'unassigned'}], + }, + { + title: 'All', + type: 'header', + icon: null, + children: [{value: 'person1@sentry.io'}, {value: 'person2@sentry.io'}], + }, + ], + }, + [FieldKey.BROWSER_NAME]: { + key: FieldKey.BROWSER_NAME, + name: 'Browser Name', + kind: FieldKind.FIELD, + predefined: true, + values: ['Chrome', 'Firefox', 'Safari', 'Edge', 'Internet Explorer', 'Opera 1,2'], + }, + [FieldKey.IS]: { + key: FieldKey.IS, + name: 'is', + predefined: true, + values: ['resolved', 'unresolved', 'ignored'], + }, + [FieldKey.LAST_SEEN]: { + key: FieldKey.LAST_SEEN, + name: 'lastSeen', + kind: FieldKind.FIELD, + }, + [FieldKey.TIMES_SEEN]: { + key: FieldKey.TIMES_SEEN, + name: 'timesSeen', + kind: FieldKind.FIELD, + }, + [WebVital.LCP]: { + key: WebVital.LCP, + name: 'lcp', + kind: FieldKind.FIELD, + }, + [MobileVital.FRAMES_SLOW_RATE]: { + key: MobileVital.FRAMES_SLOW_RATE, + name: 'framesSlowRate', + kind: FieldKind.FIELD, + }, + custom_tag_name: { + key: 'custom_tag_name', + name: 'Custom_Tag_Name', + }, +}; + +const FILTER_KEY_SECTIONS: FilterKeySection[] = [ + { + value: 'cat_1', + label: 'Category 1', + children: [FieldKey.ASSIGNED, FieldKey.IS], + }, + { + value: 'cat_2', + label: 'Category 2', + children: [WebVital.LCP, MobileVital.FRAMES_SLOW_RATE], + }, + { + value: 'cat_3', + label: 'Category 3', + children: [FieldKey.TIMES_SEEN], + }, +]; diff --git a/static/app/views/detectors/detail.tsx b/static/app/views/detectors/detail.tsx index fb42e08764de26..8aa3fa40a75068 100644 --- a/static/app/views/detectors/detail.tsx +++ b/static/app/views/detectors/detail.tsx @@ -18,9 +18,11 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import getDuration from 'sentry/utils/duration/getDuration'; import useOrganization from 'sentry/utils/useOrganization'; +import {useParams} from 'sentry/utils/useParams'; import {ConnectedAutomationsList} from 'sentry/views/detectors/components/connectedAutomationList'; import DetailsPanel from 'sentry/views/detectors/components/detailsPanel'; import IssuesList from 'sentry/views/detectors/components/issuesList'; +import {useDetectorQuery} from 'sentry/views/detectors/hooks'; import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; type Priority = { @@ -36,9 +38,14 @@ const priorities: Priority[] = [ export default function DetectorDetail() { const organization = useOrganization(); useWorkflowEngineFeatureGate({redirect: true}); + const {detectorId} = useParams(); + if (!detectorId) { + throw new Error(`Unable to find detector.`); + } + const {data: detector} = useDetectorQuery(detectorId); return ( - + diff --git a/static/app/views/detectors/edit.tsx b/static/app/views/detectors/edit.tsx index 30dc7914f16385..e8726c3607d0bb 100644 --- a/static/app/views/detectors/edit.tsx +++ b/static/app/views/detectors/edit.tsx @@ -1,7 +1,8 @@ /* eslint-disable no-alert */ -import {Fragment} from 'react'; +import {Fragment, useState} from 'react'; import {Button} from 'sentry/components/core/button'; +import FormModel from 'sentry/components/forms/model'; import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle'; import {ActionsProvider} from 'sentry/components/workflowEngine/layout/actions'; import {BreadcrumbsProvider} from 'sentry/components/workflowEngine/layout/breadcrumbs'; @@ -9,20 +10,23 @@ import EditLayout from 'sentry/components/workflowEngine/layout/edit'; import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate'; import {t} from 'sentry/locale'; import useOrganization from 'sentry/utils/useOrganization'; +import {MetricDetectorForm} from 'sentry/views/detectors/components/forms/metric'; import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; export default function DetectorEdit() { const organization = useOrganization(); useWorkflowEngineFeatureGate({redirect: true}); + const [title, setTitle] = useState(t('Edit Monitor')); + const [model] = useState(() => new FormModel()); return ( - + }> - -

Edit Monitor

+ +
diff --git a/static/app/views/detectors/hooks/index.ts b/static/app/views/detectors/hooks/index.ts new file mode 100644 index 00000000000000..beed29a5d662b9 --- /dev/null +++ b/static/app/views/detectors/hooks/index.ts @@ -0,0 +1,69 @@ +import {t} from 'sentry/locale'; +import AlertStore from 'sentry/stores/alertStore'; +import type {Detector} from 'sentry/types/workflowEngine/detectors'; +import { + useApiQueries, + useApiQuery, + useMutation, + useQueryClient, +} from 'sentry/utils/queryClient'; +import useApi from 'sentry/utils/useApi'; +import useOrganization from 'sentry/utils/useOrganization'; + +export interface UseDetectorsQueryOptions { + query?: string; + sortBy?: string; +} +export function useDetectorsQuery(_options: UseDetectorsQueryOptions = {}) { + const org = useOrganization(); + return useApiQuery(makeDetectorQueryKey(org.slug), { + staleTime: 0, + retry: false, + }); +} + +export const makeDetectorQueryKey = (orgSlug: string, detectorId = ''): [url: string] => [ + `/organizations/${orgSlug}/detectors/${detectorId ? `${detectorId}/` : ''}`, +]; + +export function useCreateDetector() { + const org = useOrganization(); + const api = useApi({persistInFlight: true}); + const queryClient = useQueryClient(); + const queryKey = makeDetectorQueryKey(org.slug); + + return useMutation({ + mutationFn: data => + api.requestPromise(queryKey[0], { + method: 'POST', + data, + }), + onSuccess: _ => { + queryClient.invalidateQueries({queryKey}); + }, + onError: _ => { + AlertStore.addAlert({type: 'error', message: t('Unable to create monitor')}); + }, + }); +} + +export function useDetectorQuery(detectorId: string) { + const org = useOrganization(); + + return useApiQuery(makeDetectorQueryKey(org.slug, detectorId), { + staleTime: 0, + retry: false, + }); +} + +export function useDetectorQueriesByIds(detectorId: string[]) { + const org = useOrganization(); + + return useApiQueries( + detectorId.map(id => makeDetectorQueryKey(org.slug, id)), + { + staleTime: 0, + retry: false, + } + ); +} diff --git a/static/app/views/detectors/list.tsx b/static/app/views/detectors/list.tsx index f3978c701a87ad..f710968d74f12e 100644 --- a/static/app/views/detectors/list.tsx +++ b/static/app/views/detectors/list.tsx @@ -14,10 +14,12 @@ import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; import useOrganization from 'sentry/utils/useOrganization'; import DetectorListTable from 'sentry/views/detectors/components/detectorListTable'; +import {useDetectorsQuery} from 'sentry/views/detectors/hooks'; import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; export default function DetectorsList() { useWorkflowEngineFeatureGate({redirect: true}); + const {data: detectors} = useDetectorsQuery(); return ( @@ -25,7 +27,7 @@ export default function DetectorsList() { }> - + @@ -51,7 +53,7 @@ function Actions() { } + icon={} > {t('Create Monitor')} diff --git a/static/app/views/detectors/new-settings.tsx b/static/app/views/detectors/new-settings.tsx index 91a270784d7153..9d64d70cbce39e 100644 --- a/static/app/views/detectors/new-settings.tsx +++ b/static/app/views/detectors/new-settings.tsx @@ -1,6 +1,9 @@ +import {useCallback, useMemo} from 'react'; + import {Flex} from 'sentry/components/container/flex'; import {Button} from 'sentry/components/core/button'; import {LinkButton} from 'sentry/components/core/button/linkButton'; +import FormModel from 'sentry/components/forms/model'; import { StickyFooter, StickyFooterLabel, @@ -8,16 +11,34 @@ import { import {useWorkflowEngineFeatureGate} from 'sentry/components/workflowEngine/useWorkflowEngineFeatureGate'; import {t} from 'sentry/locale'; import {space} from 'sentry/styles/space'; +import type {Detector} from 'sentry/types/workflowEngine/detectors'; +import {useNavigate} from 'sentry/utils/useNavigate'; import useOrganization from 'sentry/utils/useOrganization'; +import {MetricDetectorForm} from 'sentry/views/detectors/components/forms/metric'; +import {useCreateDetector} from 'sentry/views/detectors/hooks'; import NewDetectorLayout from 'sentry/views/detectors/layouts/new'; -import {makeMonitorBasePathname} from 'sentry/views/detectors/pathnames'; +import { + makeMonitorBasePathname, + makeMonitorDetailsPathname, +} from 'sentry/views/detectors/pathnames'; export default function DetectorNewSettings() { const organization = useOrganization(); useWorkflowEngineFeatureGate({redirect: true}); + const navigate = useNavigate(); + const model = useMemo(() => new FormModel(), []); + + const {mutateAsync: createDetector} = useCreateDetector(); + + const handleSubmit = useCallback(async () => { + const data = model.getData() as unknown as Detector; + const result = await createDetector(data); + navigate(makeMonitorDetailsPathname(organization.slug, result.id)); + }, [createDetector, model, navigate, organization]); return ( + {t('Step 2 of 2')} @@ -27,7 +48,9 @@ export default function DetectorNewSettings() { > {t('Back')} - + diff --git a/static/app/views/detectors/routes.tsx b/static/app/views/detectors/routes.tsx index d686823673d3f6..5fc6029046bf30 100644 --- a/static/app/views/detectors/routes.tsx +++ b/static/app/views/detectors/routes.tsx @@ -11,7 +11,7 @@ export const detectorRoutes = ( component={make(() => import('sentry/views/detectors/new-settings'))} /> - + import('sentry/views/detectors/detail'))} /> import('sentry/views/detectors/edit'))} /> diff --git a/tests/js/fixtures/automations.ts b/tests/js/fixtures/automations.ts new file mode 100644 index 00000000000000..c06db05ceb361a --- /dev/null +++ b/tests/js/fixtures/automations.ts @@ -0,0 +1,15 @@ +import {DataConditionGroupFixture} from 'sentry-fixture/dataConditions'; + +import type {Automation} from 'sentry/types/workflowEngine/automations'; + +export function AutomationFixture(params: Partial): Automation { + return { + id: '1', + name: 'Automation', + lastTriggered: '2025-01-01T00:00:00.000Z', + actionFilters: [], + detectorIds: [], + triggers: DataConditionGroupFixture({}), + ...params, + }; +} diff --git a/tests/js/fixtures/dataConditions.ts b/tests/js/fixtures/dataConditions.ts new file mode 100644 index 00000000000000..2106c3d791e087 --- /dev/null +++ b/tests/js/fixtures/dataConditions.ts @@ -0,0 +1,29 @@ +import type { + DataCondition, + DataConditionGroup, +} from 'sentry/types/workflowEngine/dataConditions'; +import { + DataConditionGroupLogicType, + DataConditionType, +} from 'sentry/types/workflowEngine/dataConditions'; + +export function DataConditionFixture(params: Partial): DataCondition { + return { + comparison_type: DataConditionType.EQUAL, + comparison: '8', + id: '1', + ...params, + }; +} + +export function DataConditionGroupFixture( + params: Partial +): DataConditionGroup { + return { + conditions: [DataConditionFixture({})], + id: '1', + logicType: DataConditionGroupLogicType.ANY, + actions: [], + ...params, + }; +} diff --git a/tests/js/fixtures/detectors.ts b/tests/js/fixtures/detectors.ts new file mode 100644 index 00000000000000..31a151328807e7 --- /dev/null +++ b/tests/js/fixtures/detectors.ts @@ -0,0 +1,39 @@ +import {DataConditionGroupFixture} from 'sentry-fixture/dataConditions'; +import {UserFixture} from 'sentry-fixture/user'; + +import type {DataSource} from 'sentry/types/workflowEngine/dataConditions'; +import type {Detector} from 'sentry/types/workflowEngine/detectors'; + +export function DetectorFixture(params: Partial): Detector { + return { + id: '1', + name: 'detector', + projectId: '1', + createdBy: UserFixture().id, + dateCreated: '2025-01-01T00:00:00.000Z', + dateUpdated: '2025-01-01T00:00:00.000Z', + lastTriggered: '2025-01-01T00:00:00.000Z', + workflowIds: [], + config: {}, + type: 'metric', + dataCondition: DataConditionGroupFixture({}), + disabled: false, + dataSource: params.dataSource ?? DetectorDataSource({}), + ...params, + }; +} + +export function DetectorDataSource(params: Partial): DataSource { + return { + id: '1', + status: 1, + snubaQuery: { + aggregate: '', + dataset: '', + id: '', + query: '', + timeWindow: 60, + ...params, + }, + }; +}