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:
commit
59e9fb0ea0
@ -177,7 +177,7 @@ export const NetworkConnectedEdges = () => {
|
||||
<br />
|
||||
<br />
|
||||
Interested in getting started?{' '}
|
||||
<a href={`mailto:sales@getunleash.io?subject=Enterprise Edge`}>
|
||||
<a href='mailto:license@getunleash.io?subject=Enterprise Edge'>
|
||||
Contact us
|
||||
</a>
|
||||
</Alert>
|
||||
|
||||
@ -37,7 +37,10 @@ const MilestoneListRendererCore = ({
|
||||
onUpdateAutomation,
|
||||
onDeleteAutomation,
|
||||
}: MilestoneListRendererCoreProps) => {
|
||||
const status: MilestoneStatus = { type: 'not-started' };
|
||||
const status: MilestoneStatus = {
|
||||
type: 'not-started',
|
||||
progression: 'active',
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
@ -66,7 +66,7 @@ export const EnvironmentAccordionBody = ({
|
||||
const [strategies, setStrategies] = useState(
|
||||
featureEnvironment?.strategies || [],
|
||||
);
|
||||
const { releasePlans } = useFeatureReleasePlans(
|
||||
const { releasePlans, refetch } = useFeatureReleasePlans(
|
||||
projectId,
|
||||
featureId,
|
||||
featureEnvironment?.name,
|
||||
@ -229,6 +229,7 @@ export const EnvironmentAccordionBody = ({
|
||||
<ReleasePlan
|
||||
plan={plan}
|
||||
environmentIsDisabled={isDisabled}
|
||||
onAutomationChange={refetch}
|
||||
/>
|
||||
</StrategyListItem>
|
||||
))}
|
||||
|
||||
@ -4,7 +4,6 @@ import PlayCircle from '@mui/icons-material/PlayCircle';
|
||||
import { DELETE_FEATURE_STRATEGY } from '@server/types/permissions';
|
||||
import PermissionIconButton from 'component/common/PermissionIconButton/PermissionIconButton';
|
||||
import { useReleasePlansApi } from 'hooks/api/actions/useReleasePlansApi/useReleasePlansApi';
|
||||
import { useFeatureReleasePlans } from 'hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans';
|
||||
import { useRequiredPathParam } from 'hooks/useRequiredPathParam';
|
||||
import useToast from 'hooks/useToast';
|
||||
import type {
|
||||
@ -32,7 +31,10 @@ import { ReleasePlanMilestoneItem } from './ReleasePlanMilestoneItem/ReleasePlan
|
||||
import Add from '@mui/icons-material/Add';
|
||||
|
||||
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 type { CreateSafeguardSchema } from 'openapi/models/createSafeguardSchema';
|
||||
import { DeleteSafeguardDialog } from './DeleteSafeguardDialog.tsx';
|
||||
@ -78,19 +80,21 @@ const StyledHeaderDescription = styled('p')(({ theme }) => ({
|
||||
}));
|
||||
|
||||
const StyledBody = styled('div', {
|
||||
shouldForwardProp: (prop) => prop !== 'safeguards',
|
||||
})<{ safeguards: boolean }>(({ theme, safeguards }) => ({
|
||||
shouldForwardProp: (prop) => prop !== 'border',
|
||||
})<{ border: 'solid' | 'dashed' | null }>(({ theme, border }) => ({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
...(safeguards && {
|
||||
border: `1px dashed ${theme.palette.neutral.border}`,
|
||||
...(border && {
|
||||
border: `1px ${border} ${theme.palette.neutral.border}`,
|
||||
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',
|
||||
borderBottom: `1px dashed ${theme.palette.neutral.border}`,
|
||||
borderBottom: `1px ${border || 'dashed'} ${theme.palette.neutral.border}`,
|
||||
padding: theme.spacing(0.25, 0.25),
|
||||
}));
|
||||
|
||||
@ -118,12 +122,14 @@ interface IReleasePlanProps {
|
||||
plan: IReleasePlan;
|
||||
environmentIsDisabled?: boolean;
|
||||
readonly?: boolean;
|
||||
onAutomationChange?: () => void;
|
||||
}
|
||||
|
||||
export const ReleasePlan = ({
|
||||
plan,
|
||||
environmentIsDisabled,
|
||||
readonly,
|
||||
onAutomationChange,
|
||||
}: IReleasePlanProps) => {
|
||||
const {
|
||||
id,
|
||||
@ -137,11 +143,6 @@ export const ReleasePlan = ({
|
||||
} = plan;
|
||||
|
||||
const projectId = useRequiredPathParam('projectId');
|
||||
const { refetch } = useFeatureReleasePlans(
|
||||
projectId,
|
||||
featureName,
|
||||
environment,
|
||||
);
|
||||
const { removeReleasePlanFromFeature, startReleasePlanMilestone } =
|
||||
useReleasePlansApi();
|
||||
const {
|
||||
@ -222,9 +223,11 @@ export const ReleasePlan = ({
|
||||
>(null);
|
||||
const [milestoneToDeleteProgression, setMilestoneToDeleteProgression] =
|
||||
useState<IReleasePlanMilestone | null>(null);
|
||||
const [safeguardFormOpen, setSafeguardFormOpen] = useState(false);
|
||||
|
||||
const [safeguardDeleteDialogOpen, setSafeguardDeleteDialogOpen] =
|
||||
useState(false);
|
||||
const { safeguardFormOpen, setSafeguardFormOpen } =
|
||||
useSafeguardForm(safeguards);
|
||||
|
||||
const onChangeRequestConfirm = async () => {
|
||||
if (!changeRequestAction) return;
|
||||
@ -312,7 +315,7 @@ export const ReleasePlan = ({
|
||||
type: 'success',
|
||||
});
|
||||
|
||||
refetch();
|
||||
onAutomationChange?.();
|
||||
setRemoveOpen(false);
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
@ -338,7 +341,7 @@ export const ReleasePlan = ({
|
||||
text: `Milestone "${milestone.name}" has started`,
|
||||
type: 'success',
|
||||
});
|
||||
refetch();
|
||||
onAutomationChange?.();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
@ -388,7 +391,7 @@ export const ReleasePlan = ({
|
||||
featureName,
|
||||
sourceMilestoneId: milestoneToDeleteProgression.id,
|
||||
});
|
||||
await refetch();
|
||||
onAutomationChange?.();
|
||||
setMilestoneToDeleteProgression(null);
|
||||
setToastData({
|
||||
type: 'success',
|
||||
@ -412,7 +415,7 @@ export const ReleasePlan = ({
|
||||
type: 'success',
|
||||
text: 'Automation resumed successfully',
|
||||
});
|
||||
refetch();
|
||||
onAutomationChange?.();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
}
|
||||
@ -435,11 +438,9 @@ export const ReleasePlan = ({
|
||||
type: 'success',
|
||||
text: 'Safeguard added successfully',
|
||||
});
|
||||
refetch();
|
||||
onAutomationChange?.();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
} finally {
|
||||
setSafeguardFormOpen(false);
|
||||
}
|
||||
};
|
||||
|
||||
@ -462,7 +463,7 @@ export const ReleasePlan = ({
|
||||
type: 'success',
|
||||
text: 'Safeguard deleted successfully',
|
||||
});
|
||||
refetch();
|
||||
onAutomationChange?.();
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
} finally {
|
||||
@ -476,6 +477,13 @@ export const ReleasePlan = ({
|
||||
}
|
||||
};
|
||||
|
||||
const safeguardBorder =
|
||||
safeguardsEnabled && safeguards
|
||||
? safeguards[0]
|
||||
? 'solid'
|
||||
: 'dashed'
|
||||
: null;
|
||||
|
||||
return (
|
||||
<StyledContainer>
|
||||
<StyledHeader>
|
||||
@ -522,26 +530,22 @@ export const ReleasePlan = ({
|
||||
</StyledAlert>
|
||||
) : null}
|
||||
|
||||
<StyledBody safeguards={safeguardsEnabled}>
|
||||
{safeguardsEnabled ? (
|
||||
<StyledAddSafeguard>
|
||||
{safeguards.length > 0 ? (
|
||||
<StyledBody border={safeguardBorder}>
|
||||
{onAutomationChange && safeguardsEnabled ? (
|
||||
<StyledAddSafeguard border={safeguardBorder}>
|
||||
{safeguardFormOpen ? (
|
||||
<SafeguardForm
|
||||
safeguard={safeguards[0]}
|
||||
safeguard={safeguards?.[0]}
|
||||
onSubmit={handleSafeguardSubmit}
|
||||
onCancel={() => setSafeguardFormOpen(false)}
|
||||
onDelete={handleSafeguardDelete}
|
||||
/>
|
||||
) : safeguardFormOpen ? (
|
||||
<SafeguardForm
|
||||
onSubmit={handleSafeguardSubmit}
|
||||
onCancel={() => setSafeguardFormOpen(false)}
|
||||
/>
|
||||
) : (
|
||||
<StyledActionButton
|
||||
onClick={() => setSafeguardFormOpen(true)}
|
||||
color='primary'
|
||||
startIcon={<Add />}
|
||||
sx={{ m: 2 }}
|
||||
>
|
||||
Add safeguard
|
||||
</StyledActionButton>
|
||||
@ -575,7 +579,7 @@ export const ReleasePlan = ({
|
||||
projectId={projectId}
|
||||
environment={environment}
|
||||
featureName={featureName}
|
||||
onUpdate={refetch}
|
||||
onUpdate={onAutomationChange}
|
||||
/>
|
||||
))}
|
||||
</StyledMilestones>
|
||||
|
||||
@ -109,7 +109,7 @@ interface IReleasePlanMilestoneProps {
|
||||
|
||||
export const ReleasePlanMilestone = ({
|
||||
milestone,
|
||||
status = { type: 'not-started' },
|
||||
status = { type: 'not-started', progression: 'active' },
|
||||
onStartMilestone,
|
||||
readonly,
|
||||
automationSection,
|
||||
|
||||
@ -4,11 +4,17 @@ import PauseCircleIcon from '@mui/icons-material/PauseCircle';
|
||||
import TripOriginIcon from '@mui/icons-material/TripOrigin';
|
||||
import { useUiFlag } from 'hooks/useUiFlag';
|
||||
|
||||
export type MilestoneProgressionStatus = 'paused' | 'active';
|
||||
|
||||
export type MilestoneStatus =
|
||||
| { type: 'not-started'; scheduledAt?: Date }
|
||||
| { type: 'active' }
|
||||
| { type: 'paused' }
|
||||
| { type: 'completed' };
|
||||
| {
|
||||
type: 'not-started';
|
||||
scheduledAt?: Date;
|
||||
progression: MilestoneProgressionStatus;
|
||||
}
|
||||
| { type: 'active'; progression: MilestoneProgressionStatus }
|
||||
| { type: 'paused'; progression: MilestoneProgressionStatus }
|
||||
| { type: 'completed'; progression: MilestoneProgressionStatus };
|
||||
|
||||
const BaseStatusButton = styled('button')<{ disabled?: boolean }>(
|
||||
({ theme, disabled }) => ({
|
||||
|
||||
@ -12,9 +12,8 @@ import { StyledActionButton } from './StyledActionButton.tsx';
|
||||
|
||||
interface MilestoneAutomationProps {
|
||||
milestone: IReleasePlanMilestone;
|
||||
milestones: IReleasePlanMilestone[];
|
||||
status: MilestoneStatus;
|
||||
isNotLastMilestone: boolean;
|
||||
nextMilestoneId: string;
|
||||
milestoneProgressionsEnabled: boolean;
|
||||
readonly: boolean | undefined;
|
||||
isProgressionFormOpen: boolean;
|
||||
@ -30,9 +29,8 @@ interface MilestoneAutomationProps {
|
||||
|
||||
export const MilestoneAutomation = ({
|
||||
milestone,
|
||||
milestones,
|
||||
status,
|
||||
isNotLastMilestone,
|
||||
nextMilestoneId,
|
||||
milestoneProgressionsEnabled,
|
||||
readonly,
|
||||
isProgressionFormOpen,
|
||||
@ -43,6 +41,13 @@ export const MilestoneAutomation = ({
|
||||
onChangeProgression,
|
||||
onDeleteProgression,
|
||||
}: 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 =
|
||||
milestoneProgressionsEnabled && isNotLastMilestone && !readonly;
|
||||
|
||||
@ -59,7 +64,7 @@ export const MilestoneAutomation = ({
|
||||
<Badge color='error'>Deleted in draft</Badge>
|
||||
) : hasPendingChange ? (
|
||||
<Badge color='warning'>Modified in draft</Badge>
|
||||
) : status?.type === 'paused' ? (
|
||||
) : status?.progression === 'paused' ? (
|
||||
<Badge color='error' icon={<WarningAmber fontSize='small' />}>
|
||||
Paused
|
||||
</Badge>
|
||||
@ -89,7 +94,7 @@ export const MilestoneAutomation = ({
|
||||
status={status}
|
||||
badge={badge}
|
||||
/>
|
||||
) : (
|
||||
) : hasAnyPausedMilestone ? null : (
|
||||
<StyledActionButton
|
||||
onClick={onOpenProgressionForm}
|
||||
color='primary'
|
||||
|
||||
@ -52,7 +52,7 @@ export interface IReleasePlanMilestoneItemProps {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
featureName: string;
|
||||
onUpdate: () => void | Promise<void>;
|
||||
onUpdate?: () => void;
|
||||
}
|
||||
|
||||
const getTimeUnit = (intervalMinutes: number): 'minutes' | 'hours' | 'days' => {
|
||||
@ -92,7 +92,6 @@ export const ReleasePlanMilestoneItem = ({
|
||||
|
||||
const isNotLastMilestone = index < milestones.length - 1;
|
||||
const isProgressionFormOpen = progressionFormOpenIndex === index;
|
||||
const nextMilestoneId = milestones[index + 1]?.id || '';
|
||||
const handleOpenProgressionForm = () =>
|
||||
onSetProgressionFormOpenIndex(index);
|
||||
const handleCloseProgressionForm = () =>
|
||||
@ -134,7 +133,7 @@ export const ReleasePlanMilestoneItem = ({
|
||||
text: 'Automation configured successfully',
|
||||
});
|
||||
handleCloseProgressionForm();
|
||||
await onUpdate();
|
||||
onUpdate?.();
|
||||
return {};
|
||||
} catch (error: unknown) {
|
||||
setToastApiError(formatUnknownError(error));
|
||||
@ -166,15 +165,11 @@ export const ReleasePlanMilestoneItem = ({
|
||||
const { pendingProgressionChange, effectiveTransitionCondition } =
|
||||
getPendingProgressionData(milestone, getPendingProgressionChange);
|
||||
|
||||
const shouldShowAutomation =
|
||||
isNotLastMilestone && milestoneProgressionsEnabled && !readonly;
|
||||
|
||||
const automationSection = shouldShowAutomation ? (
|
||||
const automationSection = (
|
||||
<MilestoneAutomation
|
||||
milestone={milestone}
|
||||
milestones={milestones}
|
||||
status={status}
|
||||
isNotLastMilestone={isNotLastMilestone}
|
||||
nextMilestoneId={nextMilestoneId}
|
||||
milestoneProgressionsEnabled={milestoneProgressionsEnabled}
|
||||
readonly={readonly}
|
||||
isProgressionFormOpen={isProgressionFormOpen}
|
||||
@ -185,7 +180,7 @@ export const ReleasePlanMilestoneItem = ({
|
||||
onChangeProgression={handleChangeProgression}
|
||||
onDeleteProgression={onDeleteProgression}
|
||||
/>
|
||||
) : undefined;
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={milestone.id}>
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
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';
|
||||
|
||||
export const calculateMilestoneStatus = (
|
||||
@ -10,16 +13,18 @@ export const calculateMilestoneStatus = (
|
||||
environmentIsDisabled: boolean | undefined,
|
||||
allMilestones: IReleasePlanMilestone[],
|
||||
): MilestoneStatus => {
|
||||
if (milestone.pausedAt) {
|
||||
return { type: 'paused' };
|
||||
}
|
||||
const progression: MilestoneProgressionStatus = milestone.pausedAt
|
||||
? 'paused'
|
||||
: 'active';
|
||||
|
||||
if (milestone.id === activeMilestoneId) {
|
||||
return environmentIsDisabled ? { type: 'paused' } : { type: 'active' };
|
||||
return environmentIsDisabled
|
||||
? { type: 'paused', progression }
|
||||
: { type: 'active', progression };
|
||||
}
|
||||
|
||||
if (index < activeIndex) {
|
||||
return { type: 'completed' };
|
||||
return { type: 'completed', progression };
|
||||
}
|
||||
|
||||
const scheduledAt = calculateMilestoneStartTime(
|
||||
@ -31,5 +36,6 @@ export const calculateMilestoneStatus = (
|
||||
return {
|
||||
type: 'not-started',
|
||||
scheduledAt: scheduledAt || undefined,
|
||||
progression,
|
||||
};
|
||||
};
|
||||
|
||||
@ -26,6 +26,20 @@ import type { ISafeguard } from 'interfaces/releasePlans.ts';
|
||||
|
||||
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 {
|
||||
onSubmit: (data: CreateSafeguardSchema) => void;
|
||||
onCancel: () => void;
|
||||
@ -182,7 +196,7 @@ export const SafeguardForm = ({
|
||||
threshold: Number(threshold),
|
||||
});
|
||||
|
||||
if (mode === 'edit') {
|
||||
if (mode === 'edit' || mode === 'create') {
|
||||
setMode('display');
|
||||
}
|
||||
};
|
||||
@ -232,80 +246,92 @@ export const SafeguardForm = ({
|
||||
</IconButton>
|
||||
)}
|
||||
</StyledTopRow>
|
||||
<StyledTopRow>
|
||||
<StyledTopRow sx={{ ml: 3 }}>
|
||||
<MetricSelector
|
||||
value={metricName}
|
||||
onChange={handleMetricChange}
|
||||
options={metricOptions}
|
||||
loading={loading}
|
||||
label=''
|
||||
/>
|
||||
|
||||
<StyledLabel>filtered by</StyledLabel>
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<StyledSelect
|
||||
value={appName}
|
||||
onChange={(e) =>
|
||||
handleApplicationChange(String(e.target.value))
|
||||
}
|
||||
variant='outlined'
|
||||
size='small'
|
||||
>
|
||||
{applicationNames.map((app) => (
|
||||
<StyledMenuItem key={app} value={app}>
|
||||
{app === '*' ? 'All' : app}
|
||||
</StyledMenuItem>
|
||||
))}
|
||||
</StyledSelect>
|
||||
</FormControl>
|
||||
<StyledTopRow>
|
||||
<StyledLabel>filtered by</StyledLabel>
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<StyledSelect
|
||||
value={appName}
|
||||
onChange={(e) =>
|
||||
handleApplicationChange(String(e.target.value))
|
||||
}
|
||||
variant='outlined'
|
||||
size='small'
|
||||
>
|
||||
{applicationNames.map((app) => (
|
||||
<StyledMenuItem key={app} value={app}>
|
||||
{app === '*' ? 'All' : app}
|
||||
</StyledMenuItem>
|
||||
))}
|
||||
</StyledSelect>
|
||||
</FormControl>
|
||||
</StyledTopRow>
|
||||
|
||||
<StyledLabel>aggregated by</StyledLabel>
|
||||
<ModeSelector
|
||||
value={aggregationMode}
|
||||
onChange={handleAggregationModeChange}
|
||||
metricType={metricType}
|
||||
/>
|
||||
</StyledTopRow>
|
||||
<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
|
||||
<StyledTopRow>
|
||||
<StyledLabel>aggregated by</StyledLabel>
|
||||
<ModeSelector
|
||||
value={aggregationMode}
|
||||
onChange={handleAggregationModeChange}
|
||||
metricType={metricType}
|
||||
label=''
|
||||
/>
|
||||
</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>
|
||||
<RangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
/>
|
||||
<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>
|
||||
<StyledLabel>over</StyledLabel>
|
||||
<RangeSelector
|
||||
value={timeRange}
|
||||
onChange={handleTimeRangeChange}
|
||||
label=''
|
||||
/>
|
||||
</StyledTopRow>
|
||||
</StyledTopRow>
|
||||
{showButtons && (
|
||||
<StyledButtonGroup>
|
||||
|
||||
@ -8,7 +8,11 @@ export const useMilestoneProgressionInfo = (
|
||||
status?: MilestoneStatus,
|
||||
) => {
|
||||
const { locationSettings } = useLocationSettings();
|
||||
if (!status || status.type !== 'active') {
|
||||
if (
|
||||
!status ||
|
||||
status.type !== 'active' ||
|
||||
status.progression === 'paused'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -6,22 +6,26 @@ export type ModeSelectorProps = {
|
||||
value: AggregationMode;
|
||||
onChange: (mode: AggregationMode) => void;
|
||||
metricType: 'counter' | 'gauge' | 'histogram' | 'unknown';
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const ModeSelector: FC<ModeSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
metricType,
|
||||
label = 'Aggregation Mode',
|
||||
}) => {
|
||||
if (metricType === 'unknown') return null;
|
||||
return (
|
||||
<FormControl variant='outlined' size='small'>
|
||||
<InputLabel id='mode-select-label'>Mode</InputLabel>
|
||||
{label ? (
|
||||
<InputLabel id='mode-select-label'>{label}</InputLabel>
|
||||
) : null}
|
||||
<Select
|
||||
labelId='mode-select-label'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as AggregationMode)}
|
||||
label='Mode'
|
||||
label={label}
|
||||
>
|
||||
{metricType === 'counter'
|
||||
? [
|
||||
|
||||
@ -6,16 +6,23 @@ export type TimeRange = 'hour' | 'day' | 'week' | 'month';
|
||||
export type RangeSelectorProps = {
|
||||
value: TimeRange;
|
||||
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'>
|
||||
<InputLabel id='range-select-label'>Time</InputLabel>
|
||||
{label ? (
|
||||
<InputLabel id='range-select-label'>{label}</InputLabel>
|
||||
) : null}
|
||||
<Select
|
||||
labelId='range-select-label'
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value as TimeRange)}
|
||||
label='Time Range'
|
||||
label={label}
|
||||
>
|
||||
<MenuItem value='hour'>Last hour</MenuItem>
|
||||
<MenuItem value='day'>Last 24 hours</MenuItem>
|
||||
|
||||
@ -9,6 +9,7 @@ export type SeriesSelectorProps = {
|
||||
onChange: (series: string) => void;
|
||||
options: SeriesOption[];
|
||||
loading?: boolean;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MetricSelector: FC<SeriesSelectorProps> = ({
|
||||
@ -16,12 +17,15 @@ export const MetricSelector: FC<SeriesSelectorProps> = ({
|
||||
onChange,
|
||||
options,
|
||||
loading = false,
|
||||
label = 'Metric name',
|
||||
}) => (
|
||||
<Autocomplete
|
||||
options={options}
|
||||
getOptionLabel={(option) => option.displayName}
|
||||
value={options.find((option) => option.name === value) || null}
|
||||
onChange={(_, newValue) => onChange(newValue?.name || '')}
|
||||
onChange={(_, newValue) =>
|
||||
onChange(newValue?.name || options[0]?.name || '')
|
||||
}
|
||||
disabled={loading}
|
||||
renderOption={(props, option, { inputValue }) => (
|
||||
<Box component='li' {...props} key={option.name}>
|
||||
@ -42,7 +46,7 @@ export const MetricSelector: FC<SeriesSelectorProps> = ({
|
||||
renderInput={(params) => (
|
||||
<TextField
|
||||
{...params}
|
||||
label='Data series'
|
||||
label={label}
|
||||
placeholder='Search for a metric…'
|
||||
variant='outlined'
|
||||
size='small'
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import { useMemo } from 'react';
|
||||
import useUiConfig from '../useUiConfig/useUiConfig.js';
|
||||
import { formatApiPath } from 'utils/formatPath';
|
||||
import handleErrorResponses from '../httpErrorResponseHandler.js';
|
||||
import { useConditionalSWR } from '../useConditionalSWR/useConditionalSWR.js';
|
||||
@ -10,11 +9,10 @@ import { useUiFlag } from 'hooks/useUiFlag';
|
||||
const DEFAULT_DATA: ConnectedEdge[] = [];
|
||||
|
||||
export const useConnectedEdges = (options?: SWRConfiguration) => {
|
||||
const { isEnterprise } = useUiConfig();
|
||||
const edgeObservabilityEnabled = useUiFlag('edgeObservability');
|
||||
|
||||
const { data, error, mutate } = useConditionalSWR<ConnectedEdge[]>(
|
||||
isEnterprise() && edgeObservabilityEnabled,
|
||||
edgeObservabilityEnabled,
|
||||
DEFAULT_DATA,
|
||||
formatApiPath('api/admin/metrics/edges'),
|
||||
fetcher,
|
||||
|
||||
@ -11,9 +11,14 @@ export const useFeatureReleasePlans = (
|
||||
const {
|
||||
releasePlans: releasePlansFromHook,
|
||||
refetch: refetchReleasePlans,
|
||||
loading: releasePlansLoading,
|
||||
...rest
|
||||
} = useReleasePlans(projectId, featureId, environmentName);
|
||||
const { feature, refetchFeature } = useFeature(projectId, featureId);
|
||||
const {
|
||||
feature,
|
||||
refetchFeature,
|
||||
loading: featureLoading,
|
||||
} = useFeature(projectId, featureId);
|
||||
|
||||
let releasePlans = releasePlansFromHook;
|
||||
|
||||
@ -28,5 +33,10 @@ export const useFeatureReleasePlans = (
|
||||
? refetchFeature
|
||||
: refetchReleasePlans;
|
||||
|
||||
return { releasePlans, refetch, ...rest };
|
||||
return {
|
||||
releasePlans,
|
||||
refetch,
|
||||
loading: featureLoading || releasePlansLoading,
|
||||
...rest,
|
||||
};
|
||||
};
|
||||
|
||||
@ -58,14 +58,15 @@ export class FeatureLifecycleStore implements IFeatureLifecycleStore {
|
||||
stopTimer();
|
||||
return [];
|
||||
}
|
||||
const baseTime = new Date();
|
||||
const result = await this.db('feature_lifecycles')
|
||||
.insert(
|
||||
validStages.map((stage) => ({
|
||||
validStages.map((stage, index) => ({
|
||||
feature: stage.feature,
|
||||
stage: stage.stage,
|
||||
status: stage.status,
|
||||
status_value: stage.statusValue,
|
||||
created_at: new Date(),
|
||||
created_at: new Date(baseTime.getTime() + index), // prevent identical times for stages in bulk update
|
||||
})),
|
||||
)
|
||||
.returning('*')
|
||||
|
||||
@ -38,7 +38,9 @@ beforeAll(async () => {
|
||||
db.stores,
|
||||
{
|
||||
experimental: {
|
||||
flags: {},
|
||||
flags: {
|
||||
optimizeLifecycle: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
db.rawDatabase,
|
||||
@ -178,6 +180,9 @@ test('should return lifecycle stages', async () => {
|
||||
enteredStageAt: expect.any(String),
|
||||
},
|
||||
]);
|
||||
expect(new Date(body[2].enteredStageAt).getTime()).toBeGreaterThan(
|
||||
new Date(body[1].enteredStageAt).getTime(),
|
||||
);
|
||||
await expectFeatureStage('my_feature_a', 'archived');
|
||||
|
||||
eventStore.emit(FEATURE_REVIVED, { featureName: 'my_feature_a' });
|
||||
|
||||
@ -239,8 +239,8 @@ export default class ClientMetricsController extends Controller {
|
||||
} else {
|
||||
const { body, ip: clientIp } = req;
|
||||
const { metrics, applications, impactMetrics } = body;
|
||||
const promises: Promise<void>[] = [];
|
||||
try {
|
||||
const promises: Promise<void>[] = [];
|
||||
for (const app of applications) {
|
||||
if (
|
||||
app.sdkType === 'frontend' &&
|
||||
@ -287,10 +287,32 @@ export default class ClientMetricsController extends Controller {
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(promises);
|
||||
|
||||
res.status(202).end();
|
||||
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();
|
||||
} else {
|
||||
res.status(202).end();
|
||||
}
|
||||
} 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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,7 +2,7 @@ import nock from 'nock';
|
||||
import createStores from '../../test/fixtures/store.js';
|
||||
import version from '../util/version.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';
|
||||
|
||||
beforeAll(() => {
|
||||
@ -347,3 +347,64 @@ test('Counts production changes', async () => {
|
||||
expect(scope.isDone()).toEqual(true);
|
||||
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);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@ -58,6 +58,12 @@ export interface IFeatureUsageInfo {
|
||||
edgeInstanceUsage?: EdgeInstanceUsage;
|
||||
}
|
||||
|
||||
export type IInstanceInfo = Partial<{
|
||||
customerPlan: string;
|
||||
customerName: string;
|
||||
clientId: string;
|
||||
}>;
|
||||
|
||||
export default class VersionService {
|
||||
private logger: Logger;
|
||||
|
||||
@ -131,6 +137,7 @@ export default class VersionService {
|
||||
|
||||
async checkLatestVersion(
|
||||
telemetryDataProvider: () => Promise<IFeatureUsageInfo>,
|
||||
instanceInfoProvider?: () => Promise<IInstanceInfo | undefined>,
|
||||
): Promise<void> {
|
||||
const instanceId = await this.getInstanceId();
|
||||
this.logger.debug(
|
||||
@ -145,6 +152,10 @@ export default class VersionService {
|
||||
|
||||
if (this.telemetryEnabled) {
|
||||
versionPayload.featureInfo = await telemetryDataProvider();
|
||||
const instanceInfo = await instanceInfoProvider?.();
|
||||
if (instanceInfo) {
|
||||
versionPayload.instanceInfo = instanceInfo;
|
||||
}
|
||||
}
|
||||
if (this.versionCheckUrl) {
|
||||
const res = await ky.post(this.versionCheckUrl, {
|
||||
|
||||
@ -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.
|
||||
|
||||
Alternatively, you can reach out to sales@getunleash.io.
|
||||
Alternatively, you can reach out to license@getunleash.io.
|
||||
|
||||
## Check your current license
|
||||
|
||||
|
||||
@ -19,4 +19,4 @@ For a detailed overview of how [Unleash Enterprise](https://www.getunleash.io/pr
|
||||
- [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).
|
||||
|
||||
@ -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.
|
||||
|
||||
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.
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user