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 />
|
||||||
<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>
|
||||||
|
|||||||
@ -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 (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@ -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>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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 }) => ({
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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}>
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
? [
|
? [
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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('*')
|
||||||
|
|||||||
@ -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' });
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|||||||
@ -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, {
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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).
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user