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 { 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}
/>
</PageContent>

View File

@ -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 = ({
<StyledBody safeguards={safeguardsEnabled}>
{safeguardsEnabled ? (
<StyledAddSafeguard>
{safeguardFormOpen ? (
{safeguards.length > 0 ? (
<SafeguardForm
safeguard={safeguards[0]}
onSubmit={handleSafeguardSubmit}
onCancel={() => setSafeguardFormOpen(false)}
/>
) : safeguardFormOpen ? (
<SafeguardForm
onSubmit={(data) => {
handleSafeguardSubmit(data);
setSafeguardFormOpen(false);
}}
onCancel={() => setSafeguardFormOpen(false)}
/>
) : (
<StyledActionButton
onClick={() => setSafeguardFormOpen(true)}

View File

@ -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<MetricQuerySchemaAggregationMode>('rps');
const [operator, setOperator] =
useState<CreateSafeguardSchemaOperator>('>');
const [threshold, setThreshold] = useState(0);
const [timeRange, setTimeRange] =
useState<MetricQuerySchemaTimeRange>('day');
useState<MetricQuerySchemaAggregationMode>(
initialValues.aggregationMode,
);
const [operator, setOperator] = useState<CreateSafeguardSchemaOperator>(
initialValues.operator,
);
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(
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 (
<StyledFormContainer onSubmit={handleSubmit}>
<StyledTopRow>
<StyledIcon />
<SeriesSelector
value={selectedMetric}
<MetricSelector
value={metricName}
onChange={handleMetricChange}
options={metricSeries}
options={metricOptions}
loading={loading}
/>
<StyledLabel>filtered by</StyledLabel>
<StyledSelect
value={application}
onChange={(e) => 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) => {
<StyledLabel>aggregated by</StyledLabel>
<ModeSelector
value={aggregationMode}
onChange={setAggregationMode}
onChange={handleAggregationModeChange}
metricType={metricType}
/>
@ -139,7 +247,7 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
<StyledSelect
value={operator}
onChange={(e) =>
setOperator(
handleOperatorChange(
e.target.value as CreateSafeguardSchemaOperator,
)
}
@ -153,7 +261,9 @@ export const SafeguardForm = ({ onSubmit, onCancel }: ISafeguardFormProps) => {
<StyledTextField
type='number'
value={threshold}
onChange={(e) => 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) => {
/>
<StyledLabel>over</StyledLabel>
<RangeSelector value={timeRange} onChange={setTimeRange} />
<RangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
/>
</StyledTopRow>
<StyledButtonGroup>
<Button variant='outlined' onClick={onCancel} size='small'>
Cancel
</Button>
<Button
variant='contained'
color='primary'
size='small'
type='submit'
disabled={Number.isNaN(Number(threshold))}
>
Save
</Button>
</StyledButtonGroup>
{showButtons && (
<StyledButtonGroup>
<Button
variant='outlined'
onClick={handleCancel}
size='small'
>
Cancel
</Button>
<Button
variant='contained'
color='primary'
size='small'
type='submit'
disabled={Number.isNaN(Number(threshold))}
>
Save
</Button>
</StyledButtonGroup>
)}
</StyledFormContainer>
);
};

View File

@ -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<ImpactMetricsControlsProps> = ({
rates.
</Typography>
<SeriesSelector
<MetricSelector
value={formData.metricName}
onChange={actions.handleSeriesChange}
options={metricSeries}

View File

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

View File

@ -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}
/>
</>

View File

@ -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,
};

View File

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

View File

@ -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 {