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/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/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 779a77c23a..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'; @@ -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(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 ( @@ -522,26 +530,22 @@ export const ReleasePlan = ({ ) : null} - - {safeguardsEnabled ? ( - - {safeguards.length > 0 ? ( + + {onAutomationChange && safeguardsEnabled ? ( + + {safeguardFormOpen ? ( setSafeguardFormOpen(false)} onDelete={handleSafeguardDelete} /> - ) : safeguardFormOpen ? ( - setSafeguardFormOpen(false)} - /> ) : ( setSafeguardFormOpen(true)} color='primary' startIcon={} + sx={{ m: 2 }} > Add safeguard @@ -575,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/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 d99ac6eacc..b62bd6a4a1 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; @@ -59,7 +64,7 @@ export const MilestoneAutomation = ({ Deleted in draft ) : hasPendingChange ? ( Modified in draft - ) : status?.type === 'paused' ? ( + ) : status?.progression === 'paused' ? ( }> Paused @@ -89,7 +94,7 @@ export const MilestoneAutomation = ({ status={status} badge={badge} /> - ) : ( + ) : hasAnyPausedMilestone ? null : ( void | Promise; + 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 = ( - ) : undefined; + ); return (
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/SafeguardForm/SafeguardForm.tsx b/frontend/src/component/feature/FeatureView/FeatureOverview/ReleasePlan/SafeguardForm/SafeguardForm.tsx index aa74caeb58..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'); } }; @@ -232,80 +246,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/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; } 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) => ( { - const { isEnterprise } = useUiConfig(); const edgeObservabilityEnabled = useUiFlag('edgeObservability'); const { data, error, mutate } = useConditionalSWR( - isEnterprise() && edgeObservabilityEnabled, + edgeObservabilityEnabled, DEFAULT_DATA, formatApiPath('api/admin/metrics/edges'), fetcher, diff --git a/frontend/src/hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans.ts b/frontend/src/hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans.ts index e50fe9fec3..de1d0e7f78 100644 --- a/frontend/src/hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans.ts +++ b/frontend/src/hooks/api/getters/useFeatureReleasePlans/useFeatureReleasePlans.ts @@ -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, + }; }; 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' }); 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(); } } 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, { diff --git a/website/docs/deploy/license-keys.mdx b/website/docs/deploy/license-keys.mdx index ac97303f31..78ee1d6e41 100644 --- a/website/docs/deploy/license-keys.mdx +++ b/website/docs/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 diff --git a/website/docs/privacy-and-compliance/compliance-overview.mdx b/website/docs/privacy-and-compliance/compliance-overview.mdx index 9204d1a6f4..8d2ce32459 100644 --- a/website/docs/privacy-and-compliance/compliance-overview.mdx +++ b/website/docs/privacy-and-compliance/compliance-overview.mdx @@ -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). 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.