From 87e901256b73e07590c27122b84781e42bddb7f1 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Thu, 20 Nov 2025 16:47:51 +0100 Subject: [PATCH 01/10] feat: safeguard UI tweaks (#11008) --- .../ReleasePlan/ReleasePlan.tsx | 10 +- .../SafeguardForm/SafeguardForm.tsx | 140 ++++++++++-------- .../ModeSelector/ModeSelector.tsx | 8 +- .../RangeSelector/RangeSelector.tsx | 13 +- .../SeriesSelector/MetricSelector.tsx | 8 +- .../useFeatureReleasePlans.ts | 14 +- 6 files changed, 114 insertions(+), 79 deletions(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index 779a77c23a..822bf503d6 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -137,11 +137,8 @@ export const ReleasePlan = ({ } = plan; const projectId = useRequiredPathParam('projectId'); - const { refetch } = useFeatureReleasePlans( - projectId, - featureName, - environment, - ); + const { refetch, loading: featureReleasePlansLoading } = + useFeatureReleasePlans(projectId, featureName, environment); const { removeReleasePlanFromFeature, startReleasePlanMilestone } = useReleasePlansApi(); const { @@ -532,7 +529,7 @@ export const ReleasePlan = ({ onCancel={() => setSafeguardFormOpen(false)} onDelete={handleSafeguardDelete} /> - ) : safeguardFormOpen ? ( + ) : safeguardFormOpen || featureReleasePlansLoading ? ( setSafeguardFormOpen(false)} @@ -542,6 +539,7 @@ export const ReleasePlan = ({ onClick={() => setSafeguardFormOpen(true)} color='primary' startIcon={} + sx={{ m: 2 }} > Add safeguard diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx index aa74caeb58..876a932b24 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx @@ -232,80 +232,92 @@ export const SafeguardForm = ({ )} - + - filtered by - - - handleApplicationChange(String(e.target.value)) - } - variant='outlined' - size='small' - > - {applicationNames.map((app) => ( - - {app === '*' ? 'All' : app} - - ))} - - + + filtered by + + + handleApplicationChange(String(e.target.value)) + } + variant='outlined' + size='small' + > + {applicationNames.map((app) => ( + + {app === '*' ? 'All' : app} + + ))} + + + - aggregated by - - - - is - - - handleOperatorChange( - e.target.value as CreateSafeguardSchemaOperator, - ) - } - variant='outlined' - size='small' - > - More than - Less than - - - - - { - const value = e.target.value; - handleThresholdChange(Number(value)); - }} - placeholder='Value' - variant='outlined' - size='small' - required + + aggregated by + - + + + + + is + + + handleOperatorChange( + e.target + .value as CreateSafeguardSchemaOperator, + ) + } + variant='outlined' + size='small' + > + More than + Less than + + - over - + + { + const value = e.target.value; + handleThresholdChange(Number(value)); + }} + placeholder='Value' + variant='outlined' + size='small' + required + /> + + + + + over + + {showButtons && ( diff --git a/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ModeSelector/ModeSelector.tsx b/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ModeSelector/ModeSelector.tsx index 5ec8e97e9f..a8bbe5021c 100644 --- a/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ModeSelector/ModeSelector.tsx +++ b/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/ModeSelector/ModeSelector.tsx @@ -6,22 +6,26 @@ export type ModeSelectorProps = { value: AggregationMode; onChange: (mode: AggregationMode) => void; metricType: 'counter' | 'gauge' | 'histogram' | 'unknown'; + label?: string; }; export const ModeSelector: FC = ({ value, onChange, metricType, + label = 'Aggregation Mode', }) => { if (metricType === 'unknown') return null; return ( - Mode + {label ? ( + {label} + ) : null} onChange(e.target.value as TimeRange)} - label='Time Range' + label={label} > Last hour Last 24 hours diff --git a/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/SeriesSelector/MetricSelector.tsx b/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/SeriesSelector/MetricSelector.tsx index e221114f49..44c87c68f4 100644 --- a/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/SeriesSelector/MetricSelector.tsx +++ b/frontend/src/component/impact-metrics/ChartConfigModal/ImpactMetricsControls/SeriesSelector/MetricSelector.tsx @@ -9,6 +9,7 @@ export type SeriesSelectorProps = { onChange: (series: string) => void; options: SeriesOption[]; loading?: boolean; + label?: string; }; export const MetricSelector: FC = ({ @@ -16,12 +17,15 @@ export const MetricSelector: FC = ({ onChange, options, loading = false, + label = 'Metric name', }) => ( 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 }) => ( @@ -42,7 +46,7 @@ export const MetricSelector: FC = ({ renderInput={(params) => ( Date: Thu, 20 Nov 2025 17:38:08 +0000 Subject: [PATCH 02/10] chore: show connected edges for pro customers (#11009) https://linear.app/unleash/issue/2-4040/show-connected-edges-for-pro-customers Show connected Edges for Pro customers. This was previously filtered to Enterprise only instances. --- .../hooks/api/getters/useConnectedEdges/useConnectedEdges.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts b/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts index 06ddd7638a..cb57d512f0 100644 --- a/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts +++ b/frontend/src/hooks/api/getters/useConnectedEdges/useConnectedEdges.ts @@ -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( - isEnterprise() && edgeObservabilityEnabled, + edgeObservabilityEnabled, DEFAULT_DATA, formatApiPath('api/admin/metrics/edges'), fetcher, From e97768957184f6576f9e59de3754fd36d6c543a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gast=C3=B3n=20Fournier?= Date: Fri, 21 Nov 2025 10:27:00 +0100 Subject: [PATCH 03/10] fix: invalid metrics should not crash (#11010) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## About the changes Properly awaits all submitted promises, preventing the node's main process from seeing rejected & unawaited promises. What's going on? - The bulk metrics handler pushes `registerBackendClient` promises into promises. - The next step (`clientMetricsEnvBulkSchema.validateAsync`) throws for invalid metrics (e.g., `appName: null`), so we jump to catch and return 400. - Because the code never reaches `Promise.all(...)`, the previously spawned promises are never awaited. Node later detects the rejected `registerBackendClient` promise as an **unhandled rejection** and crashes the process. If that promise hadn’t been rejected, there’d be no crash, but with invalid input, it does reject. - **Fix:** always await the spawned tasks (using `Promise.allSettled`) so every rejection is observed, even when validation later throws. --- src/lib/features/metrics/instance/metrics.ts | 30 +++++++++++++++++--- 1 file changed, 26 insertions(+), 4 deletions(-) diff --git a/src/lib/features/metrics/instance/metrics.ts b/src/lib/features/metrics/instance/metrics.ts index 30e9ff06b5..d51a440228 100644 --- a/src/lib/features/metrics/instance/metrics.ts +++ b/src/lib/features/metrics/instance/metrics.ts @@ -239,8 +239,8 @@ export default class ClientMetricsController extends Controller { } else { const { body, ip: clientIp } = req; const { metrics, applications, impactMetrics } = body; + const promises: Promise[] = []; try { - const promises: Promise[] = []; 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(); } } From 63e969821c9706b1b1b187f5a9d43b75e90bd592 Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Fri, 21 Nov 2025 11:39:22 +0100 Subject: [PATCH 04/10] feat: safeguard border dynamic line (#11011) --- .../ReleasePlan/ReleasePlan.tsx | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index 822bf503d6..2695640346 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -78,19 +78,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), })); @@ -473,6 +475,13 @@ export const ReleasePlan = ({ } }; + const safeguardBorder = + safeguardsEnabled && safeguards + ? safeguards[0] + ? 'solid' + : 'dashed' + : null; + return ( @@ -519,9 +528,9 @@ export const ReleasePlan = ({ ) : null} - + {safeguardsEnabled ? ( - + {safeguards.length > 0 ? ( Date: Fri, 21 Nov 2025 16:00:45 +0100 Subject: [PATCH 05/10] refactor: simplify safeguard form management (#11013) --- .../EnvironmentAccordionBody.tsx | 3 +- .../ReleasePlan/ReleasePlan.tsx | 41 +++++++++---------- .../ReleasePlanMilestoneItem.tsx | 4 +- .../SafeguardForm/SafeguardForm.tsx | 16 +++++++- 4 files changed, 38 insertions(+), 26 deletions(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx index acfe27e1a7..b4e1dcf065 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/FeatureOverviewEnvironments/FeatureOverviewEnvironment/EnvironmentAccordionBody/EnvironmentAccordionBody.tsx @@ -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 = ({ ))} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx index 2695640346..c721d84ae1 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlan.tsx @@ -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'; @@ -120,12 +122,14 @@ interface IReleasePlanProps { plan: IReleasePlan; environmentIsDisabled?: boolean; readonly?: boolean; + onAutomationChange?: () => void; } export const ReleasePlan = ({ plan, environmentIsDisabled, readonly, + onAutomationChange, }: IReleasePlanProps) => { const { id, @@ -139,8 +143,6 @@ export const ReleasePlan = ({ } = plan; const projectId = useRequiredPathParam('projectId'); - const { refetch, loading: featureReleasePlansLoading } = - useFeatureReleasePlans(projectId, featureName, environment); const { removeReleasePlanFromFeature, startReleasePlanMilestone } = useReleasePlansApi(); const { @@ -221,9 +223,11 @@ export const ReleasePlan = ({ >(null); const [milestoneToDeleteProgression, setMilestoneToDeleteProgression] = useState(null); - const [safeguardFormOpen, setSafeguardFormOpen] = useState(false); + const [safeguardDeleteDialogOpen, setSafeguardDeleteDialogOpen] = useState(false); + const { safeguardFormOpen, setSafeguardFormOpen } = + useSafeguardForm(safeguards); const onChangeRequestConfirm = async () => { if (!changeRequestAction) return; @@ -311,7 +315,7 @@ export const ReleasePlan = ({ type: 'success', }); - refetch(); + onAutomationChange?.(); setRemoveOpen(false); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); @@ -337,7 +341,7 @@ export const ReleasePlan = ({ text: `Milestone "${milestone.name}" has started`, type: 'success', }); - refetch(); + onAutomationChange?.(); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); } @@ -387,7 +391,7 @@ export const ReleasePlan = ({ featureName, sourceMilestoneId: milestoneToDeleteProgression.id, }); - await refetch(); + onAutomationChange?.(); setMilestoneToDeleteProgression(null); setToastData({ type: 'success', @@ -411,7 +415,7 @@ export const ReleasePlan = ({ type: 'success', text: 'Automation resumed successfully', }); - refetch(); + onAutomationChange?.(); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); } @@ -434,11 +438,9 @@ export const ReleasePlan = ({ type: 'success', text: 'Safeguard added successfully', }); - refetch(); + onAutomationChange?.(); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); - } finally { - setSafeguardFormOpen(false); } }; @@ -461,7 +463,7 @@ export const ReleasePlan = ({ type: 'success', text: 'Safeguard deleted successfully', }); - refetch(); + onAutomationChange?.(); } catch (error: unknown) { setToastApiError(formatUnknownError(error)); } finally { @@ -529,20 +531,15 @@ export const ReleasePlan = ({ ) : null} - {safeguardsEnabled ? ( + {onAutomationChange && safeguardsEnabled ? ( - {safeguards.length > 0 ? ( + {safeguardFormOpen ? ( setSafeguardFormOpen(false)} onDelete={handleSafeguardDelete} /> - ) : safeguardFormOpen || featureReleasePlansLoading ? ( - setSafeguardFormOpen(false)} - /> ) : ( setSafeguardFormOpen(true)} @@ -582,7 +579,7 @@ export const ReleasePlan = ({ projectId={projectId} environment={environment} featureName={featureName} - onUpdate={refetch} + onUpdate={onAutomationChange} /> ))} diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx index d1b1f8c38a..9cbe107780 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/ReleasePlanMilestoneItem.tsx @@ -52,7 +52,7 @@ export interface IReleasePlanMilestoneItemProps { projectId: string; environment: string; featureName: string; - onUpdate: () => void | Promise; + onUpdate?: () => void; } const getTimeUnit = (intervalMinutes: number): 'minutes' | 'hours' | 'days' => { @@ -134,7 +134,7 @@ export const ReleasePlanMilestoneItem = ({ text: 'Automation configured successfully', }); handleCloseProgressionForm(); - await onUpdate(); + onUpdate?.(); return {}; } catch (error: unknown) { setToastApiError(formatUnknownError(error)); diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx index 876a932b24..db86b751f1 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx @@ -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'); } }; From d6af401dd2d110af61342c7df812a6917dd4acaa Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 24 Nov 2025 11:01:38 +0200 Subject: [PATCH 06/10] feat: remove milestone progression adding when paused (#11012) --- .../MilestoneAutomation.tsx | 15 ++++++++++----- .../ReleasePlanMilestoneItem.tsx | 11 +++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx index d99ac6eacc..174f8446c7 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx @@ -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; @@ -89,7 +94,7 @@ export const MilestoneAutomation = ({ status={status} badge={badge} /> - ) : ( + ) : hasAnyPausedMilestone ? null : ( onSetProgressionFormOpenIndex(index); const handleCloseProgressionForm = () => @@ -166,15 +165,11 @@ export const ReleasePlanMilestoneItem = ({ const { pendingProgressionChange, effectiveTransitionCondition } = getPendingProgressionData(milestone, getPendingProgressionChange); - const shouldShowAutomation = - isNotLastMilestone && milestoneProgressionsEnabled && !readonly; - - const automationSection = shouldShowAutomation ? ( + const automationSection = ( - ) : undefined; + ); return (
From 123ca034ee8c0956785cf150c030123ac0643be1 Mon Sep 17 00:00:00 2001 From: Jaanus Sellin Date: Mon, 24 Nov 2025 12:15:31 +0200 Subject: [PATCH 07/10] feat: split milestone paused with progression paused (#11015) --- .../Changes/Change/MilestoneListRenderer.tsx | 5 ++++- .../ReleasePlanMilestone.tsx | 2 +- .../ReleasePlanMilestoneStatus.tsx | 14 ++++++++++---- .../MilestoneAutomation.tsx | 2 +- .../milestoneStatusUtils.ts | 18 ++++++++++++------ .../hooks/useMilestoneProgressionInfo.ts | 6 +++++- 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx index 6ce7492ca1..4399790476 100644 --- a/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx +++ b/frontend/src/component/changeRequest/ChangeRequest/Changes/Change/MilestoneListRenderer.tsx @@ -37,7 +37,10 @@ const MilestoneListRendererCore = ({ onUpdateAutomation, onDeleteAutomation, }: MilestoneListRendererCoreProps) => { - const status: MilestoneStatus = { type: 'not-started' }; + const status: MilestoneStatus = { + type: 'not-started', + progression: 'active', + }; return ( <> diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx index c57c24ec55..d7af87dba5 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestone.tsx @@ -109,7 +109,7 @@ interface IReleasePlanMilestoneProps { export const ReleasePlanMilestone = ({ milestone, - status = { type: 'not-started' }, + status = { type: 'not-started', progression: 'active' }, onStartMilestone, readonly, automationSection, diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx index 717bd7984c..93e427bb1a 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestone/ReleasePlanMilestoneStatus.tsx @@ -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 }) => ({ diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx index 174f8446c7..b62bd6a4a1 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/MilestoneAutomation.tsx @@ -64,7 +64,7 @@ export const MilestoneAutomation = ({ Deleted in draft ) : hasPendingChange ? ( Modified in draft - ) : status?.type === 'paused' ? ( + ) : status?.progression === 'paused' ? ( }> Paused diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts index 6186aeef02..3a3bfe4f1e 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/ReleasePlanMilestoneItem/milestoneStatusUtils.ts @@ -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, }; }; diff --git a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts index 2f986f3769..348ee7b310 100644 --- a/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts +++ b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/hooks/useMilestoneProgressionInfo.ts @@ -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; } From e455426b3ced8c371bfa5430176e535d71bd5083 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nuno=20G=C3=B3is?= Date: Mon, 24 Nov 2025 11:39:06 +0000 Subject: [PATCH 08/10] chore: use license address instead of sales (#11016) https://linear.app/unleash/issue/2-4042/use-licensegetunleashio-instead-of-salesgetunleashio Prefer `license@getunleash.io` over `sales@getunleash.io` for reach outs. --- .../network/NetworkConnectedEdges/NetworkConnectedEdges.tsx | 2 +- .../reference/integrations/jira-server-plugin-installation.md | 2 +- website/docs/using-unleash/compliance/compliance-overview.mdx | 2 +- website/docs/using-unleash/deploy/license-keys.mdx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx index 7463c8c0ab..109ad128cb 100644 --- a/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx +++ b/frontend/src/component/admin/network/NetworkConnectedEdges/NetworkConnectedEdges.tsx @@ -177,7 +177,7 @@ export const NetworkConnectedEdges = () => {

Interested in getting started?{' '} - + Contact us diff --git a/website/docs/reference/integrations/jira-server-plugin-installation.md b/website/docs/reference/integrations/jira-server-plugin-installation.md index 86ee8e2de9..0760a814d0 100644 --- a/website/docs/reference/integrations/jira-server-plugin-installation.md +++ b/website/docs/reference/integrations/jira-server-plugin-installation.md @@ -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. diff --git a/website/docs/using-unleash/compliance/compliance-overview.mdx b/website/docs/using-unleash/compliance/compliance-overview.mdx index 332c85f8ce..19ce6067ce 100644 --- a/website/docs/using-unleash/compliance/compliance-overview.mdx +++ b/website/docs/using-unleash/compliance/compliance-overview.mdx @@ -19,4 +19,4 @@ For a detailed overview of how [Unleash Enterprise](https://www.getunleash.io/pr - [ISO 27001](/using-unleash/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). diff --git a/website/docs/using-unleash/deploy/license-keys.mdx b/website/docs/using-unleash/deploy/license-keys.mdx index 14c5dd6746..75a5eb5a66 100644 --- a/website/docs/using-unleash/deploy/license-keys.mdx +++ b/website/docs/using-unleash/deploy/license-keys.mdx @@ -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 From bbad97a9e4a801eb0fd9a8062e6395bee3c2266e Mon Sep 17 00:00:00 2001 From: Mateusz Kwasniewski Date: Mon, 24 Nov 2025 12:43:14 +0100 Subject: [PATCH 09/10] fix: prevent duplicate lifecycle timing (#11017) --- .../features/feature-lifecycle/feature-lifecycle-store.ts | 5 +++-- .../feature-lifecycle/feature-lifecycle.e2e.test.ts | 7 ++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts index f113824b08..5b9a625ff5 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle-store.ts @@ -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('*') diff --git a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts index ef19c39edd..daa4495a59 100644 --- a/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts +++ b/src/lib/features/feature-lifecycle/feature-lifecycle.e2e.test.ts @@ -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' }); From 712cecf38d23b245a788a3fe370f988bc034de3c Mon Sep 17 00:00:00 2001 From: Thomas Heartman Date: Mon, 24 Nov 2025 15:52:59 +0100 Subject: [PATCH 10/10] feat: allow 'instanceInfo' to be added to the version checker hook (#11018) Allows the `checkLatestVersion` function in the `VersionService` to accept an optional `instanceInfo` parameter. If provided, and if the promise returns a value that is truthy, then it will add `instanceInfo` to the versionPayload. The license key may not contain a plan or a customer name, and while it definitely won't contain a client id, it has been requested that we report `self-hosted` as the client ID (will be handled in enterprise). Adding a second, optional parameter seemed to be the most backwards compatible way of doing this rather than changing the established method / callback types. --- src/lib/services/version-service.test.ts | 63 +++++++++++++++++++++++- src/lib/services/version-service.ts | 11 +++++ 2 files changed, 73 insertions(+), 1 deletion(-) diff --git a/src/lib/services/version-service.test.ts b/src/lib/services/version-service.test.ts index 8f96e15d4f..daf4336f59 100644 --- a/src/lib/services/version-service.test.ts +++ b/src/lib/services/version-service.test.ts @@ -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); + }, + ); +}); diff --git a/src/lib/services/version-service.ts b/src/lib/services/version-service.ts index 639d876018..51baaf7009 100644 --- a/src/lib/services/version-service.ts +++ b/src/lib/services/version-service.ts @@ -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, + instanceInfoProvider?: () => Promise, ): Promise { 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, {