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:
parent
89a3578826
commit
3b07b66712
@ -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>
|
||||
|
||||
@ -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)}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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,
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
|
||||
@ -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,
|
||||
};
|
||||
|
||||
@ -12,6 +12,7 @@ export const useReleasePlanPreview = (
|
||||
...template,
|
||||
featureName,
|
||||
environment,
|
||||
safeguards: [],
|
||||
milestones: template.milestones.map((milestone) => ({
|
||||
...milestone,
|
||||
releasePlanDefinitionId: template.id,
|
||||
|
||||
@ -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 {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user