1
0
mirror of https://github.com/Unleash/unleash.git synced 2025-11-24 20:06:55 +01:00

feat: safeguards form edit and display (#10967)

This commit is contained in:
Mateusz Kwasniewski 2025-11-12 21:24:07 +01:00 committed by GitHub
parent 89a3578826
commit 3b07b66712
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 231 additions and 87 deletions

View File

@ -2,7 +2,7 @@ import { PageContent } from 'component/common/PageContent/PageContent.tsx';
import { PageHeader } from '../../../common/PageHeader/PageHeader.tsx'; import { PageHeader } from '../../../common/PageHeader/PageHeader.tsx';
import { Box, styled, Typography } from '@mui/material'; import { Box, styled, Typography } from '@mui/material';
import Add from '@mui/icons-material/Add'; 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 { type FC, useMemo, useState } from 'react';
import { ChartConfigModal } from '../../../impact-metrics/ChartConfigModal/ChartConfigModal.tsx'; import { ChartConfigModal } from '../../../impact-metrics/ChartConfigModal/ChartConfigModal.tsx';
import { useImpactMetricsApi } from 'hooks/api/actions/useImpactMetricsApi/useImpactMetricsApi.ts'; import { useImpactMetricsApi } from 'hooks/api/actions/useImpactMetricsApi/useImpactMetricsApi.ts';
@ -49,10 +49,10 @@ export const FeatureImpactMetrics: FC = () => {
const { setToastApiError } = useToast(); const { setToastApiError } = useToast();
const { const {
metricSeries, metricOptions,
loading: metadataLoading, loading: metadataLoading,
error: metadataError, error: metadataError,
} = useImpactMetricsNames(); } = useImpactMetricsOptions();
const handleAddChart = () => { const handleAddChart = () => {
setModalState({ type: 'creating' }); setModalState({ type: 'creating' });
@ -151,7 +151,7 @@ export const FeatureImpactMetrics: FC = () => {
onClose={handleCloseModal} onClose={handleCloseModal}
onSave={handleSaveChart} onSave={handleSaveChart}
initialConfig={editingChart} initialConfig={editingChart}
metricSeries={metricSeries} metricSeries={metricOptions}
loading={metadataLoading} loading={metadataLoading}
/> />
</PageContent> </PageContent>

View File

@ -120,6 +120,7 @@ export const ReleasePlan = ({
featureName, featureName,
environment, environment,
milestones, milestones,
safeguards,
} = plan; } = plan;
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
@ -389,7 +390,6 @@ export const ReleasePlan = ({
planId: id, planId: id,
body: data, body: data,
}); });
setSafeguardFormOpen(false);
setToastData({ setToastData({
type: 'success', type: 'success',
text: 'Safeguard added successfully', text: 'Safeguard added successfully',
@ -431,11 +431,20 @@ export const ReleasePlan = ({
<StyledBody safeguards={safeguardsEnabled}> <StyledBody safeguards={safeguardsEnabled}>
{safeguardsEnabled ? ( {safeguardsEnabled ? (
<StyledAddSafeguard> <StyledAddSafeguard>
{safeguardFormOpen ? ( {safeguards.length > 0 ? (
<SafeguardForm <SafeguardForm
safeguard={safeguards[0]}
onSubmit={handleSafeguardSubmit} onSubmit={handleSafeguardSubmit}
onCancel={() => setSafeguardFormOpen(false)} onCancel={() => setSafeguardFormOpen(false)}
/> />
) : safeguardFormOpen ? (
<SafeguardForm
onSubmit={(data) => {
handleSafeguardSubmit(data);
setSafeguardFormOpen(false);
}}
onCancel={() => setSafeguardFormOpen(false)}
/>
) : ( ) : (
<StyledActionButton <StyledActionButton
onClick={() => setSafeguardFormOpen(true)} onClick={() => setSafeguardFormOpen(true)}

View File

@ -2,11 +2,11 @@ import { Button } from '@mui/material';
import ShieldIcon from '@mui/icons-material/Shield'; import ShieldIcon from '@mui/icons-material/Shield';
import type { FormEvent } from 'react'; import type { FormEvent } from 'react';
import { useEffect, useMemo, useState } 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 { useImpactMetricsData } from 'hooks/api/getters/useImpactMetricsData/useImpactMetricsData';
import { RangeSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/RangeSelector/RangeSelector'; import { RangeSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/RangeSelector/RangeSelector';
import { ModeSelector } from 'component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ModeSelector/ModeSelector'; 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 { CreateSafeguardSchema } from 'openapi/models/createSafeguardSchema';
import type { MetricQuerySchemaTimeRange } from 'openapi/models/metricQuerySchemaTimeRange'; import type { MetricQuerySchemaTimeRange } from 'openapi/models/metricQuerySchemaTimeRange';
import type { MetricQuerySchemaAggregationMode } from 'openapi/models/metricQuerySchemaAggregationMode'; import type { MetricQuerySchemaAggregationMode } from 'openapi/models/metricQuerySchemaAggregationMode';
@ -21,31 +21,80 @@ import {
StyledTextField, StyledTextField,
StyledTopRow, StyledTopRow,
} from '../shared/SharedFormComponents.tsx'; } from '../shared/SharedFormComponents.tsx';
import type { ISafeguard } from 'interfaces/releasePlans.ts';
const StyledIcon = createStyledIcon(ShieldIcon); const StyledIcon = createStyledIcon(ShieldIcon);
interface ISafeguardFormProps { interface ISafeguardFormProps {
onSubmit: (data: CreateSafeguardSchema) => void; onSubmit: (data: CreateSafeguardSchema) => void;
onCancel: () => void; onCancel: () => void;
safeguard?: ISafeguard;
} }
export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => { type FormMode = 'create' | 'edit' | 'display';
const { metricSeries, loading } = useImpactMetricsNames();
const [selectedMetric, setSelectedMetric] = useState(''); const getInitialValues = (safeguard?: ISafeguard) => ({
const [application, setApplication] = useState('*'); 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] = const [aggregationMode, setAggregationMode] =
useState<MetricQuerySchemaAggregationMode>('rps'); useState<MetricQuerySchemaAggregationMode>(
const [operator, setOperator] = initialValues.aggregationMode,
useState<CreateSafeguardSchemaOperator>('>'); );
const [threshold, setThreshold] = useState(0); const [operator, setOperator] = useState<CreateSafeguardSchemaOperator>(
const [timeRange, setTimeRange] = initialValues.operator,
useState<MetricQuerySchemaTimeRange>('day'); );
const [threshold, setThreshold] = useState(initialValues.threshold);
const [timeRange, setTimeRange] = useState<MetricQuerySchemaTimeRange>(
initialValues.timeRange,
);
const [mode, setMode] = useState<FormMode>(
safeguard ? 'display' : 'create',
);
const { data: metricsData } = useImpactMetricsData( const { data: metricsData } = useImpactMetricsData(
selectedMetric metricName
? { ? {
series: selectedMetric, series: metricName,
range: timeRange, range: timeRange,
aggregationMode: aggregationMode, aggregationMode: aggregationMode,
} }
@ -58,66 +107,125 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
}, [metricsData?.labels?.appName]); }, [metricsData?.labels?.appName]);
useEffect(() => { useEffect(() => {
if (metricSeries.length > 0 && !selectedMetric) { if (metricOptions.length > 0 && !metricName) {
setSelectedMetric(metricSeries[0].name); setMetricName(metricOptions[0].name);
} }
}, [metricSeries, selectedMetric]); }, [metricOptions, metricName]);
const selectedMetricData = metricSeries.find( const selectedMetricData = metricOptions.find((m) => m.name === metricName);
(m) => m.name === selectedMetric,
);
const metricType = selectedMetricData?.type || 'unknown'; const metricType = selectedMetricData?.type || 'unknown';
const handleMetricChange = (metricName: string) => { const enterEditMode = () => {
setSelectedMetric(metricName); if (mode === 'display') {
setApplication('*'); setMode('edit');
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 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) => { const handleSubmit = (e: FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!Number.isNaN(Number(threshold))) {
const data: CreateSafeguardSchema = { if (Number.isNaN(Number(threshold))) {
return;
}
onSubmit({
impactMetric: { impactMetric: {
metricName: selectedMetric, metricName,
timeRange, timeRange,
aggregationMode, aggregationMode,
labelSelectors: { labelSelectors: {
appName: [application], appName: [appName],
}, },
}, },
operator, operator,
threshold: Number(threshold), threshold: Number(threshold),
}; });
onSubmit(data);
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 ( return (
<StyledFormContainer onSubmit={handleSubmit}> <StyledFormContainer onSubmit={handleSubmit}>
<StyledTopRow> <StyledTopRow>
<StyledIcon /> <StyledIcon />
<SeriesSelector <MetricSelector
value={selectedMetric} value={metricName}
onChange={handleMetricChange} onChange={handleMetricChange}
options={metricSeries} options={metricOptions}
loading={loading} loading={loading}
/> />
<StyledLabel>filtered by</StyledLabel> <StyledLabel>filtered by</StyledLabel>
<StyledSelect <StyledSelect
value={application} value={appName}
onChange={(e) => setApplication(String(e.target.value))} onChange={(e) =>
handleApplicationChange(String(e.target.value))
}
variant='outlined' variant='outlined'
size='small' size='small'
> >
@ -131,7 +239,7 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
<StyledLabel>aggregated by</StyledLabel> <StyledLabel>aggregated by</StyledLabel>
<ModeSelector <ModeSelector
value={aggregationMode} value={aggregationMode}
onChange={setAggregationMode} onChange={handleAggregationModeChange}
metricType={metricType} metricType={metricType}
/> />
@ -139,7 +247,7 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
<StyledSelect <StyledSelect
value={operator} value={operator}
onChange={(e) => onChange={(e) =>
setOperator( handleOperatorChange(
e.target.value as CreateSafeguardSchemaOperator, e.target.value as CreateSafeguardSchemaOperator,
) )
} }
@ -153,7 +261,9 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
<StyledTextField <StyledTextField
type='number' type='number'
value={threshold} value={threshold}
onChange={(e) => setThreshold(Number(e.target.value))} onChange={(e) =>
handleThresholdChange(Number(e.target.value))
}
placeholder='Value' placeholder='Value'
variant='outlined' variant='outlined'
size='small' size='small'
@ -161,10 +271,18 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
/> />
<StyledLabel>over</StyledLabel> <StyledLabel>over</StyledLabel>
<RangeSelector value={timeRange} onChange={setTimeRange} /> <RangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
/>
</StyledTopRow> </StyledTopRow>
{showButtons && (
<StyledButtonGroup> <StyledButtonGroup>
<Button variant='outlined' onClick={onCancel} size='small'> <Button
variant='outlined'
onClick={handleCancel}
size='small'
>
Cancel Cancel
</Button> </Button>
<Button <Button
@ -177,6 +295,7 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
Save Save
</Button> </Button>
</StyledButtonGroup> </StyledButtonGroup>
)}
</StyledFormContainer> </StyledFormContainer>
); );
}; };

View File

@ -1,7 +1,7 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material'; import { Box, Typography, FormControlLabel, Checkbox } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata'; 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 { RangeSelector } from './RangeSelector/RangeSelector.tsx';
import { ModeSelector } from './ModeSelector/ModeSelector.tsx'; import { ModeSelector } from './ModeSelector/ModeSelector.tsx';
import type { ChartFormState } from '../../hooks/useChartFormState.ts'; import type { ChartFormState } from '../../hooks/useChartFormState.ts';
@ -42,7 +42,7 @@ export const ImpactMetricsControls: FC<ImpactMetricsControlsProps> = ({
rates. rates.
</Typography> </Typography>
<SeriesSelector <MetricSelector
value={formData.metricName} value={formData.metricName}
onChange={actions.handleSeriesChange} onChange={actions.handleSeriesChange}
options={metricSeries} options={metricSeries}

View File

@ -1,9 +1,8 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { Autocomplete, TextField, Typography, Box } from '@mui/material'; import { Autocomplete, TextField, Typography, Box } from '@mui/material';
import type { ImpactMetricsSeries } from 'hooks/api/getters/useImpactMetricsMetadata/useImpactMetricsMetadata';
import { Highlighter } from 'component/common/Highlighter/Highlighter'; import { Highlighter } from 'component/common/Highlighter/Highlighter';
type SeriesOption = ImpactMetricsSeries & { name: string; displayName: string }; type SeriesOption = { name: string; displayName: string; help: string };
export type SeriesSelectorProps = { export type SeriesSelectorProps = {
value: string; value: string;
@ -12,7 +11,7 @@ export type SeriesSelectorProps = {
loading?: boolean; loading?: boolean;
}; };
export const SeriesSelector: FC<SeriesSelectorProps> = ({ export const MetricSelector: FC<SeriesSelectorProps> = ({
value, value,
onChange, onChange,
options, options,

View File

@ -3,7 +3,7 @@ import { useState, useCallback } from 'react';
import { Typography, Button, Paper, styled, Box } from '@mui/material'; import { Typography, Button, Paper, styled, Box } from '@mui/material';
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import { PageHeader } from 'component/common/PageHeader/PageHeader.tsx'; 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 { ChartConfigModal } from './ChartConfigModal/ChartConfigModal.tsx';
import { ChartItem } from './ChartItem.tsx'; import { ChartItem } from './ChartItem.tsx';
import { PlausibleChartItem } from './PlausibleChartItem.tsx'; import { PlausibleChartItem } from './PlausibleChartItem.tsx';
@ -54,10 +54,10 @@ export const ImpactMetrics: FC = () => {
} = useImpactMetricsState(); } = useImpactMetricsState();
const { const {
metricSeries, metricOptions,
loading: metadataLoading, loading: metadataLoading,
error: metadataError, error: metadataError,
} = useImpactMetricsNames(); } = useImpactMetricsOptions();
const handleAddChart = () => { const handleAddChart = () => {
setEditingChart(undefined); setEditingChart(undefined);
@ -193,7 +193,7 @@ export const ImpactMetrics: FC = () => {
onClose={() => setModalOpen(false)} onClose={() => setModalOpen(false)}
onSave={handleSaveChart} onSave={handleSaveChart}
initialConfig={editingChart} initialConfig={editingChart}
metricSeries={metricSeries} metricSeries={metricOptions}
loading={metadataLoading || settingsLoading} loading={metadataLoading || settingsLoading}
/> />
</> </>

View File

@ -27,10 +27,10 @@ export const useImpactMetricsMetadata = () => {
}; };
}; };
export const useImpactMetricsNames = () => { export const useImpactMetricsOptions = () => {
const { metadata, loading, error } = useImpactMetricsMetadata(); const { metadata, loading, error } = useImpactMetricsMetadata();
const metricSeries = useMemo(() => { const metricOptions = useMemo(() => {
if (!metadata?.series) { if (!metadata?.series) {
return []; return [];
} }
@ -41,7 +41,7 @@ export const useImpactMetricsNames = () => {
}, [metadata]); }, [metadata]);
return { return {
metricSeries, metricOptions,
loading, loading,
error, error,
}; };

View File

@ -12,6 +12,7 @@ export const useReleasePlanPreview = (
...template, ...template,
featureName, featureName,
environment, environment,
safeguards: [],
milestones: template.milestones.map((milestone) => ({ milestones: template.milestones.map((milestone) => ({
...milestone, ...milestone,
releasePlanDefinitionId: template.id, releasePlanDefinitionId: template.id,

View File

@ -1,4 +1,7 @@
import type { IFeatureStrategy } from './strategy.js'; 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 { export interface IReleasePlanTemplate {
id: string; id: string;
@ -18,6 +21,18 @@ export interface IReleasePlanTemplate {
archivedAt?: string; archivedAt?: string;
} }
export interface ISafeguard {
impactMetric: {
aggregationMode: MetricQuerySchemaAggregationMode;
metricName: string;
timeRange: MetricQuerySchemaTimeRange;
labelSelectors: { appName: [string] };
};
triggerCondition: {
operator: CreateSafeguardSchemaOperator;
threshold: number;
};
}
export interface IReleasePlan { export interface IReleasePlan {
id: string; id: string;
name: string; name: string;
@ -28,6 +43,7 @@ export interface IReleasePlan {
featureName: string; featureName: string;
environment: string; environment: string;
milestones: IReleasePlanMilestone[]; milestones: IReleasePlanMilestone[];
safeguards: ISafeguard[];
} }
export interface IReleasePlanMilestone { export interface IReleasePlanMilestone {