diff --git a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx index bcb93572c1..bf3fb00ba2 100644 --- a/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureMetrics/FeatureImpactMetrics.tsx @@ -2,7 +2,7 @@ import { PageContent } from 'component/common/PageContent/PageContent.tsx'; import { PageHeader } from '../../../common/PageHeader/PageHeader.tsx'; import { Box, styled, Typography } from '@mui/material'; import Add from '@mui/icons-material/Add'; -import { useImpactMetricsNames } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts'; +import { useImpactMetricsOptions } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts'; import { type FC, useMemo, useState } from 'react'; import { ChartConfigModal } from '../../../impact-metrics/ChartConfigModal/ChartConfigModal.tsx'; import { useImpactMetricsApi } from 'hooks/api/actions/useImpactMetricsApi/useImpactMetricsApi.ts'; @@ -49,10 +49,10 @@ export const FeatureImpactMetrics: FC = () => { const { setToastApiError } = useToast(); const { - metricSeries, + metricOptions, loading: metadataLoading, error: metadataError, - } = useImpactMetricsNames(); + } = useImpactMetricsOptions(); const handleAddChart = () => { setModalState({ type: 'creating' }); @@ -151,7 +151,7 @@ export const FeatureImpactMetrics: FC = () => { onClose={handleCloseModal} onSave={handleSaveChart} initialConfig={editingChart} - metricSeries={metricSeries} + metricSeries={metricOptions} loading={metadataLoading} /> diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index 0f55fc4294..ea9f5614ed 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -120,6 +120,7 @@ export const ReleasePlan = ({ featureName, environment, milestones, + safeguards, } = plan; const projectId = useRequiredPathParam('projectId'); @@ -389,7 +390,6 @@ export const ReleasePlan = ({ planId: id, body: data, }); - setSafeguardFormOpen(false); setToastData({ type: 'success', text: 'Safeguard added successfully', @@ -431,11 +431,20 @@ export const ReleasePlan = ({ {safeguardsEnabled ? ( - {safeguardFormOpen ? ( + {safeguards.length > 0 ? ( setSafeguardFormOpen(false)} /> + ) : safeguardFormOpen ? ( + { + handleSafeguardSubmit(data); + setSafeguardFormOpen(false); + }} + onCancel={() => setSafeguardFormOpen(false)} + /> ) : ( setSafeguardFormOpen(true)} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx index 38a816f97b..e3679d56ac 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx @@ -2,11 +2,11 @@ import { Button } from '@mui/material'; import ShieldIcon from '@mui/icons-material/Shield'; import type { FormEvent } from 'react'; import { useEffect, useMemo, useState } from 'react'; -import { useImpactMetricsNames } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { useImpactMetricsOptions } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; import { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData'; import { RangeSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/RangeSelector/RangeSelector'; import { ModeSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ModeSelector/ModeSelector'; -import { SeriesSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/SeriesSelector/SeriesSelector'; +import { MetricSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/SeriesSelector/MetricSelector.tsx'; import type { CreateSafeguardSchema } from 'openapi/models/createSafeguardSchema'; import type { MetricQuerySchemaTimeRange } from 'openapi/models/metricQuerySchemaTimeRange'; import type { MetricQuerySchemaAggregationMode } from 'openapi/models/metricQuerySchemaAggregationMode'; @@ -21,31 +21,80 @@ import { StyledTextField, StyledTopRow, } from '../shared/SharedFormComponents.tsx'; +import type { ISafeguard } from 'interfaces/releasePlans.ts'; const StyledIcon = createStyledIcon(ShieldIcon); interface ISafeguardFormProps { onSubmit: (data: CreateSafeguardSchema) => void; onCancel: () => void; + safeguard?: ISafeguard; } -export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { - const { metricSeries, loading } = useImpactMetricsNames(); +type FormMode = 'create' | 'edit' | 'display'; - const [selectedMetric, setSelectedMetric] = useState(''); - const [application, setApplication] = useState('*'); +const getInitialValues = (safeguard?: ISafeguard) => ({ + metricName: safeguard?.impactMetric.metricName || '', + appName: safeguard?.impactMetric.labelSelectors.appName[0] || '*', + aggregationMode: (safeguard?.impactMetric.aggregationMode || + 'rps') as MetricQuerySchemaAggregationMode, + operator: (safeguard?.triggerCondition.operator || + '>') as CreateSafeguardSchemaOperator, + threshold: safeguard?.triggerCondition?.threshold || 0, + timeRange: (safeguard?.impactMetric.timeRange || + 'day') as MetricQuerySchemaTimeRange, +}); + +const getDefaultAggregationMode = ( + metricType: string, + fallback: MetricQuerySchemaAggregationMode = 'rps', +): MetricQuerySchemaAggregationMode => { + switch (metricType) { + case 'counter': + return 'count'; + case 'gauge': + return 'avg'; + case 'histogram': + return 'p50'; + default: + return fallback; + } +}; + +export const SafeguardForm = ({ + onSubmit, + onCancel, + safeguard, +}: ISafeguardFormProps) => { + const { metricOptions, loading } = useImpactMetricsOptions(); + + const initialValues = useMemo( + () => getInitialValues(safeguard), + [safeguard], + ); + + const [metricName, setMetricName] = useState(initialValues.metricName); + const [appName, setAppName] = useState(initialValues.appName); const [aggregationMode, setAggregationMode] = - useState('rps'); - const [operator, setOperator] = - useState('>'); - const [threshold, setThreshold] = useState(0); - const [timeRange, setTimeRange] = - useState('day'); + useState( + initialValues.aggregationMode, + ); + const [operator, setOperator] = useState( + initialValues.operator, + ); + const [threshold, setThreshold] = useState(initialValues.threshold); + const [timeRange, setTimeRange] = useState( + initialValues.timeRange, + ); + + const [mode, setMode] = useState( + safeguard ? 'display' : 'create', + ); const { data: metricsData } = useImpactMetricsData( - selectedMetric + metricName ? { - series: selectedMetric, + series: metricName, range: timeRange, aggregationMode: aggregationMode, } @@ -58,66 +107,125 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { }, [metricsData?.labels?.appName]); useEffect(() => { - if (metricSeries.length > 0 && !selectedMetric) { - setSelectedMetric(metricSeries[0].name); + if (metricOptions.length > 0 && !metricName) { + setMetricName(metricOptions[0].name); } - }, [metricSeries, selectedMetric]); + }, [metricOptions, metricName]); - const selectedMetricData = metricSeries.find( - (m) => m.name === selectedMetric, - ); + const selectedMetricData = metricOptions.find((m) => m.name === metricName); const metricType = selectedMetricData?.type || 'unknown'; - const handleMetricChange = (metricName: string) => { - setSelectedMetric(metricName); - setApplication('*'); - - const metric = metricSeries.find((m) => m.name === metricName); - const type = metric?.type || 'unknown'; - - if (type === 'counter') { - setAggregationMode('count'); - } else if (type === 'gauge') { - setAggregationMode('avg'); - } else if (type === 'histogram') { - setAggregationMode('p50'); + const enterEditMode = () => { + if (mode === 'display') { + setMode('edit'); } }; + const handleMetricChange = (value: string) => { + enterEditMode(); + setMetricName(value); + setAppName('*'); + + const metric = metricOptions.find((m) => m.name === value); + if (metric?.type) { + setAggregationMode( + getDefaultAggregationMode(metric.type, aggregationMode), + ); + } + }; + + const handleApplicationChange = (value: string) => { + enterEditMode(); + setAppName(value); + }; + + const handleAggregationModeChange = ( + value: MetricQuerySchemaAggregationMode, + ) => { + enterEditMode(); + setAggregationMode(value); + }; + + const handleOperatorChange = (value: CreateSafeguardSchemaOperator) => { + enterEditMode(); + setOperator(value); + }; + + const handleThresholdChange = (value: number) => { + enterEditMode(); + setThreshold(value); + }; + + const handleTimeRangeChange = (value: MetricQuerySchemaTimeRange) => { + enterEditMode(); + setTimeRange(value); + }; + const handleSubmit = (e: FormEvent) => { e.preventDefault(); - if (!Number.isNaN(Number(threshold))) { - const data: CreateSafeguardSchema = { - impactMetric: { - metricName: selectedMetric, - timeRange, - aggregationMode, - labelSelectors: { - appName: [application], - }, + + if (Number.isNaN(Number(threshold))) { + return; + } + + onSubmit({ + impactMetric: { + metricName, + timeRange, + aggregationMode, + labelSelectors: { + appName: [appName], }, - operator, - threshold: Number(threshold), - }; - onSubmit(data); + }, + operator, + threshold: Number(threshold), + }); + + if (mode === 'edit') { + setMode('display'); } }; + const resetToOriginalValues = () => { + if (!safeguard) return; + + setMetricName(initialValues.metricName); + setAppName(initialValues.appName); + setAggregationMode(initialValues.aggregationMode); + setOperator(initialValues.operator); + setThreshold(initialValues.threshold); + setTimeRange(initialValues.timeRange); + }; + + const handleCancel = () => { + if (mode === 'create') { + onCancel(); + return; + } + + resetToOriginalValues(); + setMode('display'); + }; + + const showButtons = mode === 'create' || mode === 'edit'; + return ( - filtered by setApplication(String(e.target.value))} + value={appName} + onChange={(e) => + handleApplicationChange(String(e.target.value)) + } variant='outlined' size='small' > @@ -131,7 +239,7 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { aggregated by @@ -139,7 +247,7 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { - setOperator( + handleOperatorChange( e.target.value as CreateSafeguardSchemaOperator, ) } @@ -153,7 +261,9 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { setThreshold(Number(e.target.value))} + onChange={(e) => + handleThresholdChange(Number(e.target.value)) + } placeholder='Value' variant='outlined' size='small' @@ -161,22 +271,31 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { /> over - + - - - - + {showButtons && ( + + + + + )} ); }; diff --git a/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ImpactMetricsControls.tsx b/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ImpactMetricsControls.tsx index 519bbaf231..fa3a712c4c 100644 --- a/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ImpactMetricsControls.tsx +++ b/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ImpactMetricsControls.tsx @@ -1,7 +1,7 @@ import type { FC } from 'react'; import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material'; import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; -import { SeriesSelector } from './SeriesSelector/SeriesSelector.tsx'; +import { MetricSelector } from './SeriesSelector/MetricSelector.tsx'; import { RangeSelector } from './RangeSelector/RangeSelector.tsx'; import { ModeSelector } from './ModeSelector/ModeSelector.tsx'; import type { ChartFormState } from '../../hooks/useChartFormState.ts'; @@ -42,7 +42,7 @@ export const ImpactMetricsControls: FC = ({ rates. - = ({ +export const MetricSelector: FC = ({ value, onChange, options, diff --git a/frontend/src/component/impact-metrics/ImpactMetrics.tsx b/frontend/src/component/impact-metrics/ImpactMetrics.tsx index 879988c7c7..cfea8a6010 100644 --- a/frontend/src/component/impact-metrics/ImpactMetrics.tsx +++ b/frontend/src/component/impact-metrics/ImpactMetrics.tsx @@ -3,7 +3,7 @@ import { useState, useCallback } from 'react'; import { Typography, Button, Paper, styled, Box } from '@mui/material'; import Add from '@mui/icons-material/Add'; import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; -import { useImpactMetricsNames } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; +import { useImpactMetricsOptions } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; import { ChartConfigModal } from './ChartConfigModal/ChartConfigModal.tsx'; import { ChartItem } from './ChartItem.tsx'; import { PlausibleChartItem } from './PlausibleChartItem.tsx'; @@ -54,10 +54,10 @@ export const ImpactMetrics: FC = () => { } = useImpactMetricsState(); const { - metricSeries, + metricOptions, loading: metadataLoading, error: metadataError, - } = useImpactMetricsNames(); + } = useImpactMetricsOptions(); const handleAddChart = () => { setEditingChart(undefined); @@ -193,7 +193,7 @@ export const ImpactMetrics: FC = () => { onClose={() => setModalOpen(false)} onSave={handleSaveChart} initialConfig={editingChart} - metricSeries={metricSeries} + metricSeries={metricOptions} loading={metadataLoading || settingsLoading} /> diff --git a/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts index c70cf72308..2e2ccb0385 100644 --- a/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts +++ b/frontend/src/hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata.ts @@ -27,10 +27,10 @@ export const useImpactMetricsMetadata = () => { }; }; -export const useImpactMetricsNames = () => { +export const useImpactMetricsOptions = () => { const { metadata, loading, error } = useImpactMetricsMetadata(); - const metricSeries = useMemo(() => { + const metricOptions = useMemo(() => { if (!metadata?.series) { return []; } @@ -41,7 +41,7 @@ export const useImpactMetricsNames = () => { }, [metadata]); return { - metricSeries, + metricOptions, loading, error, }; diff --git a/frontend/src/hooks/useReleasePlanPreview.ts b/frontend/src/hooks/useReleasePlanPreview.ts index d1c940cd14..c4415623be 100644 --- a/frontend/src/hooks/useReleasePlanPreview.ts +++ b/frontend/src/hooks/useReleasePlanPreview.ts @@ -12,6 +12,7 @@ export const useReleasePlanPreview = ( ...template, featureName, environment, + safeguards: [], milestones: template.milestones.map((milestone) => ({ ...milestone, releasePlanDefinitionId: template.id, diff --git a/frontend/src/interfaces/releasePlans.ts b/frontend/src/interfaces/releasePlans.ts index a839b70f5e..4bb368963d 100644 --- a/frontend/src/interfaces/releasePlans.ts +++ b/frontend/src/interfaces/releasePlans.ts @@ -1,4 +1,7 @@ import type { IFeatureStrategy } from './strategy.js'; +import type { MetricQuerySchemaTimeRange } from 'openapi/models/metricQuerySchemaTimeRange'; +import type { MetricQuerySchemaAggregationMode } from 'openapi/models/metricQuerySchemaAggregationMode'; +import type { CreateSafeguardSchemaOperator } from 'openapi/models/createSafeguardSchemaOperator'; export interface IReleasePlanTemplate { id: string; @@ -18,6 +21,18 @@ export interface IReleasePlanTemplate { archivedAt?: string; } +export interface ISafeguard { + impactMetric: { + aggregationMode: MetricQuerySchemaAggregationMode; + metricName: string; + timeRange: MetricQuerySchemaTimeRange; + labelSelectors: { appName: [string] }; + }; + triggerCondition: { + operator: CreateSafeguardSchemaOperator; + threshold: number; + }; +} export interface IReleasePlan { id: string; name: string; @@ -28,6 +43,7 @@ export interface IReleasePlan { featureName: string; environment: string; milestones: IReleasePlanMilestone[]; + safeguards: ISafeguard[]; } export interface IReleasePlanMilestone {