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

Merge branch 'main' into docs/audit-urls

This commit is contained in:
melindafekete 2025-11-24 17:36:30 +01:00
commit 59e9fb0ea0
No known key found for this signature in database
24 changed files with 325 additions and 152 deletions

View File

@ -177,7 +177,7 @@ export const NetworkConnectedEdges = () => {
<br /> <br />
<br /> <br />
Interested in getting started?{' '} Interested in getting started?{' '}
<a href={`mailto:sales@getunleash.io?subject=Enterprise Edge`}> <a href='mailto:license@getunleash.io?subject=Enterprise Edge'>
Contact us Contact us
</a> </a>
</Alert> </Alert>

View File

@ -37,7 +37,10 @@ const MilestoneListRendererCore = ({
onUpdateAutomation, onUpdateAutomation,
onDeleteAutomation, onDeleteAutomation,
}: MilestoneListRendererCoreProps) => { }: MilestoneListRendererCoreProps) => {
const status: MilestoneStatus = { type: 'not-started' }; const status: MilestoneStatus = {
type: 'not-started',
progression: 'active',
};
return ( return (
<> <>

View File

@ -66,7 +66,7 @@ export const EnvironmentAccordionBody = ({
const [strategies, setStrategies] = useState( const [strategies, setStrategies] = useState(
featureEnvironment?.strategies || [], featureEnvironment?.strategies || [],
); );
const { releasePlans } = useFeatureReleasePlans( const { releasePlans, refetch } = useFeatureReleasePlans(
projectId, projectId,
featureId, featureId,
featureEnvironment?.name, featureEnvironment?.name,
@ -229,6 +229,7 @@ export const EnvironmentAccordionBody = ({
<ReleasePlan <ReleasePlan
plan={plan} plan={plan}
environmentIsDisabled={isDisabled} environmentIsDisabled={isDisabled}
onAutomationChange={refetch}
/> />
</StrategyListItem> </StrategyListItem>
))} ))}

View File

@ -4,7 +4,6 @@ import PlayCircle from '@mui/icons-material/PlayCircle';
import { DELETE_FEATURE_STRATEGY } from '@server/types/permissions'; import { DELETE_FEATURE_STRATEGY } from '@server/types/permissions';
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton'; import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi'; import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
import { useFeatureReleasePlans } from 'hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans';
import { useRequiredPathParam } from 'hooks/useRequiredPathParam'; import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
import useToast from 'hooks/useToast'; import useToast from 'hooks/useToast';
import type { import type {
@ -32,7 +31,10 @@ import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlan
import Add from '@mui/icons-material/Add'; import Add from '@mui/icons-material/Add';
import { StyledActionButton } from './ReleasePlanMilestoneItem/StyledActionButton.tsx'; import { StyledActionButton } from './ReleasePlanMilestoneItem/StyledActionButton.tsx';
import { SafeguardForm } from './SafeguardForm/SafeguardForm.tsx'; import {
SafeguardForm,
useSafeguardForm,
} from './SafeguardForm/SafeguardForm.tsx';
import { useSafeguardsApi } from 'hooks/api/actions/useSafeguardsApi/useSafeguardsApi'; import { useSafeguardsApi } from 'hooks/api/actions/useSafeguardsApi/useSafeguardsApi';
import type { CreateSafeguardSchema } from 'openapi/models/createSafeguardSchema'; import type { CreateSafeguardSchema } from 'openapi/models/createSafeguardSchema';
import { DeleteSafeguardDialog } from './DeleteSafeguardDialog.tsx'; import { DeleteSafeguardDialog } from './DeleteSafeguardDialog.tsx';
@ -78,19 +80,21 @@ const StyledHeaderDescription = styled('p')(({ theme }) => ({
})); }));
const StyledBody = styled('div', { const StyledBody = styled('div', {
shouldForwardProp: (prop) => prop !== 'safeguards', shouldForwardProp: (prop) => prop !== 'border',
})<{ safeguards: boolean }>(({ theme, safeguards }) => ({ })<{ border: 'solid' | 'dashed' | null }>(({ theme, border }) => ({
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
...(safeguards && { ...(border && {
border: `1px dashed ${theme.palette.neutral.border}`, border: `1px ${border} ${theme.palette.neutral.border}`,
borderRadius: theme.shape.borderRadiusMedium, borderRadius: theme.shape.borderRadiusMedium,
}), }),
})); }));
const StyledAddSafeguard = styled('div')(({ theme }) => ({ const StyledAddSafeguard = styled('div', {
shouldForwardProp: (prop) => prop !== 'border',
})<{ border: 'solid' | 'dashed' | null }>(({ theme, border }) => ({
display: 'flex', display: 'flex',
borderBottom: `1px dashed ${theme.palette.neutral.border}`, borderBottom: `1px ${border || 'dashed'} ${theme.palette.neutral.border}`,
padding: theme.spacing(0.25, 0.25), padding: theme.spacing(0.25, 0.25),
})); }));
@ -118,12 +122,14 @@ interface IReleasePlanProps {
plan: IReleasePlan; plan: IReleasePlan;
environmentIsDisabled?: boolean; environmentIsDisabled?: boolean;
readonly?: boolean; readonly?: boolean;
onAutomationChange?: () => void;
} }
export const ReleasePlan = ({ export const ReleasePlan = ({
plan, plan,
environmentIsDisabled, environmentIsDisabled,
readonly, readonly,
onAutomationChange,
}: IReleasePlanProps) => { }: IReleasePlanProps) => {
const { const {
id, id,
@ -137,11 +143,6 @@ export const ReleasePlan = ({
} = plan; } = plan;
const projectId = useRequiredPathParam('projectId'); const projectId = useRequiredPathParam('projectId');
const { refetch } = useFeatureReleasePlans(
projectId,
featureName,
environment,
);
const { removeReleasePlanFromFeature, startReleasePlanMilestone } = const { removeReleasePlanFromFeature, startReleasePlanMilestone } =
useReleasePlansApi(); useReleasePlansApi();
const { const {
@ -222,9 +223,11 @@ export const ReleasePlan = ({
>(null); >(null);
const [milestoneToDeleteProgression, setMilestoneToDeleteProgression] = const [milestoneToDeleteProgression, setMilestoneToDeleteProgression] =
useState<IReleasePlanMilestone | null>(null); useState<IReleasePlanMilestone | null>(null);
const [safeguardFormOpen, setSafeguardFormOpen] = useState(false);
const [safeguardDeleteDialogOpen, setSafeguardDeleteDialogOpen] = const [safeguardDeleteDialogOpen, setSafeguardDeleteDialogOpen] =
useState(false); useState(false);
const { safeguardFormOpen, setSafeguardFormOpen } =
useSafeguardForm(safeguards);
const onChangeRequestConfirm = async () => { const onChangeRequestConfirm = async () => {
if (!changeRequestAction) return; if (!changeRequestAction) return;
@ -312,7 +315,7 @@ export const ReleasePlan = ({
type: 'success', type: 'success',
}); });
refetch(); onAutomationChange?.();
setRemoveOpen(false); setRemoveOpen(false);
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
@ -338,7 +341,7 @@ export const ReleasePlan = ({
text: `Milestone "${milestone.name}" has started`, text: `Milestone "${milestone.name}" has started`,
type: 'success', type: 'success',
}); });
refetch(); onAutomationChange?.();
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }
@ -388,7 +391,7 @@ export const ReleasePlan = ({
featureName, featureName,
sourceMilestoneId: milestoneToDeleteProgression.id, sourceMilestoneId: milestoneToDeleteProgression.id,
}); });
await refetch(); onAutomationChange?.();
setMilestoneToDeleteProgression(null); setMilestoneToDeleteProgression(null);
setToastData({ setToastData({
type: 'success', type: 'success',
@ -412,7 +415,7 @@ export const ReleasePlan = ({
type: 'success', type: 'success',
text: 'Automation resumed successfully', text: 'Automation resumed successfully',
}); });
refetch(); onAutomationChange?.();
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} }
@ -435,11 +438,9 @@ export const ReleasePlan = ({
type: 'success', type: 'success',
text: 'Safeguard added successfully', text: 'Safeguard added successfully',
}); });
refetch(); onAutomationChange?.();
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} finally {
setSafeguardFormOpen(false);
} }
}; };
@ -462,7 +463,7 @@ export const ReleasePlan = ({
type: 'success', type: 'success',
text: 'Safeguard deleted successfully', text: 'Safeguard deleted successfully',
}); });
refetch(); onAutomationChange?.();
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
} finally { } finally {
@ -476,6 +477,13 @@ export const ReleasePlan = ({
} }
}; };
const safeguardBorder =
safeguardsEnabled && safeguards
? safeguards[0]
? 'solid'
: 'dashed'
: null;
return ( return (
<StyledContainer> <StyledContainer>
<StyledHeader> <StyledHeader>
@ -522,26 +530,22 @@ export const ReleasePlan = ({
</StyledAlert> </StyledAlert>
) : null} ) : null}
<StyledBody safeguards={safeguardsEnabled}> <StyledBody border={safeguardBorder}>
{safeguardsEnabled ? ( {onAutomationChange && safeguardsEnabled ? (
<StyledAddSafeguard> <StyledAddSafeguard border={safeguardBorder}>
{safeguards.length > 0 ? ( {safeguardFormOpen ? (
<SafeguardForm <SafeguardForm
safeguard={safeguards[0]} safeguard={safeguards?.[0]}
onSubmit={handleSafeguardSubmit} onSubmit={handleSafeguardSubmit}
onCancel={() => setSafeguardFormOpen(false)} onCancel={() => setSafeguardFormOpen(false)}
onDelete={handleSafeguardDelete} onDelete={handleSafeguardDelete}
/> />
) : safeguardFormOpen ? (
<SafeguardForm
onSubmit={handleSafeguardSubmit}
onCancel={() => setSafeguardFormOpen(false)}
/>
) : ( ) : (
<StyledActionButton <StyledActionButton
onClick={() => setSafeguardFormOpen(true)} onClick={() => setSafeguardFormOpen(true)}
color='primary' color='primary'
startIcon={<Add />} startIcon={<Add />}
sx={{ m: 2 }}
> >
Add safeguard Add safeguard
</StyledActionButton> </StyledActionButton>
@ -575,7 +579,7 @@ export const ReleasePlan = ({
projectId={projectId} projectId={projectId}
environment={environment} environment={environment}
featureName={featureName} featureName={featureName}
onUpdate={refetch} onUpdate={onAutomationChange}
/> />
))} ))}
</StyledMilestones> </StyledMilestones>

View File

@ -109,7 +109,7 @@ interface IReleasePlanMilestoneProps {
export const ReleasePlanMilestone = ({ export const ReleasePlanMilestone = ({
milestone, milestone,
status = { type: 'not-started' }, status = { type: 'not-started', progression: 'active' },
onStartMilestone, onStartMilestone,
readonly, readonly,
automationSection, automationSection,

View File

@ -4,11 +4,17 @@ import PauseCircleIcon from '@mui/icons-material/PauseCircle';
import TripOriginIcon from '@mui/icons-material/TripOrigin'; import TripOriginIcon from '@mui/icons-material/TripOrigin';
import { useUiFlag } from 'hooks/useUiFlag'; import { useUiFlag } from 'hooks/useUiFlag';
export type MilestoneProgressionStatus = 'paused' | 'active';
export type MilestoneStatus = export type MilestoneStatus =
| { type: 'not-started'; scheduledAt?: Date } | {
| { type: 'active' } type: 'not-started';
| { type: 'paused' } scheduledAt?: Date;
| { type: 'completed' }; progression: MilestoneProgressionStatus;
}
| { type: 'active'; progression: MilestoneProgressionStatus }
| { type: 'paused'; progression: MilestoneProgressionStatus }
| { type: 'completed'; progression: MilestoneProgressionStatus };
const BaseStatusButton = styled('button')<{ disabled?: boolean }>( const BaseStatusButton = styled('button')<{ disabled?: boolean }>(
({ theme, disabled }) => ({ ({ theme, disabled }) => ({

View File

@ -12,9 +12,8 @@ import { StyledActionButton } from './StyledActionButton.tsx';
interface MilestoneAutomationProps { interface MilestoneAutomationProps {
milestone: IReleasePlanMilestone; milestone: IReleasePlanMilestone;
milestones: IReleasePlanMilestone[];
status: MilestoneStatus; status: MilestoneStatus;
isNotLastMilestone: boolean;
nextMilestoneId: string;
milestoneProgressionsEnabled: boolean; milestoneProgressionsEnabled: boolean;
readonly: boolean | undefined; readonly: boolean | undefined;
isProgressionFormOpen: boolean; isProgressionFormOpen: boolean;
@ -30,9 +29,8 @@ interface MilestoneAutomationProps {
export const MilestoneAutomation = ({ export const MilestoneAutomation = ({
milestone, milestone,
milestones,
status, status,
isNotLastMilestone,
nextMilestoneId,
milestoneProgressionsEnabled, milestoneProgressionsEnabled,
readonly, readonly,
isProgressionFormOpen, isProgressionFormOpen,
@ -43,6 +41,13 @@ export const MilestoneAutomation = ({
onChangeProgression, onChangeProgression,
onDeleteProgression, onDeleteProgression,
}: MilestoneAutomationProps) => { }: MilestoneAutomationProps) => {
const milestoneIndex = milestones.findIndex((m) => m.id === milestone.id);
const isNotLastMilestone = milestoneIndex < milestones.length - 1;
const nextMilestoneId = milestones[milestoneIndex + 1]?.id || '';
const hasAnyPausedMilestone = milestones.some((milestone) =>
Boolean(milestone.pausedAt),
);
const showAutomation = const showAutomation =
milestoneProgressionsEnabled && isNotLastMilestone && !readonly; milestoneProgressionsEnabled && isNotLastMilestone && !readonly;
@ -59,7 +64,7 @@ export const MilestoneAutomation = ({
<Badge color='error'>Deleted in draft</Badge> <Badge color='error'>Deleted in draft</Badge>
) : hasPendingChange ? ( ) : hasPendingChange ? (
<Badge color='warning'>Modified in draft</Badge> <Badge color='warning'>Modified in draft</Badge>
) : status?.type === 'paused' ? ( ) : status?.progression === 'paused' ? (
<Badge color='error' icon={<WarningAmber fontSize='small' />}> <Badge color='error' icon={<WarningAmber fontSize='small' />}>
Paused Paused
</Badge> </Badge>
@ -89,7 +94,7 @@ export const MilestoneAutomation = ({
status={status} status={status}
badge={badge} badge={badge}
/> />
) : ( ) : hasAnyPausedMilestone ? null : (
<StyledActionButton <StyledActionButton
onClick={onOpenProgressionForm} onClick={onOpenProgressionForm}
color='primary' color='primary'

View File

@ -52,7 +52,7 @@ export interface IReleasePlanMilestoneItemProps {
projectId: string; projectId: string;
environment: string; environment: string;
featureName: string; featureName: string;
onUpdate: () => void | Promise<void>; onUpdate?: () => void;
} }
const getTimeUnit = (intervalMinutes: number): 'minutes' | 'hours' | 'days' => { const getTimeUnit = (intervalMinutes: number): 'minutes' | 'hours' | 'days' => {
@ -92,7 +92,6 @@ export const ReleasePlanMilestoneItem = ({
const isNotLastMilestone = index < milestones.length - 1; const isNotLastMilestone = index < milestones.length - 1;
const isProgressionFormOpen = progressionFormOpenIndex === index; const isProgressionFormOpen = progressionFormOpenIndex === index;
const nextMilestoneId = milestones[index + 1]?.id || '';
const handleOpenProgressionForm = () => const handleOpenProgressionForm = () =>
onSetProgressionFormOpenIndex(index); onSetProgressionFormOpenIndex(index);
const handleCloseProgressionForm = () => const handleCloseProgressionForm = () =>
@ -134,7 +133,7 @@ export const ReleasePlanMilestoneItem = ({
text: 'Automation configured successfully', text: 'Automation configured successfully',
}); });
handleCloseProgressionForm(); handleCloseProgressionForm();
await onUpdate(); onUpdate?.();
return {}; return {};
} catch (error: unknown) { } catch (error: unknown) {
setToastApiError(formatUnknownError(error)); setToastApiError(formatUnknownError(error));
@ -166,15 +165,11 @@ export const ReleasePlanMilestoneItem = ({
const { pendingProgressionChange, effectiveTransitionCondition } = const { pendingProgressionChange, effectiveTransitionCondition } =
getPendingProgressionData(milestone, getPendingProgressionChange); getPendingProgressionData(milestone, getPendingProgressionChange);
const shouldShowAutomation = const automationSection = (
isNotLastMilestone && milestoneProgressionsEnabled && !readonly;
const automationSection = shouldShowAutomation ? (
<MilestoneAutomation <MilestoneAutomation
milestone={milestone} milestone={milestone}
milestones={milestones}
status={status} status={status}
isNotLastMilestone={isNotLastMilestone}
nextMilestoneId={nextMilestoneId}
milestoneProgressionsEnabled={milestoneProgressionsEnabled} milestoneProgressionsEnabled={milestoneProgressionsEnabled}
readonly={readonly} readonly={readonly}
isProgressionFormOpen={isProgressionFormOpen} isProgressionFormOpen={isProgressionFormOpen}
@ -185,7 +180,7 @@ export const ReleasePlanMilestoneItem = ({
onChangeProgression={handleChangeProgression} onChangeProgression={handleChangeProgression}
onDeleteProgression={onDeleteProgression} onDeleteProgression={onDeleteProgression}
/> />
) : undefined; );
return ( return (
<div key={milestone.id}> <div key={milestone.id}>

View File

@ -1,5 +1,8 @@
import type { IReleasePlanMilestone } from 'interfaces/releasePlans'; import type { IReleasePlanMilestone } from 'interfaces/releasePlans';
import type { MilestoneStatus } from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx'; import type {
MilestoneStatus,
MilestoneProgressionStatus,
} from '../ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx';
import { calculateMilestoneStartTime } from '../utils/calculateMilestoneStartTime.js'; import { calculateMilestoneStartTime } from '../utils/calculateMilestoneStartTime.js';
export const calculateMilestoneStatus = ( export const calculateMilestoneStatus = (
@ -10,16 +13,18 @@ export const calculateMilestoneStatus = (
environmentIsDisabled: boolean | undefined, environmentIsDisabled: boolean | undefined,
allMilestones: IReleasePlanMilestone[], allMilestones: IReleasePlanMilestone[],
): MilestoneStatus => { ): MilestoneStatus => {
if (milestone.pausedAt) { const progression: MilestoneProgressionStatus = milestone.pausedAt
return { type: 'paused' }; ? 'paused'
} : 'active';
if (milestone.id === activeMilestoneId) { if (milestone.id === activeMilestoneId) {
return environmentIsDisabled ? { type: 'paused' } : { type: 'active' }; return environmentIsDisabled
? { type: 'paused', progression }
: { type: 'active', progression };
} }
if (index < activeIndex) { if (index < activeIndex) {
return { type: 'completed' }; return { type: 'completed', progression };
} }
const scheduledAt = calculateMilestoneStartTime( const scheduledAt = calculateMilestoneStartTime(
@ -31,5 +36,6 @@ export const calculateMilestoneStatus = (
return { return {
type: 'not-started', type: 'not-started',
scheduledAt: scheduledAt || undefined, scheduledAt: scheduledAt || undefined,
progression,
}; };
}; };

View File

@ -26,6 +26,20 @@ import type { ISafeguard } from 'interfaces/releasePlans.ts';
const StyledIcon = createStyledIcon(ShieldIcon); const StyledIcon = createStyledIcon(ShieldIcon);
export const useSafeguardForm = (safeguards: ISafeguard[] | undefined) => {
const [safeguardFormOpen, setSafeguardFormOpen] = useState(false);
useEffect(() => {
if (safeguards && safeguards.length > 0) {
setSafeguardFormOpen(true);
} else {
setSafeguardFormOpen(false);
}
}, [JSON.stringify(safeguards)]);
return { safeguardFormOpen, setSafeguardFormOpen };
};
interface ISafeguardFormProps { interface ISafeguardFormProps {
onSubmit: (data: CreateSafeguardSchema) => void; onSubmit: (data: CreateSafeguardSchema) => void;
onCancel: () => void; onCancel: () => void;
@ -182,7 +196,7 @@ export const SafeguardForm = ({
threshold: Number(threshold), threshold: Number(threshold),
}); });
if (mode === 'edit') { if (mode === 'edit' || mode === 'create') {
setMode('display'); setMode('display');
} }
}; };
@ -232,80 +246,92 @@ export const SafeguardForm = ({
</IconButton> </IconButton>
)} )}
</StyledTopRow> </StyledTopRow>
<StyledTopRow> <StyledTopRow sx={{ ml: 3 }}>
<MetricSelector <MetricSelector
value={metricName} value={metricName}
onChange={handleMetricChange} onChange={handleMetricChange}
options={metricOptions} options={metricOptions}
loading={loading} loading={loading}
label=''
/> />
<StyledLabel>filtered by</StyledLabel> <StyledTopRow>
<FormControl variant='outlined' size='small'> <StyledLabel>filtered by</StyledLabel>
<StyledSelect <FormControl variant='outlined' size='small'>
value={appName} <StyledSelect
onChange={(e) => value={appName}
handleApplicationChange(String(e.target.value)) onChange={(e) =>
} handleApplicationChange(String(e.target.value))
variant='outlined' }
size='small' variant='outlined'
> size='small'
{applicationNames.map((app) => ( >
<StyledMenuItem key={app} value={app}> {applicationNames.map((app) => (
{app === '*' ? 'All' : app} <StyledMenuItem key={app} value={app}>
</StyledMenuItem> {app === '*' ? 'All' : app}
))} </StyledMenuItem>
</StyledSelect> ))}
</FormControl> </StyledSelect>
</FormControl>
</StyledTopRow>
<StyledLabel>aggregated by</StyledLabel> <StyledTopRow>
<ModeSelector <StyledLabel>aggregated by</StyledLabel>
value={aggregationMode} <ModeSelector
onChange={handleAggregationModeChange} value={aggregationMode}
metricType={metricType} onChange={handleAggregationModeChange}
/> metricType={metricType}
</StyledTopRow> label=''
<StyledTopRow>
<StyledLabel>is</StyledLabel>
<FormControl variant='outlined' size='small'>
<StyledSelect
value={operator}
onChange={(e) =>
handleOperatorChange(
e.target.value as CreateSafeguardSchemaOperator,
)
}
variant='outlined'
size='small'
>
<StyledMenuItem value='>'>More than</StyledMenuItem>
<StyledMenuItem value='<'>Less than</StyledMenuItem>
</StyledSelect>
</FormControl>
<FormControl variant='outlined' size='small'>
<TextField
type='number'
inputProps={{
step: 0.1,
}}
value={threshold}
onChange={(e) => {
const value = e.target.value;
handleThresholdChange(Number(value));
}}
placeholder='Value'
variant='outlined'
size='small'
required
/> />
</FormControl> </StyledTopRow>
</StyledTopRow>
<StyledTopRow sx={{ ml: 0.75 }}>
<StyledTopRow>
<StyledLabel>is</StyledLabel>
<FormControl variant='outlined' size='small'>
<StyledSelect
value={operator}
onChange={(e) =>
handleOperatorChange(
e.target
.value as CreateSafeguardSchemaOperator,
)
}
variant='outlined'
size='small'
>
<StyledMenuItem value='>'>More than</StyledMenuItem>
<StyledMenuItem value='<'>Less than</StyledMenuItem>
</StyledSelect>
</FormControl>
<StyledLabel>over</StyledLabel> <FormControl variant='outlined' size='small'>
<RangeSelector <TextField
value={timeRange} type='number'
onChange={handleTimeRangeChange} inputProps={{
/> step: 0.1,
}}
value={threshold}
onChange={(e) => {
const value = e.target.value;
handleThresholdChange(Number(value));
}}
placeholder='Value'
variant='outlined'
size='small'
required
/>
</FormControl>
</StyledTopRow>
<StyledTopRow>
<StyledLabel>over</StyledLabel>
<RangeSelector
value={timeRange}
onChange={handleTimeRangeChange}
label=''
/>
</StyledTopRow>
</StyledTopRow> </StyledTopRow>
{showButtons && ( {showButtons && (
<StyledButtonGroup> <StyledButtonGroup>

View File

@ -8,7 +8,11 @@ export const useMilestoneProgressionInfo = (
status?: MilestoneStatus, status?: MilestoneStatus,
) => { ) => {
const { locationSettings } = useLocationSettings(); const { locationSettings } = useLocationSettings();
if (!status || status.type !== 'active') { if (
!status ||
status.type !== 'active' ||
status.progression === 'paused'
) {
return null; return null;
} }

View File

@ -6,22 +6,26 @@ export type ModeSelectorProps = {
value: AggregationMode; value: AggregationMode;
onChange: (mode: AggregationMode) => void; onChange: (mode: AggregationMode) => void;
metricType: 'counter' | 'gauge' | 'histogram' | 'unknown'; metricType: 'counter' | 'gauge' | 'histogram' | 'unknown';
label?: string;
}; };
export const ModeSelector: FC<ModeSelectorProps> = ({ export const ModeSelector: FC<ModeSelectorProps> = ({
value, value,
onChange, onChange,
metricType, metricType,
label = 'Aggregation Mode',
}) => { }) => {
if (metricType === 'unknown') return null; if (metricType === 'unknown') return null;
return ( return (
<FormControl variant='outlined' size='small'> <FormControl variant='outlined' size='small'>
<InputLabel id='mode-select-label'>Mode</InputLabel> {label ? (
<InputLabel id='mode-select-label'>{label}</InputLabel>
) : null}
<Select <Select
labelId='mode-select-label' labelId='mode-select-label'
value={value} value={value}
onChange={(e) => onChange(e.target.value as AggregationMode)} onChange={(e) => onChange(e.target.value as AggregationMode)}
label='Mode' label={label}
> >
{metricType === 'counter' {metricType === 'counter'
? [ ? [

View File

@ -6,16 +6,23 @@ export type TimeRange = 'hour' | 'day' | 'week' | 'month';
export type RangeSelectorProps = { export type RangeSelectorProps = {
value: TimeRange; value: TimeRange;
onChange: (range: TimeRange) => void; onChange: (range: TimeRange) => void;
label?: string;
}; };
export const RangeSelector: FC<RangeSelectorProps> = ({ value, onChange }) => ( export const RangeSelector: FC<RangeSelectorProps> = ({
value,
onChange,
label = 'Time',
}) => (
<FormControl variant='outlined' size='small'> <FormControl variant='outlined' size='small'>
<InputLabel id='range-select-label'>Time</InputLabel> {label ? (
<InputLabel id='range-select-label'>{label}</InputLabel>
) : null}
<Select <Select
labelId='range-select-label' labelId='range-select-label'
value={value} value={value}
onChange={(e) => onChange(e.target.value as TimeRange)} onChange={(e) => onChange(e.target.value as TimeRange)}
label='Time Range' label={label}
> >
<MenuItem value='hour'>Last hour</MenuItem> <MenuItem value='hour'>Last hour</MenuItem>
<MenuItem value='day'>Last 24 hours</MenuItem> <MenuItem value='day'>Last 24 hours</MenuItem>

View File

@ -9,6 +9,7 @@ export type SeriesSelectorProps = {
onChange: (series: string) => void; onChange: (series: string) => void;
options: SeriesOption[]; options: SeriesOption[];
loading?: boolean; loading?: boolean;
label?: string;
}; };
export const MetricSelector: FC<SeriesSelectorProps> = ({ export const MetricSelector: FC<SeriesSelectorProps> = ({
@ -16,12 +17,15 @@ export const MetricSelector: FC<SeriesSelectorProps> = ({
onChange, onChange,
options, options,
loading = false, loading = false,
label = 'Metric name',
}) => ( }) => (
<Autocomplete <Autocomplete
options={options} options={options}
getOptionLabel={(option) => option.displayName} getOptionLabel={(option) => option.displayName}
value={options.find((option) => option.name === value) || null} value={options.find((option) => option.name === value) || null}
onChange={(_, newValue) => onChange(newValue?.name || '')} onChange={(_, newValue) =>
onChange(newValue?.name || options[0]?.name || '')
}
disabled={loading} disabled={loading}
renderOption={(props, option, { inputValue }) => ( renderOption={(props, option, { inputValue }) => (
<Box component='li' {...props} key={option.name}> <Box component='li' {...props} key={option.name}>
@ -42,7 +46,7 @@ export const MetricSelector: FC<SeriesSelectorProps> = ({
renderInput={(params) => ( renderInput={(params) => (
<TextField <TextField
{...params} {...params}
label='Data series' label={label}
placeholder='Search for a metric…' placeholder='Search for a metric…'
variant='outlined' variant='outlined'
size='small' size='small'

View File

@ -1,5 +1,4 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import useUiConfig from '../useUiConfig/useUiConfig.js';
import { formatApiPath } from 'utils/formatPath'; import { formatApiPath } from 'utils/formatPath';
import handleErrorResponses from '../httpErrorResponseHandler.js'; import handleErrorResponses from '../httpErrorResponseHandler.js';
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR.js'; import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR.js';
@ -10,11 +9,10 @@ import { useUiFlag } from 'hooks/useUiFlag';
const DEFAULT_DATA: ConnectedEdge[] = []; const DEFAULT_DATA: ConnectedEdge[] = [];
export const useConnectedEdges = (options?: SWRConfiguration) => { export const useConnectedEdges = (options?: SWRConfiguration) => {
const { isEnterprise } = useUiConfig();
const edgeObservabilityEnabled = useUiFlag('edgeObservability'); const edgeObservabilityEnabled = useUiFlag('edgeObservability');
const { data, error, mutate } = useConditionalSWR<ConnectedEdge[]>( const { data, error, mutate } = useConditionalSWR<ConnectedEdge[]>(
isEnterprise() && edgeObservabilityEnabled, edgeObservabilityEnabled,
DEFAULT_DATA, DEFAULT_DATA,
formatApiPath('api/admin/metrics/edges'), formatApiPath('api/admin/metrics/edges'),
fetcher, fetcher,

View File

@ -11,9 +11,14 @@ export const useFeatureReleasePlans = (
const { const {
releasePlans: releasePlansFromHook, releasePlans: releasePlansFromHook,
refetch: refetchReleasePlans, refetch: refetchReleasePlans,
loading: releasePlansLoading,
...rest ...rest
} = useReleasePlans(projectId, featureId, environmentName); } = useReleasePlans(projectId, featureId, environmentName);
const { feature, refetchFeature } = useFeature(projectId, featureId); const {
feature,
refetchFeature,
loading: featureLoading,
} = useFeature(projectId, featureId);
let releasePlans = releasePlansFromHook; let releasePlans = releasePlansFromHook;
@ -28,5 +33,10 @@ export const useFeatureReleasePlans = (
? refetchFeature ? refetchFeature
: refetchReleasePlans; : refetchReleasePlans;
return { releasePlans, refetch, ...rest }; return {
releasePlans,
refetch,
loading: featureLoading || releasePlansLoading,
...rest,
};
}; };

View File

@ -58,14 +58,15 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
stopTimer(); stopTimer();
return []; return [];
} }
const baseTime = new Date();
const result = await this.db('feature_lifecycles') const result = await this.db('feature_lifecycles')
.insert( .insert(
validStages.map((stage) => ({ validStages.map((stage, index) => ({
feature: stage.feature, feature: stage.feature,
stage: stage.stage, stage: stage.stage,
status: stage.status, status: stage.status,
status_value: stage.statusValue, status_value: stage.statusValue,
created_at: new Date(), created_at: new Date(baseTime.getTime() + index), // prevent identical times for stages in bulk update
})), })),
) )
.returning('*') .returning('*')

View File

@ -38,7 +38,9 @@ beforeAll(async () => {
db.stores, db.stores,
{ {
experimental: { experimental: {
flags: {}, flags: {
optimizeLifecycle: true,
},
}, },
}, },
db.rawDatabase, db.rawDatabase,
@ -178,6 +180,9 @@ test('should return lifecycle stages', async () => {
enteredStageAt: expect.any(String), enteredStageAt: expect.any(String),
}, },
]); ]);
expect(new Date(body[2].enteredStageAt).getTime()).toBeGreaterThan(
new Date(body[1].enteredStageAt).getTime(),
);
await expectFeatureStage('my_feature_a', 'archived'); await expectFeatureStage('my_feature_a', 'archived');
eventStore.emit(FEATURE_REVIVED, { featureName: 'my_feature_a' }); eventStore.emit(FEATURE_REVIVED, { featureName: 'my_feature_a' });

View File

@ -239,8 +239,8 @@ export default class ClientMetricsController extends Controller {
} else { } else {
const { body, ip: clientIp } = req; const { body, ip: clientIp } = req;
const { metrics, applications, impactMetrics } = body; const { metrics, applications, impactMetrics } = body;
const promises: Promise<void>[] = [];
try { try {
const promises: Promise<void>[] = [];
for (const app of applications) { for (const app of applications) {
if ( if (
app.sdkType === 'frontend' && app.sdkType === 'frontend' &&
@ -287,10 +287,32 @@ export default class ClientMetricsController extends Controller {
); );
} }
await Promise.all(promises); const results = await Promise.allSettled(promises);
const rejected = results.filter(
res.status(202).end(); (result): result is PromiseRejectedResult =>
result.status === 'rejected',
);
if (rejected.length) {
this.logger.warn(
'Some bulkMetrics tasks failed',
rejected.map((r) => r.reason?.message || r.reason),
);
res.status(400).end();
} else {
res.status(202).end();
}
} catch (e) { } catch (e) {
const results = await Promise.allSettled(promises);
const rejected = results.filter(
(result): result is PromiseRejectedResult =>
result.status === 'rejected',
);
if (rejected.length) {
this.logger.warn(
'Some bulkMetrics tasks failed',
rejected.map((r) => r.reason?.message || r.reason),
);
}
res.status(400).end(); res.status(400).end();
} }
} }

View File

@ -2,7 +2,7 @@ import nock from 'nock';
import createStores from '../../test/fixtures/store.js'; import createStores from '../../test/fixtures/store.js';
import version from '../util/version.js'; import version from '../util/version.js';
import getLogger from '../../test/fixtures/no-logger.js'; import getLogger from '../../test/fixtures/no-logger.js';
import VersionService from './version-service.js'; import VersionService, { type IInstanceInfo } from './version-service.js';
import { randomId } from '../util/random-id.js'; import { randomId } from '../util/random-id.js';
beforeAll(() => { beforeAll(() => {
@ -347,3 +347,64 @@ test('Counts production changes', async () => {
expect(scope.isDone()).toEqual(true); expect(scope.isDone()).toEqual(true);
nock.cleanAll(); nock.cleanAll();
}); });
describe('instance info reading', () => {
test('it sets instance info if the instanceInfoProvider promise returns a truthy value', async () => {
const instanceInfo: IInstanceInfo = {
customerPlan: 'Test Plan',
customerName: 'Test Company',
clientId: 'Test Id',
};
const url = `https://${randomId()}.example.com`;
const scope = nock(url)
.post(
'/',
(body) =>
body.instanceInfo &&
body.instanceInfo.customerPlan ===
instanceInfo.customerPlan &&
body.instanceInfo.customerName ===
instanceInfo.customerName &&
body.instanceInfo.clientId === instanceInfo.clientId,
)
.reply(() => [200]);
const stores = createStores();
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
telemetry: true,
});
await service.checkLatestVersion(
() => Promise.resolve(fakeTelemetryData),
() => Promise.resolve(instanceInfo),
);
expect(scope.isDone()).toEqual(true);
});
test.each([
['is undefined', undefined],
['returns undefined', () => Promise.resolve(undefined)],
])(
'it does not set instance info if the instanceInfoProvider promise %s',
async (_, instanceInfoProvider) => {
const url = `https://${randomId()}.example.com`;
const scope = nock(url)
.post('/', (body) => body.instanceInfo === undefined)
.reply(() => [200]);
const stores = createStores();
const service = new VersionService(stores, {
getLogger,
versionCheck: { url, enable: true },
telemetry: true,
});
await service.checkLatestVersion(
() => Promise.resolve(fakeTelemetryData),
instanceInfoProvider,
);
expect(scope.isDone()).toEqual(true);
},
);
});

View File

@ -58,6 +58,12 @@ export interface IFeatureUsageInfo {
edgeInstanceUsage?: EdgeInstanceUsage; edgeInstanceUsage?: EdgeInstanceUsage;
} }
export type IInstanceInfo = Partial<{
customerPlan: string;
customerName: string;
clientId: string;
}>;
export default class VersionService { export default class VersionService {
private logger: Logger; private logger: Logger;
@ -131,6 +137,7 @@ export default class VersionService {
async checkLatestVersion( async checkLatestVersion(
telemetryDataProvider: () => Promise<IFeatureUsageInfo>, telemetryDataProvider: () => Promise<IFeatureUsageInfo>,
instanceInfoProvider?: () => Promise<IInstanceInfo | undefined>,
): Promise<void> { ): Promise<void> {
const instanceId = await this.getInstanceId(); const instanceId = await this.getInstanceId();
this.logger.debug( this.logger.debug(
@ -145,6 +152,10 @@ export default class VersionService {
if (this.telemetryEnabled) { if (this.telemetryEnabled) {
versionPayload.featureInfo = await telemetryDataProvider(); versionPayload.featureInfo = await telemetryDataProvider();
const instanceInfo = await instanceInfoProvider?.();
if (instanceInfo) {
versionPayload.instanceInfo = instanceInfo;
}
} }
if (this.versionCheckUrl) { if (this.versionCheckUrl) {
const res = await ky.post(this.versionCheckUrl, { const res = await ky.post(this.versionCheckUrl, {

View File

@ -52,7 +52,7 @@ If you are a new customer, your account representative will provide the required
If you are an existing customer and are making changes to your agreement (changing seat count or the contract expiration), contact your account representative to obtain the required license key. If you are an existing customer and are making changes to your agreement (changing seat count or the contract expiration), contact your account representative to obtain the required license key.
Alternatively, you can reach out to sales@getunleash.io. Alternatively, you can reach out to license@getunleash.io.
## Check your current license ## Check your current license

View File

@ -19,4 +19,4 @@ For a detailed overview of how [Unleash Enterprise](https://www.getunleash.io/pr
- [ISO 27001](/privacy-and-compliance/iso27001) - [ISO 27001](/privacy-and-compliance/iso27001)
For information regarding any other frameworks, [reach out to us](mailto:sales@getunleash.io). For information regarding any other frameworks, [reach out to us](mailto:license@getunleash.io).

View File

@ -47,7 +47,7 @@ The [Jira server plugin is available in the Atlassian marketplace](https://marke
You'll need to download the plugin and create a license key. You'll need to download the plugin and create a license key.
If you have an Unleash enterprise license you're welcome to reach out to us at sales@getunleash.io for a free plugin license, otherwise you'll need to try the plugin for 30 days free or purchase a license through the marketplace. If you have an Unleash enterprise license you're welcome to reach out to us at license@getunleash.io for a free plugin license, otherwise you'll need to try the plugin for 30 days free or purchase a license through the marketplace.
Once you've downloaded the plugin artifact, you'll need to follow the Manage apps link in Jira's administration menu. Once you've downloaded the plugin artifact, you'll need to follow the Manage apps link in Jira's administration menu.