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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) => ({

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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'
? [

View File

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

View File

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

View File

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

View File

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

View File

@ -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('*')

View File

@ -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' });

View File

@ -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();
}
}

View File

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

View File

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

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.
Alternatively, you can reach out to sales@getunleash.io.
Alternatively, you can reach out to license@getunleash.io.
## 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)
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.
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.